diff --git a/.changeset/genui-components.md b/.changeset/genui-components.md new file mode 100644 index 0000000000..7daa6bc20e --- /dev/null +++ b/.changeset/genui-components.md @@ -0,0 +1,5 @@ +--- +"@openuidev/react-ui": minor +--- + +Rework the generative UI component library and its system prompt. New components: Section, Grid, Shell, Bleed, Heading, Metric, Avatar, AvatarStack, Emoji, Shape, Link, LeadingText, EntityTitle, ImageTitle, EntityListItem, Checklist, Timeline, InsightCard, ValueCard, ImageCard, ValueLayout and FormRow, plus a Sparkline chart. `TextContent` becomes `Text` and `MarkDownRenderer` becomes `Markdown` (with `==highlight==` and favicon links). `ContextCardBlock` and `SnippetCardBlock` are removed; `IconText` becomes the archived `IconTitle`, and CardHeader, InlineHeader, Label, BoldText, TextCallout, MetricIndicator, ImageBlock, ImageText, OverviewCardBlock and VisualCardBlock move to `openuiArchivedComponents`, out of the model-facing libraries. Cards, Callout, Tag, Chips, OptionCards, Table, EditableTable (now `EditableCol`), form fields, Calendar, Modal, Carousel, ImageGallery and lists are restyled, and the theme adds a Bold `wght` axis token. diff --git a/docs/app/components/blocks/lists/page.tsx b/docs/app/components/blocks/lists/page.tsx index 8e07c26ce8..a51fb20505 100644 --- a/docs/app/components/blocks/lists/page.tsx +++ b/docs/app/components/blocks/lists/page.tsx @@ -8,7 +8,7 @@ import { } from "@components/components/preview"; import { ListBlock, ListItem } from "@openuidev/react-ui"; import { ChevronRight, FileText, Globe, LayoutPanelTop } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import styles from "./page.module.css"; type ListVariant = "number" | "icon" | "image"; @@ -24,6 +24,14 @@ function ListsPreview({ interactionMode: ListInteractionMode; }) { const descriptionFor = (text: string) => (showDescription ? text : undefined); + // ListItem shows a number, or a leading icon or image with variant "plain". + const itemVariant = variant === "number" ? "number" : "plain"; + const leadingFor = (icon: ReactNode, src: string, alt: string) => + variant === "icon" ? ( + icon + ) : variant === "image" ? ( + {alt} + ) : undefined; const commonActionProps = useMemo(() => { if (interactionMode === "static") { @@ -38,33 +46,30 @@ function ListsPreview({ return (
- + : undefined} - image={variant === "image" ? { src: "/file.svg", alt: "File" } : undefined} + leading={leadingFor(, "/file.svg", "File")} title="Connect your data" subtitle={descriptionFor("Link your database or upload a CSV to get started.")} {...commonActionProps} /> : undefined} - image={variant === "image" ? { src: "/globe.svg", alt: "Globe" } : undefined} + leading={leadingFor(, "/globe.svg", "Globe")} title="Choose a goal" subtitle={descriptionFor("Summarize, classify, extract, or generate UI from prompts.")} {...commonActionProps} /> : undefined} - image={variant === "image" ? { src: "/window.svg", alt: "Window" } : undefined} + leading={leadingFor(, "/window.svg", "Window")} title="Review and refine" subtitle={descriptionFor("Edit the result and rerun with tighter instructions.")} {...commonActionProps} diff --git a/packages/assistant-ui/src/__tests__/renderers.test.tsx b/packages/assistant-ui/src/__tests__/renderers.test.tsx index b6b689771e..cf95619c33 100644 --- a/packages/assistant-ui/src/__tests__/renderers.test.tsx +++ b/packages/assistant-ui/src/__tests__/renderers.test.tsx @@ -12,7 +12,7 @@ vi.mock("@assistant-ui/react", () => ({ })); const FORM = `root = Card([title, form]) -title = TextContent("Contact Us", "large-heavy") +title = Text("Contact Us", "h3") form = Form("contact", btns, [nameField, emailField]) 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 })) @@ -259,7 +259,7 @@ describe("OpenUIPrompt", () => { }); it("does not show the fallback for a warning when the program renders", async () => { - const args = { ui: 'card = Card([h])\nh = CardHeader("Entry", "B")' }; + const args = { ui: 'card = Card([h])\nh = Heading("Entry", "B")' }; await renderPrompt(makeProps({ args, argsText: JSON.stringify(args) })); expect(container.textContent).toContain("Entry"); diff --git a/packages/react-ui/src/components/Accordion/accordion.scss b/packages/react-ui/src/components/Accordion/accordion.scss index 240cf63eac..b00ae586ad 100644 --- a/packages/react-ui/src/components/Accordion/accordion.scss +++ b/packages/react-ui/src/components/Accordion/accordion.scss @@ -11,29 +11,26 @@ border: 1px solid transparent; box-shadow: none; padding: 0; - .openui-accordion-trigger { - box-shadow: none; - padding-left: 0; - padding-right: 0; - padding-top: cssUtils.$space-l; - padding-bottom: cssUtils.$space-l; - } + // Every row is the same height: equal padding above and below each + // trigger, a 1px rule between rows and none after the last. An open row's + // content ends with the same space, so both ends of the list match. .openui-accordion-item { - &:first-child { - .openui-accordion-trigger { - padding-top: 0; - } - } + overflow: visible; &:last-child { border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; - border-bottom: 1px solid transparent; + border-bottom: none; } } + .openui-accordion-item .openui-accordion-trigger, + .openui-accordion-item:first-child .openui-accordion-trigger { + box-shadow: none; + padding: calc(cssUtils.$space-m + 2px) 0; + } .openui-accordion-content { &-wrapper { padding: 0; - padding-bottom: cssUtils.$space-l; + padding-bottom: calc(cssUtils.$space-m + 2px); gap: cssUtils.$space-m; } } @@ -107,7 +104,8 @@ z-index: 1; } } - &-header { + // Scoped so host heading styles (h3 margins) never add space under a row. + & &-header { display: flex; margin-block-start: 0; margin-block-end: 0; @@ -117,7 +115,8 @@ &-trigger { border: none; box-sizing: border-box; - @include cssUtils.typography("body", "default"); + // Section titles in Medium, same size as before. + @include cssUtils.typography("body", "heavy"); background-color: transparent; padding: cssUtils.$space-l 0; width: 100%; @@ -127,18 +126,34 @@ justify-content: space-between; color: cssUtils.$text-neutral-primary; cursor: pointer; + border-radius: cssUtils.$radius-m; + + &:focus { + outline: none; + } + &:focus-visible { + outline: 2px solid cssUtils.$border-interactive-emphasis; + outline-offset: 2px; + } &-content { display: flex; align-items: center; color: cssUtils.$text-neutral-primary; - gap: cssUtils.$space-xs; + gap: 0.5em; + // Leading: an Icon at the text's icon size, or an inline Avatar or Emoji. &-icon { - display: flex; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - height: 16px; - width: 16px; + min-width: 1.15em; + + > svg { + width: 1.15em; + height: 1.15em; + stroke-width: 1.5; + } } } diff --git a/packages/react-ui/src/components/Avatar/Avatar.tsx b/packages/react-ui/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..0049a7e78d --- /dev/null +++ b/packages/react-ui/src/components/Avatar/Avatar.tsx @@ -0,0 +1,94 @@ +import clsx from "clsx"; +import { ImageOff } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { getFaviconUrl } from "../Sources/SourceContext"; + +export type AvatarShape = "circle" | "rounded"; +export type AvatarSize = "inline" | "md" | "lg"; +export type AvatarColor = + "neutral" | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted"; + +export interface AvatarProps { + /** Person or company name, used as the accessible label. */ + name: string; + /** Image URL (a photo or logo). */ + src?: string; + /** A company's website; its favicon is used when there's no src. */ + domain?: string; + /** circle for people (default), rounded for logos. */ + shape?: AvatarShape; + /** inline (default) sits on a text line like an Icon; md 32px; lg 48px. */ + size?: AvatarSize; + /** An icon shown inside the shape when there's no image. */ + icon?: ReactNode; + /** Background tint behind the icon; the icon color matches it. */ + color?: AvatarColor; + className?: string; +} + +const faviconFor = (domain?: string) => { + if (!domain) return ""; + const url = /^https?:\/\//i.test(domain) ? domain : `https://${domain}`; + return getFaviconUrl(url, 128); +}; + +/** + * A small photo or logo that can stand in for an Icon, or an Icon on a tinted + * shape. A broken image shows the icon if given, else a placeholder icon. + */ +export const Avatar = ({ + name, + src, + domain, + shape = "circle", + size = "inline", + icon, + color = "neutral", + className, +}: AvatarProps) => { + const imageUrl = src || faviconFor(domain); + const [failed, setFailed] = useState(false); + const imgRef = useRef(null); + // An image can fail before hydration attaches onError, so also check once + // on mount whether it already finished loading with no pixels. + useEffect(() => { + const img = imgRef.current; + setFailed(Boolean(img && img.complete && img.naturalWidth === 0)); + }, [imageUrl]); + const showImage = Boolean(imageUrl) && !failed; + // A broken image becomes a placeholder icon; with no image and no icon at + // all there's nothing to show. + const content = icon ?? (imageUrl ? : null); + if (!showImage && !content) return null; + + return ( + + {showImage ? ( + setFailed(true)} + /> + ) : ( + + {content} + + )} + + ); +}; diff --git a/packages/react-ui/src/components/Avatar/avatar.scss b/packages/react-ui/src/components/Avatar/avatar.scss new file mode 100644 index 0000000000..951e42fa5b --- /dev/null +++ b/packages/react-ui/src/components/Avatar/avatar.scss @@ -0,0 +1,117 @@ +@use "../../cssUtils" as cssUtils; + +.openui-avatar { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + overflow: hidden; + vertical-align: -0.2em; + // Neutral tint: primary text at 8%, visible on page and card surfaces in + // both themes (the sunk token is too faint without an outline). + // The fill is a variable so circles can draw their outline in it. + --openui-avatar-bg: oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.08); + background-color: cssUtils.$sunk; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-neutral-secondary; + + // inline matches an Icon on the same text line (1.15em, centered in the + // IconText frame); md and lg are fixed for standalone use. + // Inline avatars are 4px larger than an Icon on the same line: a filled + // shape reads smaller than an icon of the same box. + &--inline { + width: calc(1.15em + 4px); + height: calc(1.15em + 4px); + } + &--md { + width: 32px; + height: 32px; + } + &--lg { + width: 48px; + height: 48px; + } + + // A 1px ring in the avatar's own fill: a circle reads smaller than a + // rounded square of the same box, so this evens them out. + &--circle { + border-radius: 50%; + box-shadow: 0 0 0 1px var(--openui-avatar-bg); + } + // Logos: a rounded square, about a quarter of the size. + &--rounded { + border-radius: 25%; + } + + &__image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + &--rounded &__image { + object-fit: contain; + background-color: cssUtils.$foreground; + } + + // Icon centered in the shape at about 60% of its size. + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + // 70% of the avatar, with a 2-unit stroke, reads at the same weight as + // a plain Icon next to it (a lucide glyph only fills ~80% of its box). + width: 70%; + height: 70%; + svg { + width: 100%; + height: 100%; + stroke-width: 2; + } + } + + // Tints: soft semantic background with the matching primary foreground, + // so icons stay readable in light and dark themes. + &--color-info { + --openui-avatar-bg: #{cssUtils.$info-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-info-primary; + } + &--color-success { + --openui-avatar-bg: #{cssUtils.$success-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-success-primary; + } + &--color-warning { + --openui-avatar-bg: #{cssUtils.$alert-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-alert-primary; + } + &--color-danger { + --openui-avatar-bg: #{cssUtils.$danger-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-danger-primary; + } + &--color-purple { + --openui-avatar-bg: #{cssUtils.$purple-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-purple-primary; + } + &--color-pink { + --openui-avatar-bg: #{cssUtils.$pink-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$text-pink-primary; + } + &--color-inverted { + --openui-avatar-bg: #{cssUtils.$inverted-background}; + background-color: var(--openui-avatar-bg); + color: cssUtils.$background; + } +} + +// Inside IconText the frame sizes icons; give the avatar the same box. +.openui-text-with-icon__frame .openui-avatar--inline { + width: calc(var(--icon-scale, 1.15) * 1em + 4px); + height: calc(var(--icon-scale, 1.15) * 1em + 4px); + vertical-align: middle; +} diff --git a/packages/react-ui/src/components/Avatar/index.ts b/packages/react-ui/src/components/Avatar/index.ts new file mode 100644 index 0000000000..82892e85c6 --- /dev/null +++ b/packages/react-ui/src/components/Avatar/index.ts @@ -0,0 +1,2 @@ +export { Avatar } from "./Avatar"; +export type { AvatarColor, AvatarProps, AvatarShape, AvatarSize } from "./Avatar"; diff --git a/packages/react-ui/src/components/AvatarStack/AvatarStack.tsx b/packages/react-ui/src/components/AvatarStack/AvatarStack.tsx new file mode 100644 index 0000000000..bddf93834b --- /dev/null +++ b/packages/react-ui/src/components/AvatarStack/AvatarStack.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { Children, type ReactNode } from "react"; + +export type AvatarStackSize = "inline" | "md" | "lg"; + +export interface AvatarStackProps { + /** Avatar elements, already sized to `size`. */ + children: ReactNode; + size?: AvatarStackSize; + /** Extra avatars beyond those shown, rendered as a count chip. */ + overflow?: number; + /** Shape of each child, so each cutout matches the avatar that overlaps it. */ + shapes?: ("circle" | "rounded")[]; + className?: string; +} + +/** + * Avatars overlapped left to right, the first on top. Each later one is + * masked with a cutout shaped like the avatar before it (circle or rounded + * square) plus a thin gap, so edges stay clean on any background without + * outlines, and every avatar's visible side stays whole. + */ +export const AvatarStack = ({ + children, + size = "md", + overflow = 0, + shapes = [], + className, +}: AvatarStackProps) => { + const items = Children.toArray(children).filter(Boolean); + const total = items.length + (overflow > 0 ? 1 : 0); + // Each item is cut by the one before it (which sits on top). + const prevShape = (i: number) => shapes[i - 1] ?? "circle"; + return ( + + {items.map((child, i) => ( + 0 && `openui-avatar-stack__item--cut-${prevShape(i)}`, + )} + style={{ zIndex: total - i }} + > + {child} + + ))} + {overflow > 0 && ( + 0 && `openui-avatar-stack__item--cut-${prevShape(items.length)}`, + )} + > + {overflow} more + {overflow} + + )} + + ); +}; diff --git a/packages/react-ui/src/components/AvatarStack/avatarStack.scss b/packages/react-ui/src/components/AvatarStack/avatarStack.scss new file mode 100644 index 0000000000..6e9d84c6ab --- /dev/null +++ b/packages/react-ui/src/components/AvatarStack/avatarStack.scss @@ -0,0 +1,75 @@ +@use "../../cssUtils" as cssUtils; + +// --s is one avatar's size; each avatar overlaps the previous by 30%. +.openui-avatar-stack { + display: inline-flex; + align-items: center; + vertical-align: -0.2em; + --s: 32px; + + &--inline { + --s: calc(1.15em + 4px); + } + &--md { + --s: 32px; + } + &--lg { + --s: 48px; + } + + &__item { + position: relative; + display: inline-flex; + flex: none; + width: var(--s); + height: var(--s); + .openui-avatar { + width: var(--s); + height: var(--s); + } + & + & { + margin-left: calc(var(--s) * -0.3); + } + } + + // The first avatar sits on top. Each later one is cut where the previous + // one overlaps it: that avatar spans -70%..30% of this one, grown by a ~5% + // gap. SVG masks in a 100-unit box scale with every size, and match + // circle or rounded (25% radius) avatars. + &__item--cut-circle { + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22 preserveAspectRatio=%22none%22%3E%3Cpath fill-rule=%22evenodd%22 d=%22M0 0H100V100H0Z M-75 50a55 55 0 1 0 110 0a55 55 0 1 0 -110 0Z%22/%3E%3C/svg%3E") + 0 0 / 100% 100% no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22 preserveAspectRatio=%22none%22%3E%3Cpath fill-rule=%22evenodd%22 d=%22M0 0H100V100H0Z M-75 50a55 55 0 1 0 110 0a55 55 0 1 0 -110 0Z%22/%3E%3C/svg%3E") + 0 0 / 100% 100% no-repeat; + } + &__item--cut-rounded { + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22 preserveAspectRatio=%22none%22%3E%3Cpath fill-rule=%22evenodd%22 d=%22M0 0H100V100H0Z M-45 -5H5A30 30 0 0 1 35 25V75A30 30 0 0 1 5 105H-45A30 30 0 0 1 -75 75V25A30 30 0 0 1 -45 -5Z%22/%3E%3C/svg%3E") + 0 0 / 100% 100% no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22 preserveAspectRatio=%22none%22%3E%3Cpath fill-rule=%22evenodd%22 d=%22M0 0H100V100H0Z M-45 -5H5A30 30 0 0 1 35 25V75A30 30 0 0 1 5 105H-45A30 30 0 0 1 -75 75V25A30 30 0 0 1 -45 -5Z%22/%3E%3C/svg%3E") + 0 0 / 100% 100% no-repeat; + } + + &__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + &__more { + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: cssUtils.$sunk; + color: cssUtils.$text-neutral-secondary; + font-weight: cssUtils.$font-weight-medium; + font-variation-settings: cssUtils.$font-variation-medium; + font-variant-numeric: tabular-nums; + // The count fills about half the chip. + font-size: calc(var(--s) * 0.5); + line-height: 1; + letter-spacing: 0; + } +} diff --git a/packages/react-ui/src/components/AvatarStack/index.ts b/packages/react-ui/src/components/AvatarStack/index.ts new file mode 100644 index 0000000000..969279f68e --- /dev/null +++ b/packages/react-ui/src/components/AvatarStack/index.ts @@ -0,0 +1,2 @@ +export { AvatarStack } from "./AvatarStack"; +export type { AvatarStackProps, AvatarStackSize } from "./AvatarStack"; diff --git a/packages/react-ui/src/components/Bleed/Bleed.tsx b/packages/react-ui/src/components/Bleed/Bleed.tsx new file mode 100644 index 0000000000..61a8dbce18 --- /dev/null +++ b/packages/react-ui/src/components/Bleed/Bleed.tsx @@ -0,0 +1,12 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export interface BleedProps { + children?: ReactNode; + className?: string; +} + +/** Frameless content wider than the chat column, for expressive layouts. */ +export const Bleed = ({ children, className }: BleedProps) => ( +
{children}
+); diff --git a/packages/react-ui/src/components/Bleed/bleed.scss b/packages/react-ui/src/components/Bleed/bleed.scss new file mode 100644 index 0000000000..6305724bb7 --- /dev/null +++ b/packages/react-ui/src/components/Bleed/bleed.scss @@ -0,0 +1,9 @@ +@use "../_shared/bleed"; + +// No frame: just the shared breakout, with children stacked 12px apart. +.openui-bleed { + @include bleed.breakout; + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/packages/react-ui/src/components/Bleed/index.ts b/packages/react-ui/src/components/Bleed/index.ts new file mode 100644 index 0000000000..7838f1fc8b --- /dev/null +++ b/packages/react-ui/src/components/Bleed/index.ts @@ -0,0 +1,2 @@ +export { Bleed } from "./Bleed"; +export type { BleedProps } from "./Bleed"; diff --git a/packages/react-ui/src/components/Button/button.scss b/packages/react-ui/src/components/Button/button.scss index 1661b6f154..0a1424e931 100644 --- a/packages/react-ui/src/components/Button/button.scss +++ b/packages/react-ui/src/components/Button/button.scss @@ -147,3 +147,64 @@ padding: calc(cssUtils.$space-s-m - 1px) cssUtils.$space-m; } } + +// ─── Agent (genui) buttons ─── +// Pills in two sizes. Scoped to .openui-genui-button so app chrome that uses +// the base Button (composer, menus, dialogs) keeps its own sizes and colors. +.openui-button-base.openui-genui-button { + justify-content: center; + border-radius: 999px; + white-space: nowrap; + box-shadow: none; + // A leading icon sits a little closer to the edge than the label would. + &:has(> svg:first-child) { + padding-left: 12px; + } + + &--default { + height: 32px; + padding: 0 14px; + gap: 6px; + @include cssUtils.typography(label, small-heavy); + & svg { + width: 16px; + height: 16px; + } + } + + &--large { + height: 40px; + padding: 0 20px; + gap: 8px; + @include cssUtils.typography(label, heavy); + & svg { + width: 18px; + height: 18px; + } + &:has(> svg:first-child) { + padding-left: 16px; + } + } + + // Primary is the black inverted fill (white in dark mode), like the user's + // chat bubble, instead of the accent blue. + &.openui-button-base-primary { + background-color: cssUtils.$inverted-background; + border-color: cssUtils.$inverted-background; + color: cssUtils.$background; + &:not(:disabled):hover { + background-color: cssUtils.$inverted-background; + opacity: 0.85; + } + &:not(:disabled):active { + opacity: 0.75; + } + &:disabled { + opacity: 0.4; + } + } + + &.openui-button-base-secondary { + box-shadow: none; + } +} diff --git a/packages/react-ui/src/components/Buttons/buttons.scss b/packages/react-ui/src/components/Buttons/buttons.scss index 44853cb01c..ad513f3a52 100644 --- a/packages/react-ui/src/components/Buttons/buttons.scss +++ b/packages/react-ui/src/components/Buttons/buttons.scss @@ -15,3 +15,13 @@ flex-direction: column; } } + +// The agent's button groups space like the buttons inside them: 6px between +// default buttons, 8px when the group holds large ones. +.openui-buttons.openui-genui-buttons { + gap: 6px; + + &:has(.openui-genui-button--large) { + gap: 8px; + } +} diff --git a/packages/react-ui/src/components/Calendar/utils/calendarBaseStyle.scss b/packages/react-ui/src/components/Calendar/utils/calendarBaseStyle.scss index a7ba53f6b0..d0bd34bd7d 100644 --- a/packages/react-ui/src/components/Calendar/utils/calendarBaseStyle.scss +++ b/packages/react-ui/src/components/Calendar/utils/calendarBaseStyle.scss @@ -3,9 +3,9 @@ /* Base calendar surface and shared dimensions */ .openui-calendar-root { --openui-calendar-day-size: calc(#{cssUtils.$space-2xl} + #{cssUtils.$space-2xs}); - --openui-calendar-cell-radius: #{cssUtils.$radius-m}; + --openui-calendar-cell-radius: #{cssUtils.$radius-full}; - @include cssUtils.typography(body, default); + @include cssUtils.typography(body, small); width: fit-content; max-width: 100%; box-sizing: border-box; @@ -63,7 +63,7 @@ } .openui-calendar-chevron { - fill: cssUtils.$interactive-accent-default; + fill: cssUtils.$text-neutral-primary; } /* Grid setup */ @@ -100,7 +100,7 @@ .openui-calendar-single-day-button, .openui-calendar-range-day-button { - @include cssUtils.typography(label, default); + @include cssUtils.typography(label, small); width: 100%; height: 100%; margin: 0; @@ -123,12 +123,12 @@ } &:focus-visible { - outline: 2px solid cssUtils.$border-accent; + outline: 2px solid cssUtils.$inverted-background; outline-offset: cssUtils.$space-3xs; } &:not(:disabled):hover { - background-color: cssUtils.$highlight-subtle; + background-color: cssUtils.$sunk; } &:disabled { @@ -156,15 +156,15 @@ } .openui-calendar-single-day-selected { - background: cssUtils.$interactive-accent-default; - color: cssUtils.$text-accent-primary; + background: cssUtils.$inverted-background; + color: cssUtils.$foreground; border-radius: var(--openui-calendar-cell-radius); } /* Range states apply on day container cells */ .openui-calendar-range-start { - background: cssUtils.$interactive-accent-default; - color: cssUtils.$text-accent-primary; + background: cssUtils.$inverted-background; + color: cssUtils.$foreground; border-top-left-radius: var(--openui-calendar-cell-radius); border-bottom-left-radius: var(--openui-calendar-cell-radius); } @@ -174,8 +174,8 @@ } .openui-calendar-range-end { - background: cssUtils.$interactive-accent-default; - color: cssUtils.$text-accent-primary; + background: cssUtils.$inverted-background; + color: cssUtils.$foreground; border-top-right-radius: var(--openui-calendar-cell-radius); border-bottom-right-radius: var(--openui-calendar-cell-radius); } diff --git a/packages/react-ui/src/components/Callout/Callout.tsx b/packages/react-ui/src/components/Callout/Callout.tsx index a6112e6db7..cf62c1947d 100644 --- a/packages/react-ui/src/components/Callout/Callout.tsx +++ b/packages/react-ui/src/components/Callout/Callout.tsx @@ -1,12 +1,15 @@ import clsx from "clsx"; import React from "react"; -type CalloutVariant = "info" | "danger" | "warning" | "success" | "neutral"; +type CalloutVariant = + "info" | "danger" | "warning" | "success" | "neutral" | "purple" | "pink" | "inverted"; export interface CalloutProps extends Omit, "title"> { variant?: CalloutVariant; title?: React.ReactNode; description?: React.ReactNode; + /** Leading icon, centered on the title's first line. */ + icon?: React.ReactNode; /** Auto-dismiss after N milliseconds. CSS-only fade + collapse. */ duration?: number; } @@ -17,10 +20,22 @@ const variantMap: Record = { warning: "openui-callout-warning", success: "openui-callout-success", neutral: "openui-callout-neutral", + purple: "openui-callout-purple", + pink: "openui-callout-pink", + inverted: "openui-callout-inverted", }; export const Callout = React.forwardRef((props, ref) => { - const { className, variant = "neutral", title, description, duration, style, ...rest } = props; + const { + className, + variant = "neutral", + title, + description, + icon, + duration, + style, + ...rest + } = props; const dismissStyle = duration ? ({ ...style, "--callout-duration": `${duration}ms` } as React.CSSProperties) @@ -38,8 +53,15 @@ export const Callout = React.forwardRef((props, re style={dismissStyle} {...rest} > - {title && {title}} - {description && {description}} + {icon && ( + + {icon} + + )} +
+ {title && {title}} + {description && {description}} +
); }); diff --git a/packages/react-ui/src/components/Callout/callout.scss b/packages/react-ui/src/components/Callout/callout.scss index 3431be8bcf..3ef8241351 100644 --- a/packages/react-ui/src/components/Callout/callout.scss +++ b/packages/react-ui/src/components/Callout/callout.scss @@ -1,55 +1,105 @@ @use "../../cssUtils" as cssUtils; +// Notion-style callout: a tinted block with an icon on the first line and +// the content beside it (an optional title sits above the content). .openui-callout { display: flex; - flex-direction: column; - padding: cssUtils.$space-000 calc(cssUtils.$space-m + 2px); - box-sizing: border-box; + flex-direction: row; align-items: flex-start; - border-left: 2px solid; + gap: 0.6em; + box-sizing: border-box; + padding: 12px 16px; + border-radius: 14px; + color: cssUtils.$text-neutral-primary; + // 14px on a 20px line. Set through the body token, so the Markdown content + // and the icon frame (which reads the same token) both take it. + --openui-text-body-default: #{cssUtils.$font-weight-regular} 14px/20px #{cssUtils.$font-body}; + // Tints are the semantic backgrounds at 60% of their alpha, lighter than + // the token, so a callout reads as a soft note in light and dark. &-info { - border-color: cssUtils.$border-info-emphasis; - & .openui-callout-title { - color: cssUtils.$text-info-primary; - } + background-color: cssUtils.$info-background; + background-color: oklch(from cssUtils.$info-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-info-primary}; } - &-danger { - border-color: cssUtils.$border-danger-emphasis; - & .openui-callout-title { - color: cssUtils.$text-danger-primary; - } + background-color: cssUtils.$danger-background; + background-color: oklch(from cssUtils.$danger-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-danger-primary}; } - &-warning { - border-color: cssUtils.$border-alert-emphasis; - & .openui-callout-title { - color: cssUtils.$text-alert-primary; - } + background-color: cssUtils.$alert-background; + background-color: oklch(from cssUtils.$alert-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-alert-primary}; } - &-success { - border-color: cssUtils.$border-success-emphasis; - & .openui-callout-title { - color: cssUtils.$text-success-primary; + background-color: cssUtils.$success-background; + background-color: oklch(from cssUtils.$success-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-success-primary}; + } + &-neutral { + background-color: cssUtils.$sunk; + background-color: oklch(from cssUtils.$sunk l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-neutral-secondary}; + } + + &-purple { + background-color: cssUtils.$purple-background; + background-color: oklch(from cssUtils.$purple-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-purple-primary}; + } + &-pink { + background-color: cssUtils.$pink-background; + background-color: oklch(from cssUtils.$pink-background l c h / calc(alpha * 0.6)); + --openui-callout-icon: #{cssUtils.$text-pink-primary}; + } + // Inverted is solid, with the content in the page background color. + &-inverted { + background-color: cssUtils.$inverted-background; + color: cssUtils.$background; + --openui-callout-icon: #{cssUtils.$background}; + .openui-callout-title, + .openui-callout-description, + .openui-callout-description .openui-markdown-renderer :is(p, li, strong, a) { + color: cssUtils.$background; } } - &-neutral { - border-color: cssUtils.$border-interactive-selected; - & .openui-callout-title { - color: cssUtils.$text-neutral-primary; + // Frame one content line tall, so the icon centers on the first line. + &-icon { + display: inline-flex; + flex: none; + align-items: center; + @include cssUtils.typography(body, default); + height: 1lh; + color: var(--openui-callout-icon); + svg { + width: 1.15em; + height: 1.15em; + stroke-width: 1.5; } } + &-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + &-title { @include cssUtils.typography(body, heavy); + color: cssUtils.$text-neutral-primary; } &-description { @include cssUtils.typography(body, default); color: cssUtils.$text-neutral-primary; + // Markdown content sits flush: no outer paragraph spacing. + .openui-markdown-renderer > :first-child { + margin-top: 0; + } } &-autodismiss { diff --git a/packages/react-ui/src/components/Card/Card.tsx b/packages/react-ui/src/components/Card/Card.tsx index b174b1ab3f..2e83ffd61a 100644 --- a/packages/react-ui/src/components/Card/Card.tsx +++ b/packages/react-ui/src/components/Card/Card.tsx @@ -1,7 +1,8 @@ import clsx from "clsx"; import React from "react"; -type CardVariant = "clear" | "card" | "sunk"; +// "clear" is internal only (the chat response wrapper); generated UI picks outlined, lifted or sunk. +type CardVariant = "clear" | "outlined" | "lifted" | "sunk"; type CardWidth = "standard" | "full"; export interface CardProps extends React.HTMLAttributes { @@ -11,7 +12,8 @@ export interface CardProps extends React.HTMLAttributes { const variantMap: Record = { clear: "openui-card-clear", - card: "openui-card-card", + outlined: "openui-card-outlined", + lifted: "openui-card-lifted", sunk: "openui-card-sunk", }; @@ -21,7 +23,7 @@ const widthMap: Record = { }; export const Card = React.forwardRef((props, ref) => { - const { className, children, variant = "card", width = "standard", ...rest } = props; + const { className, children, variant = "outlined", width = "standard", ...rest } = props; return (
+ title || subtitle ? ( +
+ {title &&
{title}
} + {subtitle &&
{subtitle}
} +
+ ) : null; diff --git a/packages/react-ui/src/components/Card/card.scss b/packages/react-ui/src/components/Card/card.scss index a30b32cdc9..24d28cf14d 100644 --- a/packages/react-ui/src/components/Card/card.scss +++ b/packages/react-ui/src/components/Card/card.scss @@ -1,5 +1,12 @@ @use "../../cssUtils.scss" as cssUtils; +// Same insets and radius as our card items (CompositeCard, InsightCard, +// ValueCard): 12px, 18px on the right where text ends, 14px at the bottom. +@mixin card-inset { + padding: 12px 18px 14px 12px; + border-radius: 14px; +} + .openui-card { display: flex; flex-direction: column; @@ -9,10 +16,25 @@ color: cssUtils.$text-neutral-primary; @include cssUtils.typography(body, default); - &-card { + // Border only: shows whatever surface it sits on. + &-outlined { + @include card-inset; + background-color: transparent; + border-color: cssUtils.$border-default; box-shadow: none; } + // The outlined card raised off the page by a shadow. + &-lifted { + @include card-inset; + background-color: cssUtils.$foreground; + border-color: cssUtils.$border-default; + // Same soft, low shadow as CompositeCard. + box-shadow: + 0 1px 2px oklch(0 0 0 / 0.04), + 0 4px 16px -4px oklch(0 0 0 / 0.08); + } + &-clear { padding: 0px cssUtils.$space-l; border-radius: cssUtils.$radius-3xl; @@ -21,11 +43,33 @@ } &-sunk { - padding: cssUtils.$space-l; - border-radius: cssUtils.$radius-3xl; + @include card-inset; + // Light grey fill only (the ValueCard tint): no border, no shadow. background-color: cssUtils.$sunk; - border-color: cssUtils.$border-default; - box-shadow: cssUtils.$shadow-m; + box-shadow: none; + } + + // A Card's own title: 14px Medium over a 13px secondary subtitle, the + // label sizes our card items use. + &__heading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__title { + font-size: 14px; + line-height: 20px; + font-weight: var(--openui-font-weight-medium); + font-variation-settings: var(--openui-font-variation-medium); + color: cssUtils.$text-neutral-primary; + } + + &__subtitle { + font-size: 13px; + line-height: 18px; + color: cssUtils.$text-neutral-secondary; } &-standard { diff --git a/packages/react-ui/src/components/Card/index.ts b/packages/react-ui/src/components/Card/index.ts index 24d3212465..922192e570 100644 --- a/packages/react-ui/src/components/Card/index.ts +++ b/packages/react-ui/src/components/Card/index.ts @@ -1 +1,2 @@ export * from "./Card"; +export { CardHeading } from "./CardHeading"; diff --git a/packages/react-ui/src/components/Card/stories/card.stories.tsx b/packages/react-ui/src/components/Card/stories/card.stories.tsx index 0c5f29bbbd..94a19960df 100644 --- a/packages/react-ui/src/components/Card/stories/card.stories.tsx +++ b/packages/react-ui/src/components/Card/stories/card.stories.tsx @@ -28,11 +28,11 @@ const meta: Meta = { }, variant: { control: "radio", - options: ["card", "clear", "sunk"], + options: ["outlined", "lifted", "sunk"], description: "Determines the visual style of the card", table: { - defaultValue: { summary: "card" }, + defaultValue: { summary: "outlined" }, category: "Appearance", }, }, @@ -47,7 +47,7 @@ type Story = StoryObj; export const CardStory: Story = { args: { width: "standard", - variant: "card", + variant: "outlined", }, render: (args) => (
diff --git a/packages/react-ui/src/components/Carousel/Carousel.tsx b/packages/react-ui/src/components/Carousel/Carousel.tsx index fbdd2fdc05..b27b4fce1f 100644 --- a/packages/react-ui/src/components/Carousel/Carousel.tsx +++ b/packages/react-ui/src/components/Carousel/Carousel.tsx @@ -18,7 +18,7 @@ interface CarouselContextType { itemsToScroll: number; noSnap?: boolean; showButtons?: boolean; - variant?: "card" | "sunk"; + variant?: "card" | "sunk" | "bare"; isPrevVisible: boolean; isNextVisible: boolean; } @@ -35,7 +35,7 @@ export interface CarouselProps extends React.HTMLAttributes { itemsToScroll?: number; noSnap?: boolean; showButtons?: boolean; - variant?: "card" | "sunk"; + variant?: "card" | "sunk" | "bare"; onScrollLeftEnabled?: (enabled: boolean) => void; onScrollRightEnabled?: (enabled: boolean) => void; } @@ -236,7 +236,7 @@ export const CarouselPrevious = forwardRef<
scroll("left")} @@ -257,7 +257,7 @@ export const CarouselNext = forwardRef scroll("right")} diff --git a/packages/react-ui/src/components/Carousel/carousel.scss b/packages/react-ui/src/components/Carousel/carousel.scss index 421d0a12da..9f9cd8af4c 100644 --- a/packages/react-ui/src/components/Carousel/carousel.scss +++ b/packages/react-ui/src/components/Carousel/carousel.scss @@ -74,11 +74,24 @@ top: 50%; z-index: 10; transform: translateY(-50%); - background: cssUtils.$foreground; display: flex; align-items: center; justify-content: center; - border-radius: cssUtils.$radius-s; + + // Frosted grey circle: translucent fill, backdrop blur, no outline. + .openui-icon-button { + border: none; + box-shadow: none; + color: cssUtils.$text-neutral-primary; + background-color: oklch(from #{cssUtils.$foreground} calc(l - 0.06) c h / 0.72); + backdrop-filter: blur(8px); + + &:not(:disabled):hover, + &:not(:disabled):active { + background-color: oklch(from #{cssUtils.$foreground} calc(l - 0.1) c h / 0.86); + box-shadow: none; + } + } &-left { left: -12px; @@ -102,3 +115,19 @@ background-color: cssUtils.$highlight-subtle; } } + +// Slides that bring their own frame (genui Carousel slides are Cards). +.openui-carousel--bare { + .openui-carousel-item { + padding: 0; + border: none; + border-radius: 0; + background-color: transparent; + } +} + +// Card blocks (ImageCard, InsightCard, CompositeCardBlock, ValueCard) in +// their carousel layout reuse these arrows; they sit over the block root. +:is(.openui-small-card-block, .openui-medium-card-block)[data-layout="carousel"] { + position: relative; +} diff --git a/packages/react-ui/src/components/CheckBoxItem/checkBoxItem.scss b/packages/react-ui/src/components/CheckBoxItem/checkBoxItem.scss index 3f700848ee..b6ec78bdde 100644 --- a/packages/react-ui/src/components/CheckBoxItem/checkBoxItem.scss +++ b/packages/react-ui/src/components/CheckBoxItem/checkBoxItem.scss @@ -24,23 +24,23 @@ border-radius: cssUtils.$radius-xs; border: 1px solid cssUtils.$border-interactive; cursor: pointer; - margin-top: cssUtils.$space-2xs; - background-color: transparent; + margin-top: 1px; + background-color: cssUtils.$foreground; &[data-state="checked"] { - background-color: cssUtils.$interactive-accent-default; - color: cssUtils.$text-neutral-primary; + background-color: cssUtils.$inverted-background; + border-color: cssUtils.$inverted-background; } &:not(:disabled):not([data-state="checked"]):hover { - border-color: cssUtils.$border-interactive; - background-color: cssUtils.$sunk; + border-color: cssUtils.$border-interactive-emphasis; + background-color: cssUtils.$foreground; color: cssUtils.$text-neutral-secondary; } &:disabled { cursor: not-allowed; - color: cssUtils.$text-neutral-tertiary; + opacity: 0.5; } } @@ -48,7 +48,7 @@ display: flex; align-items: center; justify-content: center; - color: cssUtils.$text-accent-primary; + color: cssUtils.$foreground; } .openui-checkbox-item-content { @@ -59,18 +59,18 @@ .openui-checkbox-item-label { flex: 1; - @include cssUtils.typography(body, default); + @include cssUtils.typography(label, small-heavy); color: cssUtils.$text-neutral-primary; cursor: pointer; overflow-wrap: anywhere; &:disabled { - color: cssUtils.$text-neutral-tertiary; + opacity: 0.5; cursor: not-allowed; } } .openui-checkbox-item-description { - @include cssUtils.typography(body, default); + @include cssUtils.typography(body, small); color: cssUtils.$text-neutral-secondary; overflow-wrap: anywhere; } diff --git a/packages/react-ui/src/components/Chips/Chips.tsx b/packages/react-ui/src/components/Chips/Chips.tsx index 8be902d8bf..601dc3dcf1 100644 --- a/packages/react-ui/src/components/Chips/Chips.tsx +++ b/packages/react-ui/src/components/Chips/Chips.tsx @@ -6,7 +6,8 @@ export type ChipsSelectionType = "single" | "multiple"; export interface ChipsItem { value: string; label: string; - icon?: ReactNode; + /** Icon, Avatar or Emoji before the label. */ + leading?: ReactNode; disabled?: boolean; } @@ -61,8 +62,22 @@ const Chips = forwardRef((props, ref) => { )} onClick={() => onToggle?.(item.value)} > - {item.icon ? {item.icon} : null} + {item.leading ? ( + {item.leading} + ) : null} {item.label} + + + + + ); })} diff --git a/packages/react-ui/src/components/Chips/chips.scss b/packages/react-ui/src/components/Chips/chips.scss index 8955ac5fd9..86ee53e117 100644 --- a/packages/react-ui/src/components/Chips/chips.scss +++ b/packages/react-ui/src/components/Chips/chips.scss @@ -8,53 +8,84 @@ box-sizing: border-box; } +// Pills at the genui button height. Selection follows OptionCards: the +// outline stays put and a plain stroke check appears at the end. .openui-chip-item { @include cssUtils.button-reset; + @include cssUtils.typography(label, small-heavy); display: inline-flex; align-items: center; - gap: cssUtils.$space-xs; - padding: cssUtils.$space-xs cssUtils.$space-s; + gap: 0.429em; + height: 32px; + padding: 0 1em; border: 1px solid cssUtils.$border-interactive; - border-radius: cssUtils.$radius-m; + border-radius: 999px; box-sizing: border-box; cursor: pointer; background: transparent; color: cssUtils.$text-neutral-primary; transition: - border-color 0.15s ease, - background-color 0.15s ease, - opacity 0.15s ease; - @include cssUtils.typography(label, default); - - &:not(:disabled):not(.openui-chip-item--selected):hover { - border-color: cssUtils.$border-default; - background-color: cssUtils.$sunk-light; + border-color 0.16s ease-out, + background-color 0.16s ease-out, + padding 0.18s ease-out, + transform 0.16s ease-out, + opacity 0.16s ease-out; + + &:has(.openui-chip-item__leading) { + padding-left: 0.5em; } - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; + &:not(:disabled):hover { + border-color: cssUtils.$border-interactive-emphasis; + background-color: cssUtils.$sunk; + transform: scale(1.01); } - &--selected { - border-color: cssUtils.$border-interactive-selected; + &:not(:disabled):active { background-color: cssUtils.$highlight-strong; + transform: scale(0.98); + transition-duration: 0.08s; + } + + // Selected: a grey fill in place of the outline, plus the check. + &--selected, + &--selected:not(:disabled):hover { + padding-right: 0.714em; + border-color: transparent; + background-color: cssUtils.$highlight-strong; + } + + &:focus-visible { + outline: 2px solid cssUtils.$inverted-background; + outline-offset: 2px; } &:disabled, &--disabled { - opacity: 0.6; + opacity: 0.5; cursor: not-allowed; } } -.openui-chip-item__icon { - display: flex; +.openui-chip-item__leading { + display: inline-flex; align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 20px; + height: 20px; + border-radius: 999px; - & > * { - width: 14px; - height: 14px; + // A bare Icon sits in a round sunk tile like OptionCards; Avatars and Emoji + // bring their own shape. + &:has(> svg) { + width: 20px; + background: cssUtils.$sunk; + + svg { + width: 12px; + height: 12px; + } } } @@ -63,3 +94,49 @@ white-space: nowrap; text-overflow: ellipsis; } + +// Always rendered so the pill can grow smoothly: collapsed to zero width +// (with the gap cancelled) until selected, then it opens and the stroke draws. +.openui-chip-item__check { + display: inline-flex; + flex-shrink: 0; + width: 0; + height: 14px; + margin-left: -0.429em; + overflow: hidden; + color: cssUtils.$text-neutral-primary; + transition: + width 0.18s ease-out, + margin-left 0.18s ease-out; + + svg { + flex-shrink: 0; + width: 14px; + height: 14px; + } + + path { + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 0.18s ease-out; + } + + .openui-chip-item--selected & { + width: 14px; + margin-left: 0; + + path { + stroke-dashoffset: 0; + transition-delay: 0.08s; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .openui-chip-item, + .openui-chip-item__check, + .openui-chip-item__check path { + transition: none; + transform: none !important; + } +} diff --git a/packages/react-ui/src/components/CodeBlock/CodeBlock.tsx b/packages/react-ui/src/components/CodeBlock/CodeBlock.tsx index 42d217e35b..c44dff14ba 100644 --- a/packages/react-ui/src/components/CodeBlock/CodeBlock.tsx +++ b/packages/react-ui/src/components/CodeBlock/CodeBlock.tsx @@ -1,9 +1,11 @@ import clsx from "clsx"; -import { CheckCheck, Copy } from "lucide-react"; +import { CheckCheck, Copy, SquareTerminal } from "lucide-react"; import { useState } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { IconButton } from "../IconButton"; +import { useTheme } from "../ThemeProvider"; +import { openuiLightTheme } from "./lightTheme"; export interface CodeBlockProps { language: string; @@ -13,8 +15,17 @@ export interface CodeBlockProps { }; } +const SHELL_LANGUAGES = new Set(["bash", "sh", "shell", "zsh", "console"]); + export const CodeBlock = ({ language, codeString, theme }: CodeBlockProps) => { const [copied, setCopied] = useState(false); + // Without an explicit theme, follow the app: light syntax theme in light + // mode, dark in dark mode, so the copy button matches the surface. + const { mode } = useTheme(); + const syntaxTheme = theme ?? (mode === "dark" ? vscDarkPlus : openuiLightTheme); + + const isShell = SHELL_LANGUAGES.has(language?.toLowerCase()); + const isSingleLine = !codeString.trim().includes("\n"); const handleCopy = () => { navigator.clipboard.writeText(codeString); @@ -23,11 +34,17 @@ export const CodeBlock = ({ language, codeString, theme }: CodeBlockProps) => { }; return ( -
+
+ {isShell && } { aria-label={copied ? "Copied to clipboard" : "Copy code"} /> = { + [secondary]: ["comment", "prolog", "cdata", "doctype", "punctuation", "operator"], + [purple]: ["keyword", "atrule", "important", "selector"], + [green]: ["string", "char", "attr-value", "regex", "inserted", "url"], + [blue]: ["function", "class-name", "builtin", "property", "attr-name"], + [amber]: ["number", "boolean", "constant", "symbol"], + [pink]: ["tag", "variable", "entity", "namespace"], + [red]: ["deleted"], +}; + +export const openuiLightTheme: Record = { + // The background is translucent, so only the outer pre carries it; on the + // inner code it would stack into a darker band behind each line. + 'code[class*="language-"]': { ...base, background: "transparent" }, + 'pre[class*="language-"]': { ...base, overflow: "auto" }, + ...Object.fromEntries( + Object.entries(colors).flatMap(([color, tokens]) => tokens.map((token) => [token, { color }])), + ), + comment: { color: secondary, fontStyle: "normal" }, + bold: { fontWeight: 600 }, + italic: { fontStyle: "italic" }, +}; diff --git a/packages/react-ui/src/components/CompositeCardBlock/CompositeCardBlock.tsx b/packages/react-ui/src/components/CompositeCardBlock/CompositeCardBlock.tsx index fd4272f46e..5cd478d319 100644 --- a/packages/react-ui/src/components/CompositeCardBlock/CompositeCardBlock.tsx +++ b/packages/react-ui/src/components/CompositeCardBlock/CompositeCardBlock.tsx @@ -4,11 +4,17 @@ import { CardBlockLayout, cardKeyDownHandler } from "../_shared/cards/CardBlockL export interface CompositeCardBlockItem { id?: string; + /** A photo shown edge to edge across the top of the card. */ + media?: ReactNode; + /** chart media gets an inset tinted panel; image media bleeds to the edges. */ + mediaKind?: "image" | "chart"; header?: ReactNode; body?: ReactNode[]; footer?: { price?: ReactNode; button?: ReactNode; + /** button holds IconButtons: they sit in a row to the right of the price. */ + iconActions?: boolean; }; } @@ -22,7 +28,7 @@ export interface CompositeCardProps { /** A bordered card with header, stacked body content and a price/button footer. */ export const CompositeCard = forwardRef((props, ref) => { const { item, clickable = false, onClick, className } = props; - const { header, body, footer } = item; + const { media, mediaKind, header, body, footer } = item; const bodyItems = body ?? []; const hasFooter = Boolean(footer?.price || footer?.button); @@ -40,15 +46,35 @@ export const CompositeCard = forwardRef((pro onClick={clickable ? onClick : undefined} onKeyDown={clickable && onClick ? cardKeyDownHandler(onClick) : undefined} > + {media && ( +
+ {media} +
+ )} + {header &&
{header}
} {bodyItems.length > 0 &&
{bodyItems}
} {hasFooter && (
-
+
{footer?.price ?? null} - {footer?.button ?? null} + {footer?.iconActions ? ( +
{footer.button}
+ ) : ( + (footer?.button ?? null) + )}
)} diff --git a/packages/react-ui/src/components/CompositeCardBlock/compositeCardBlock.scss b/packages/react-ui/src/components/CompositeCardBlock/compositeCardBlock.scss index a453ea81c0..c940e49734 100644 --- a/packages/react-ui/src/components/CompositeCardBlock/compositeCardBlock.scss +++ b/packages/react-ui/src/components/CompositeCardBlock/compositeCardBlock.scss @@ -1,42 +1,91 @@ @use "../../cssUtils" as cssUtils; +@use "../_shared/cards/clickable"; .openui-composite-card__wrapper { height: 100%; } .openui-composite-card { + // Images inside a composite card keep the tighter radius they had. + --openui-image-radius: #{cssUtils.$radius-m}; display: flex; flex-direction: column; gap: cssUtils.$space-m; min-width: 15rem; height: 100%; - padding: cssUtils.$space-m; + // Our card insets (12px, 18px on the right where text ends). + padding: 12px 18px 14px 12px; + // A white card with a hairline outline and a soft, low shadow. border: 1px solid cssUtils.$border-default; - border-radius: cssUtils.$radius-3xl; + border-radius: 14px; box-sizing: border-box; - background: cssUtils.$sunk-light; + $soft-shadow: + 0 1px 2px oklch(0 0 0 / 0.04), + 0 4px 16px -4px oklch(0 0 0 / 0.08); + background: cssUtils.$foreground; + box-shadow: $soft-shadow; &--clickable { - background: cssUtils.$foreground; - border-color: cssUtils.$border-interactive; - box-shadow: cssUtils.$shadow-m; - cursor: pointer; - transition: - transform 0.2s ease, - box-shadow 0.2s ease; + @include clickable.clickable-card; + // Keep the soft shadow under the hover and press wash. &:hover { - transform: scale(0.995); - box-shadow: none; + box-shadow: + inset 0 0 0 999px oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.03), + $soft-shadow; } - - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; + &:active { + box-shadow: + inset 0 0 0 999px oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.06), + $soft-shadow; } } } +// Body copy: 14px on a 20px line in Regular, secondary for supporting text; +// emphasized Text stays primary in Medium. Headings (h1-h3) keep their own +// size. !important is needed to beat the Text component's inline font var. +.openui-composite-card__text > div { + --openui-text-body-default: #{cssUtils.$font-weight-regular} 14px/20px #{cssUtils.$font-body} !important; + color: cssUtils.$text-neutral-secondary; +} + +.openui-composite-card .openui-composite-card__text .openui-markdown-renderer, +.openui-composite-card .openui-composite-card__text .openui-markdown-renderer p { + color: cssUtils.$text-neutral-secondary; +} + +.openui-composite-card .openui-composite-card__text--emphasis .openui-markdown-renderer, +.openui-composite-card .openui-composite-card__text--emphasis .openui-markdown-renderer p { + color: cssUtils.$text-neutral-primary; +} + +.openui-composite-card__text--emphasis > div { + --openui-text-body-default: #{cssUtils.$font-weight-medium} 14px/20px #{cssUtils.$font-body} !important; + color: cssUtils.$text-neutral-primary; +} + +// Media bleeds to the card's edges: pulled out by the card's padding, with +// the top corners following the card's inner radius (14px less the 1px +// border) and no frame border of its own. +.openui-composite-card__media { + flex-shrink: 0; + margin: -12px -18px 0 -12px; + --openui-image-radius: 13px 13px 0 0; + + .openui-image-frame { + border: none; + } + + // A chart fills the same edge-to-edge band on a faint tint, inset so its + // axes stay clear of the card edge. + &--chart { + padding: 12px 12px 4px; + border-radius: 13px 13px 0 0; + background-color: oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.03); + } +} + .openui-composite-card__header { display: flex; flex-direction: column; @@ -44,12 +93,23 @@ flex-shrink: 0; } +// A Text title in the header sits 4px above the body, the same as a title +// at the top of the body (12px card gap less 8px). +.openui-composite-card__header:has(> div > .openui-text-content) + .openui-composite-card__body { + margin-top: -8px; +} + .openui-composite-card__body { display: flex; flex: 1; flex-direction: column; - gap: cssUtils.$space-m; + // 8px between body pieces; the title sits 4px above its description. + gap: 8px; min-height: 0; + + > :first-child + .openui-composite-card__text { + margin-top: -4px; + } } // Markdown emphasis must follow the surrounding slot's text sizing — the @@ -89,6 +149,8 @@ display: flex; flex-shrink: 0; margin-top: auto; + // A little extra air between the body and the price/actions. + padding-top: 4px; & > * { width: 100%; @@ -98,6 +160,50 @@ .openui-composite-card__footer-content { display: flex; flex-direction: column; - gap: cssUtils.$space-s; + // 12px between the price and the actions. + gap: cssUtils.$space-m; width: 100%; + + // IconButtons: price on the left, the icons in a row on the right. + &--icons { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.openui-composite-card__icon-actions { + display: flex; + align-items: center; + gap: 0.5em; + margin-inline-start: auto; +} + +// Block widths. A grid splits the block into two equal columns; a lone last +// card keeps that half width instead of stretching across the row. Carousel +// cards are 360px (288px in a narrow container). +.openui-medium-card-block--composite-card { + .openui-medium-card-block__row--1:not(:only-child) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .openui-medium-card-block__grid--odd-count { + @container (max-width: 48rem) { + .openui-medium-card-block__row:last-child > .openui-medium-card-block__item:last-child { + grid-column: auto; + } + } + } + + .openui-medium-card-block__carousel-item { + width: 22.5rem; + } + + .openui-medium-card-block__carousel--responsive { + @container (max-width: 30rem) { + .openui-medium-card-block__carousel-item { + width: 18rem; + } + } + } } diff --git a/packages/react-ui/src/components/ContextCardBlock/ContextCardBlock.tsx b/packages/react-ui/src/components/ContextCardBlock/ContextCardBlock.tsx index 1a3bf516f7..fdf2d366d1 100644 --- a/packages/react-ui/src/components/ContextCardBlock/ContextCardBlock.tsx +++ b/packages/react-ui/src/components/ContextCardBlock/ContextCardBlock.tsx @@ -1,18 +1,22 @@ import clsx from "clsx"; -import { ChevronRight } from "lucide-react"; -import { forwardRef, type ReactNode } from "react"; +import { forwardRef, type CSSProperties, type ReactNode } from "react"; import { InlineMarkdownRenderer } from "../InlineMarkdownRenderer"; import { CardBlockLayout, cardKeyDownHandler } from "../_shared/cards/CardBlockLayout"; import { toCssUrl } from "../_shared/utils"; -export type ContextCardBgColor = "gray" | "info" | "success" | "warning" | "danger"; +export type ContextCardBgColor = + "gray" | "neutral" | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted"; export interface ContextCardBlockItem { id?: string; - /** Plain string renders as small title text; any other node (e.g. a Tag) renders in the tag slot. */ + /** Plain string renders as small title text; any other node renders as a composed label (leading, title, subtitle). */ title?: ReactNode; /** Inline markdown supported. */ body?: string; + /** Composed bottom content (a title and subtitle); replaces body when given. */ + content?: ReactNode; + /** Controls at the top right, e.g. IconButtons. Clicks on them don't reach the card. */ + topRight?: ReactNode; bgColor?: ContextCardBgColor; bgImageSrc?: string; /** Alt text for the background image. */ @@ -29,7 +33,7 @@ export interface ContextCardProps { /** A compact tinted card with a title (text or tag) and a bold markdown body. */ export const ContextCard = forwardRef((props, ref) => { const { item, clickable = false, onClick, className } = props; - const { title, body, bgColor, bgImageSrc, bgImageAlt } = item; + const { title, body, content, topRight, bgColor, bgImageSrc, bgImageAlt } = item; const backgroundImage = toCssUrl(bgImageSrc); const variant = backgroundImage ? "image" : bgColor; @@ -37,9 +41,7 @@ export const ContextCard = forwardRef((props, title == null || title === "" ? null : typeof title === "string" ? ( {title} ) : ( -
-
{title}
-
+ title ); return ( @@ -51,7 +53,13 @@ export const ContextCard = forwardRef((props, variant && `openui-context-card--variant-${variant}`, className, )} - style={backgroundImage ? { backgroundImage } : undefined} + // The photo is drawn by a layer inside the card (see the scss) so it can + // zoom on hover without moving the frame or the text. + style={ + backgroundImage + ? ({ "--openui-context-card-image": backgroundImage } as CSSProperties) + : undefined + } aria-label={backgroundImage ? bgImageAlt : undefined} role={clickable ? "button" : undefined} tabIndex={clickable ? 0 : undefined} @@ -60,19 +68,29 @@ export const ContextCard = forwardRef((props, >
- {titleContent} - {clickable && ( - - {body && ( + {content ? (
-
- -
+ {content}
+ ) : ( + body && ( +
+
+ +
+
+ ) )}
diff --git a/packages/react-ui/src/components/ContextCardBlock/contextCardBlock.scss b/packages/react-ui/src/components/ContextCardBlock/contextCardBlock.scss index 0ebc21c61a..6e98497127 100644 --- a/packages/react-ui/src/components/ContextCardBlock/contextCardBlock.scss +++ b/packages/react-ui/src/components/ContextCardBlock/contextCardBlock.scss @@ -1,5 +1,7 @@ +@use "sass:list"; @use "../../cssUtils" as cssUtils; @use "../_shared/chatUtils" as chatUtils; +@use "../_shared/cards/clickable"; .openui-context-card { display: flex; @@ -7,9 +9,14 @@ justify-content: space-between; width: 100%; min-width: 196px; - padding: cssUtils.$space-m; + // A tall, expressive tile: the title sits at the top and the body + // settles at the bottom, with room between them for a background image. + min-height: 280px; + // Same insets as the vertical ValueCard (18px on the right, 14px under + // the body) and our 14px card radius. + padding: 12px 18px 14px 12px; border: none; - border-radius: cssUtils.$radius-xl; + border-radius: 14px; box-sizing: border-box; overflow: hidden; position: relative; @@ -20,16 +27,7 @@ &--clickable { background-color: cssUtils.$highlight; - cursor: pointer; - - &:hover { - transform: scale(0.995); - } - - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; - } + @include clickable.clickable-card; } &--static { @@ -37,105 +35,184 @@ box-shadow: none; } - &--variant-gray { - background-color: cssUtils.$highlight; - color: cssUtils.$text-neutral-primary; - } - - &--variant-info { - background-color: cssUtils.$info-background; - color: cssUtils.$text-info-primary; - - .openui-context-card__title-text { - color: cssUtils.$text-info-primary; - opacity: 0.8; - } - - .openui-context-card__body-text { - color: cssUtils.$text-info-primary; - } - - .openui-context-card__tag { - background-color: cssUtils.$elevated; + // Shared palette tints: each colour sets its background and a matching + // text colour for the title, subtitle and body. + $tints: ( + "neutral": ( + cssUtils.$highlight, + cssUtils.$text-neutral-primary, + ), + "gray": ( + cssUtils.$highlight, + cssUtils.$text-neutral-primary, + ), + "info": ( + cssUtils.$info-background, + cssUtils.$text-info-primary, + ), + "success": ( + cssUtils.$success-background, + cssUtils.$text-success-primary, + ), + "warning": ( + cssUtils.$alert-background, + cssUtils.$text-alert-primary, + ), + "danger": ( + cssUtils.$danger-background, + cssUtils.$text-danger-primary, + ), + "purple": ( + cssUtils.$purple-background, + cssUtils.$text-purple-primary, + ), + "pink": ( + cssUtils.$pink-background, + cssUtils.$text-pink-primary, + ), + "inverted": ( + cssUtils.$inverted-background, + cssUtils.$background, + ), + ); + @each $name, $pair in $tints { + &--variant-#{$name} { + background-color: list.nth($pair, 1); + color: list.nth($pair, 2); + + .openui-context-card__title-text, + .openui-context-card__title, + .openui-context-card__body-text { + color: list.nth($pair, 2); + } + .openui-context-card__title-text, + .openui-context-card__overline, + .openui-context-card__subtitle { + color: list.nth($pair, 2); + opacity: 0.75; + } } } - &--variant-success { - background-color: cssUtils.$success-background; - color: cssUtils.$text-success-primary; + &--variant-image { + color: cssUtils.$text-white; + background-color: cssUtils.$sunk; - .openui-context-card__title-text { - color: cssUtils.$text-success-primary; - opacity: 0.8; + // The photo layer, under the scrim (::before) and the content. On a + // clickable card it zooms gently on hover while the frame, scrim and text + // stay put; a card with no action stays still. + &::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background-image: var(--openui-context-card-image); + background-position: center; + background-size: cover; + transition: transform 0.4s ease; + pointer-events: none; } - .openui-context-card__body-text { - color: cssUtils.$text-success-primary; + &.openui-context-card--clickable:hover::after { + transform: scale(1.05); } - .openui-context-card__tag { - background-color: cssUtils.$elevated; + @media (prefers-reduced-motion: reduce) { + &::after { + transition: none; + } + &.openui-context-card--clickable:hover::after { + transform: none; + } } - } - - &--variant-warning { - background-color: cssUtils.$alert-background; - color: cssUtils.$text-alert-primary; .openui-context-card__title-text { - color: cssUtils.$text-alert-primary; - opacity: 0.8; + color: cssUtils.$text-white; + opacity: 0.6; } - .openui-context-card__body-text { - color: cssUtils.$text-alert-primary; + .openui-context-card__title { + color: cssUtils.$text-white; } - - .openui-context-card__tag { - background-color: cssUtils.$elevated; + .openui-context-card__overline, + .openui-context-card__subtitle { + color: cssUtils.$text-white; + opacity: 0.7; } - } - - &--variant-danger { - background-color: cssUtils.$danger-background; - color: cssUtils.$text-danger-primary; - .openui-context-card__title-text { - color: cssUtils.$text-danger-primary; - opacity: 0.8; - } + // Whatever the agent composes on a photo (Text, Markdown, Metric, + // LeadingText, Link, Tag text, Metric deltas, icons) takes the on-image + // colour: white, with the subtitle at 75%. Components set their own + // colours, so every descendant inherits instead. + .openui-context-card__slot--top, + .openui-context-card__slot--bottom { + color: cssUtils.$text-white; - .openui-context-card__body-text { - color: cssUtils.$text-danger-primary; + * { + color: inherit; + } } - - .openui-context-card__tag { - background-color: cssUtils.$elevated; + .openui-context-card__slot--bottom .openui-context-card__subtitle { + opacity: 1; + color: rgb(255 255 255 / 75%); } - } - &--variant-image { - background-position: center; - background-size: cover; - color: cssUtils.$text-white; - - .openui-context-card__title-text { + // On a photo, Tags and top-right IconButtons become frosted dark glass + // with white content so they read over any image. + .openui-context-card__subtitle .openui-tag.openui-tag, + .openui-context-card__subtitle .openui-tag-block .openui-tag.openui-tag, + .openui-context-card__top-right .openui-icon-button.openui-icon-button, + .openui-context-card__tag .openui-tag { + background-color: rgb(0 0 0 / 40%); color: cssUtils.$text-white; - opacity: 0.6; + border-color: transparent; + backdrop-filter: blur(8px); + } + .openui-context-card__top-right .openui-icon-button.openui-icon-button:hover { + background-color: rgb(0 0 0 / 55%); } - .openui-context-card__tag .openui-tag { + // The overline becomes a frosted pill the same height as the top-right + // IconButtons, so both top corners match. + .openui-context-card__overline { + align-self: flex-start; + height: 32px; + margin: -4px 0 0 -4px; + padding: 0 12px 0 10px; + border-radius: 999px; background-color: rgb(0 0 0 / 40%); - color: cssUtils.$text-white; - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(8px); + opacity: 1; } + // Eased scrims behind the text instead of a flat dim: a light one under + // the overline at the top and a deeper one under the title at the + // bottom, each fading out on an ease curve so the photo stays clear in + // the middle. &::before { content: ""; position: absolute; inset: 0; - background: cssUtils.$overlay; + background: + linear-gradient( + to bottom, + rgb(0 0 0 / 0.45) 0%, + rgb(0 0 0 / 0.38) 6%, + rgb(0 0 0 / 0.26) 14%, + rgb(0 0 0 / 0.14) 22%, + rgb(0 0 0 / 0.05) 30%, + rgb(0 0 0 / 0) 38% + ), + linear-gradient( + to top, + rgb(0 0 0 / 0.75) 0%, + rgb(0 0 0 / 0.66) 10%, + rgb(0 0 0 / 0.5) 22%, + rgb(0 0 0 / 0.32) 34%, + rgb(0 0 0 / 0.16) 46%, + rgb(0 0 0 / 0.05) 56%, + rgb(0 0 0 / 0) 64% + ); z-index: chatUtils.$z-content; } @@ -149,6 +226,8 @@ .openui-context-card__vertical { display: flex; flex: 1; + // Fill the card's height so the body sits at the bottom. + align-self: stretch; flex-direction: column; justify-content: space-between; min-width: 0; @@ -203,3 +282,52 @@ .openui-context-card__tag { border: none; } + +// Top: the overline, a small label with an optional inline leading. +.openui-context-card__overline { + display: flex; + align-items: center; + gap: 0.375em; + min-width: 0; + @include cssUtils.typography(body, small); + color: cssUtils.$text-neutral-secondary; +} + +.openui-context-card__overline-leading { + display: inline-flex; + flex: none; + align-items: center; + + > svg { + width: 1.15em; + height: 1.15em; + stroke-width: 1.5; + } +} + +// Bottom: the title in our 22px h2 size, with the composable subtitle under it. +.openui-context-card__title { + font: cssUtils.$font-weight-bold 22px/28px cssUtils.$font-heading; + font-variation-settings: cssUtils.$font-variation-bold; + letter-spacing: -0.012em; + color: cssUtils.$text-neutral-primary; +} + +.openui-context-card__subtitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375em; + margin-top: 4px; + @include cssUtils.typography(body, small); + color: cssUtils.$text-neutral-secondary; +} + +// Top-right controls (IconButtons) on an image card. +.openui-context-card__top-right { + display: flex; + flex: none; + align-items: center; + gap: 6px; + margin: -4px -8px 0 0; +} diff --git a/packages/react-ui/src/components/DatePicker/DatePicker.tsx b/packages/react-ui/src/components/DatePicker/DatePicker.tsx index 724ea316b2..873f995a0a 100644 --- a/packages/react-ui/src/components/DatePicker/DatePicker.tsx +++ b/packages/react-ui/src/components/DatePicker/DatePicker.tsx @@ -3,6 +3,7 @@ import { DateRange } from "react-day-picker"; import { useLayoutContext } from "../../context/LayoutContext"; import { FloatingDatePickerRenderer } from "./helpers/components/FloatingDatePickerRenderer"; import { DatePickerProvider } from "./helpers/context/DatePickerContext"; +import { toDate, toDateRange } from "./helpers/utils/helperFn"; export interface DatePickerProps { mode?: "single" | "range"; @@ -21,8 +22,8 @@ const DatePicker = (props: DatePickerProps) => { const { mode = "single", - selectedSingleDate, - selectedRangeDates, + selectedSingleDate: rawSingleDate, + selectedRangeDates: rawRangeDates, setSelectedSingleDate, setSelectedRangeDates, isOpen, @@ -31,6 +32,10 @@ const DatePicker = (props: DatePickerProps) => { style, } = props; + // Callers may pass strings, timestamps or partial ranges; see toDate. + const selectedSingleDate = toDate(rawSingleDate); + const selectedRangeDates = toDateRange(rawRangeDates); + const [internalSelectedDate, setInternalSelectedDate] = useState( selectedSingleDate, ); diff --git a/packages/react-ui/src/components/DatePicker/helpers/components/FloatingDatePickerRenderer.tsx b/packages/react-ui/src/components/DatePicker/helpers/components/FloatingDatePickerRenderer.tsx index c8ef34327e..9f42116a31 100644 --- a/packages/react-ui/src/components/DatePicker/helpers/components/FloatingDatePickerRenderer.tsx +++ b/packages/react-ui/src/components/DatePicker/helpers/components/FloatingDatePickerRenderer.tsx @@ -1,6 +1,6 @@ import { autoUpdate, flip, offset, useFloating } from "@floating-ui/react-dom"; import clsx from "clsx"; -import { ChevronDown } from "lucide-react"; +import { CalendarDays, ChevronDown } from "lucide-react"; import { forwardRef, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useMultipleRefs } from "../../../../hooks/useMultipleRefs"; @@ -29,6 +29,10 @@ const FloatingDateInput = () => { setIsOpen(!isOpen); }} > + {mode === "single" ? formatSingleDate(selectedDate) : formatDateRange(selectedRange)} diff --git a/packages/react-ui/src/components/DatePicker/helpers/components/floatingDatePickerRenderer.scss b/packages/react-ui/src/components/DatePicker/helpers/components/floatingDatePickerRenderer.scss index 88476d80aa..e315bc3378 100644 --- a/packages/react-ui/src/components/DatePicker/helpers/components/floatingDatePickerRenderer.scss +++ b/packages/react-ui/src/components/DatePicker/helpers/components/floatingDatePickerRenderer.scss @@ -1,4 +1,5 @@ @use "../../../../cssUtils" as cssUtils; +@use "../../../Input/genuiField" as field; /* Floating Date Picker Renderer Container Styles @@ -61,6 +62,11 @@ white-space: nowrap; } +// Leading calendar icon: only the genui date picker shows it. +.openui-date-picker-renderer-floating-input-container-leading { + display: none; +} + .openui-date-picker-renderer-floating-input-container-icon { transform: rotate(180deg); } @@ -83,11 +89,16 @@ z-index: 50; } +// Same lift as the Select dropdown (../../../_menu.scss): a hairline ring in +// the text color plus a soft two-step shadow, no hard border. .openui-date-picker-renderer-floating-menu { background: cssUtils.$foreground; border-radius: cssUtils.$radius-2xl; - box-shadow: cssUtils.$shadow-s; - border: 1px solid cssUtils.$border-interactive; + border: none; + box-shadow: + 0 0 0 1px color-mix(in oklch, #{cssUtils.$text-neutral-primary} 6%, transparent), + 0 4px 12px -2px oklch(0 0 0 / 0.05), + 0 16px 32px -8px oklch(0 0 0 / 0.08); overflow: hidden; transform-origin: top center; animation: openui-date-picker-show 0.18s ease-out forwards; @@ -111,3 +122,49 @@ transform: scale(1); } } + +// ─── Agent (genui) date picker ─── +// Same pill surface as the genui input, with a leading calendar icon. +.openui-genui-date-picker { + width: 100%; + + .openui-date-picker-renderer-floating-input-container { + @include field.pill; + gap: 0.571em; + + &-open { + border-color: cssUtils.$border-interactive-emphasis; + box-shadow: 0 0 0 3px cssUtils.$sunk; + } + + &-has-no-selected-date { + color: cssUtils.$text-neutral-secondary; + } + + & > svg { + @include field.icon; + } + } + + .openui-date-picker-renderer-floating-input-container-text { + flex: 1; + } + + .openui-date-picker-renderer-floating-input-container-leading { + display: block; + } + + &--inline .openui-date-picker-renderer-floating-input-container-leading { + display: none; + } + + &--inline .openui-date-picker-renderer-floating-input-container { + @include field.inline; + + &-open { + background-color: cssUtils.$foreground; + border-color: cssUtils.$border-interactive-emphasis; + box-shadow: none; + } + } +} diff --git a/packages/react-ui/src/components/DatePicker/helpers/utils/helperFn.tsx b/packages/react-ui/src/components/DatePicker/helpers/utils/helperFn.tsx index 93c062133c..0a69913b62 100644 --- a/packages/react-ui/src/components/DatePicker/helpers/utils/helperFn.tsx +++ b/packages/react-ui/src/components/DatePicker/helpers/utils/helperFn.tsx @@ -63,7 +63,41 @@ export const getMonthNumber = (monthName: string): number => { } }; -export const formatDateRange = (range: DateRange | undefined): string => { +/** + * Values reach the picker from agent output and from stored state, where a + * Date has usually become an ISO string (or a timestamp). Coerce anything + * date-like to a valid Date and drop the rest, so rendering never throws. + */ +export const toDate = (value: unknown): Date | undefined => { + if (value == null || value === "") return undefined; + const date = + value instanceof Date + ? value + : typeof value === "string" || typeof value === "number" + ? new Date(value) + : undefined; + return date && !Number.isNaN(date.getTime()) ? date : undefined; +}; + +/** Accepts {from, to} or [from, to] with any date-like ends; a missing start drops the range. */ +export const toDateRange = (value: unknown): DateRange | undefined => { + if (value == null) return undefined; + let from: unknown; + let to: unknown; + if (Array.isArray(value)) { + [from, to] = value; + } else if (typeof value === "object" && !(value instanceof Date)) { + ({ from, to } = value as { from?: unknown; to?: unknown }); + } else { + from = value; + } + const start = toDate(from); + if (!start) return undefined; + return { from: start, to: toDate(to) }; +}; + +export const formatDateRange = (value: DateRange | undefined): string => { + const range = toDateRange(value); if (!range) return "Select a range"; const { from, to } = range; @@ -81,7 +115,8 @@ export const formatDateRange = (range: DateRange | undefined): string => { return `${format(from, "MMM d, yyyy")} - ${format(to, "MMM d, yyyy")}`; }; -export const formatSingleDate = (date: Date | undefined): string => { +export const formatSingleDate = (value: Date | undefined): string => { + const date = toDate(value); if (!date) return "Select a date"; return format(date, "MMM d, yyyy"); }; diff --git a/packages/react-ui/src/components/EditableTable/EditableTable.tsx b/packages/react-ui/src/components/EditableTable/EditableTable.tsx index d7e9baabff..d62284b470 100644 --- a/packages/react-ui/src/components/EditableTable/EditableTable.tsx +++ b/packages/react-ui/src/components/EditableTable/EditableTable.tsx @@ -9,19 +9,9 @@ import { useReactTable, } from "@tanstack/react-table"; import clsx from "clsx"; -import { - CalendarDays, - Check, - ChevronLeft, - ChevronRight, - Link, - List, - RotateCcw, - Type, -} from "lucide-react"; +import { CalendarDays, Check, Hash, Link, List, RotateCcw, Type } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "../Button"; -import { IconButton } from "../IconButton"; import { getCellRenderer } from "./base/components/cellRegistry"; import type { EditableCellNavigateDirection, @@ -41,6 +31,8 @@ export interface EditableTableColumn { key: string; header: string; type?: EditableCellType; + /** Leading header icon; defaults to one for the column type */ + icon?: React.ReactNode; width?: number; /** Options for `select` type cells */ options?: Array<{ value: string; label: string }>; @@ -103,18 +95,28 @@ const CellRenderer = (info: CellRendererProps) => { const headerIcon = (type: EditableCellType) => { switch (type) { case "url": - return ; + return ; case "date-single": - return ; + return ; case "select": - return ; - case "text": + return ; case "number": + return ; + case "text": default: - return ; + return ; } }; +// Control cells (select, date) need room for the value plus the chevron. +const DEFAULT_COLUMN_WIDTH: Record = { + text: 160, + number: 100, + url: 180, + select: 140, + "date-single": 160, +}; + const PORTAL_CELL_TYPES: EditableCellType[] = ["date-single", "select"]; /** @@ -129,18 +131,6 @@ export const EditableTable = React.forwardRef(null); const tableRef = useRef(null); - const scrollContainerRef = useRef(null); - /** - * Horizontal scroll controls state - * - isScrollable: whether content overflows horizontally - * - canScrollLeft/canScrollRight: whether there is hidden content in that direction - * - columnLefts: cumulative left offsets of header cells relative to the scroll container - * Used to snap scrolling by exactly one column at a time. - */ - const [isScrollable, setIsScrollable] = useState(false); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const [columnLefts, setColumnLefts] = useState([]); React.useImperativeHandle(ref, () => tableRef.current as HTMLDivElement); @@ -264,7 +254,7 @@ export const EditableTable = React.forwardRef) => { - const type = (column.columnDef.meta as EditableColumnMeta | undefined)?.type; - return type ? headerIcon(type) : null; - }, []); - - /** - * Measure scrollability and header column positions. - * - Recomputes on container resize via ResizeObserver - * - Updates button enabled/disabled state on scroll - * - Computes `columnLefts` by reading each left position relative to the scroll container - */ - useEffect(() => { - const container = scrollContainerRef.current; - const wrapper = tableRef.current; - if (!container || !wrapper) return; - - const updateScrollButtons = () => { - setCanScrollLeft(container.scrollLeft > 0); - setCanScrollRight(container.scrollLeft < container.scrollWidth - container.clientWidth - 1); - }; - - const compute = () => { - setIsScrollable(container.scrollWidth > container.clientWidth); - updateScrollButtons(); - - // Measure header cell left positions relative to the scroll container - const ths = wrapper.querySelectorAll("thead th"); - if (!ths || ths.length === 0) { - setColumnLefts([]); - return; - } - const containerRect = container.getBoundingClientRect(); - const lefts: number[] = []; - ths.forEach((th) => { - const rect = th.getBoundingClientRect(); - const left = rect.left - containerRect.left + container.scrollLeft; - lefts.push(Math.max(0, Math.round(left))); - }); - // Ensure unique and sorted - setColumnLefts(Array.from(new Set(lefts)).sort((a, b) => a - b)); - }; - - compute(); - - const ro = new ResizeObserver(() => compute()); - ro.observe(container); - container.addEventListener("scroll", updateScrollButtons); - - return () => { - ro.disconnect(); - container.removeEventListener("scroll", updateScrollButtons); - }; - }, [columns, data]); - - /** - * Scroll right to the start of the next column that is not fully visible. - * If no next column is found, scrolls to the maximum possible scroll position. - */ - const scrollToNextColumn = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || columnLefts.length === 0) return; - const current = container.scrollLeft; - const target = columnLefts.find((l) => l > current + 1); - const maxScroll = container.scrollWidth - container.clientWidth; - const next = typeof target === "number" ? target : maxScroll; - container.scrollTo({ left: Math.min(next, maxScroll), behavior: "smooth" }); - }, [columnLefts]); - - /** - * Scroll left to the start of the previous column. - * Picks the nearest stored left offset smaller than current scrollLeft. - */ - const scrollToPrevColumn = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || columnLefts.length === 0) return; - const current = container.scrollLeft; - const prevs = columnLefts.filter((l) => l < current - 1); - const prev = prevs.at(-1) ?? 0; - container.scrollTo({ left: Math.max(0, prev), behavior: "smooth" }); - }, [columnLefts]); + const headerIconForColumn = useCallback( + (column: Column) => { + const custom = columns.find((c) => c.key === column.id)?.icon; + if (custom) return custom; + const type = (column.columnDef.meta as EditableColumnMeta | undefined)?.type; + return headerIcon(type ?? "text"); + }, + [columns], + ); return (
- {isScrollable && ( - <> -
- } - /> -
-
- } - /> -
- - )} -
- +
+
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} @@ -459,6 +341,9 @@ export interface EditableTableChangesBarProps { className?: string; } +// Agent pill styling: black primary and pill secondary, matching other genui buttons. +const GENUI_BUTTON_CLASS = "openui-genui-button openui-genui-button--default"; + /** * Pending-changes summary bar shown beneath an EditableTable, with reset / save actions. * Renders nothing when `changedCellCount` is 0. @@ -474,17 +359,26 @@ export const EditableTableChangesBar = ({ if (changedCellCount <= 0) return null; return (
-
{changedCellCount} changes made
+
+ {changedCellCount} {changedCellCount === 1 ? "change" : "changes"} made +
-
diff --git a/packages/react-ui/src/components/EditableTable/base/components/EditableDateCell.tsx b/packages/react-ui/src/components/EditableTable/base/components/EditableDateCell.tsx index c40f7b92c5..b7b0448039 100644 --- a/packages/react-ui/src/components/EditableTable/base/components/EditableDateCell.tsx +++ b/packages/react-ui/src/components/EditableTable/base/components/EditableDateCell.tsx @@ -19,7 +19,6 @@ export const EditableDateCell: React.FC = (props) => { columnId, isSelected, isEditing, - onSelect, onStartEdit, onFinishEdit, onNavigate, @@ -89,11 +88,8 @@ export const EditableDateCell: React.FC = (props) => { return; } e.stopPropagation(); - if (!isSelected) { - onSelect(rowIndex, columnId); - } else { - onStartEdit(rowIndex, columnId); - } + // One click opens the picker; keyboard navigation still selects without editing. + onStartEdit(rowIndex, columnId); }; const handleDateChange = (date: Date | undefined) => { @@ -106,7 +102,7 @@ export const EditableDateCell: React.FC = (props) => { return (
= (props) => { > = (props) => { columnId, isSelected, isEditing, - onSelect, onStartEdit, onFinishEdit, onNavigate, @@ -81,11 +80,8 @@ export const EditableSelectCell: React.FC = (props) => { ); const handleClick = () => { - if (!isSelected && !isEditing) { - onSelect(rowIndex, columnId); - } else if (isSelected && !isEditing) { - onStartEdit(rowIndex, columnId); - } + // One click opens the menu; keyboard navigation still selects without editing. + if (!isEditing) onStartEdit(rowIndex, columnId); }; const handleValueChange = (newValue: string) => { @@ -105,7 +101,7 @@ export const EditableSelectCell: React.FC = (props) => { return (
= (props) => { open={isEditing} onOpenChange={handleOpenChange} > - + diff --git a/packages/react-ui/src/components/EditableTable/base/components/TextBaseEditableCell.tsx b/packages/react-ui/src/components/EditableTable/base/components/TextBaseEditableCell.tsx index 7921cbd61c..9a3882281d 100644 --- a/packages/react-ui/src/components/EditableTable/base/components/TextBaseEditableCell.tsx +++ b/packages/react-ui/src/components/EditableTable/base/components/TextBaseEditableCell.tsx @@ -23,7 +23,6 @@ export const TextBaseEditableCell: React.FC = ({ columnId, isSelected, isEditing, - onSelect, onStartEdit, onFinishEdit, onNavigate, @@ -87,11 +86,8 @@ export const TextBaseEditableCell: React.FC = ({ // Let the url cell's anchor handle its own click. if ((e.target as HTMLElement).closest("a")) return; e.stopPropagation(); - if (!isSelected && !isEditing) { - onSelect(rowIndex, columnId); - } else { - onStartEdit(rowIndex, columnId); - } + // One click edits; keyboard navigation still selects without editing. + if (!isEditing) onStartEdit(rowIndex, columnId); }; const handleContainerBlur = (e: React.FocusEvent) => { diff --git a/packages/react-ui/src/components/EditableTable/base/components/editableCellBase.scss b/packages/react-ui/src/components/EditableTable/base/components/editableCellBase.scss index 104449619a..bc96f83b20 100644 --- a/packages/react-ui/src/components/EditableTable/base/components/editableCellBase.scss +++ b/packages/react-ui/src/components/EditableTable/base/components/editableCellBase.scss @@ -9,6 +9,15 @@ @include cssUtils.typography(body, small); } +// Cells that host an inline Select or DatePicker: the control brings its own +// 8px inset, so the cell pads 4px to land the value on the same 12px line as +// text cells. The 32px control centres in the 44px row. +.openui-editable-table-cell-base--control { + display: flex; + align-items: center; + padding: 0 cssUtils.$space-2xs; +} + .openui-editable-table-cell-input { height: 100%; width: 100%; diff --git a/packages/react-ui/src/components/EditableTable/base/editableTableBase.scss b/packages/react-ui/src/components/EditableTable/base/editableTableBase.scss index e11dc1e928..11ae83afc3 100644 --- a/packages/react-ui/src/components/EditableTable/base/editableTableBase.scss +++ b/packages/react-ui/src/components/EditableTable/base/editableTableBase.scss @@ -1,52 +1,21 @@ @use "../../../cssUtils" as cssUtils; -@use "../../_shared/chatUtils" as chatUtils; .openui-editable-table-wrapper { border: 1px solid cssUtils.$border-default; - border-radius: cssUtils.$radius-xl cssUtils.$radius-xl 0 0; + border-radius: cssUtils.$radius-2xl; overflow: hidden; position: relative; } -// Horizontal scroll container for the table. When content width exceeds the -// container, the scroll controls will become visible and enable snapping by column. +// Native horizontal scroll, matching Table: the table sizes to its columns +// (at least full width) and wider grids scroll inside the wrapper. .openui-editable-table-scroll-container { overflow: auto; - // Hide scrollbar while preserving scroll functionality - scrollbar-width: none; // Firefox - -ms-overflow-style: none; // IE/Edge - &::-webkit-scrollbar { - display: none; // Chrome/Safari/Opera - } -} - -// Scroll control button containers; they host IconButtons. -// Visibility is toggled via the `--disabled` modifier from component state. -.openui-editable-table-scroll-control { - position: absolute; - top: cssUtils.$space-s; - z-index: chatUtils.$z-content; - width: fit-content; - height: fit-content; - border-radius: cssUtils.$radius-s; - background-color: cssUtils.$elevated; - cursor: pointer; - - &.openui-editable-table-scroll-control--disabled { - cursor: not-allowed; - display: none; - } - - &.openui-editable-table-scroll-control--left { - left: cssUtils.$space-s; - } - - &.openui-editable-table-scroll-control--right { - right: cssUtils.$space-s; - } } .openui-editable-table-table { + width: max-content; + min-width: 100%; border-collapse: collapse; & .openui-select-trigger { @@ -54,23 +23,18 @@ width: 100%; } + // Header row mirrors Table: 13px Medium secondary labels, no fill. thead { th { text-align: left; - padding: cssUtils.$space-s cssUtils.$space-m; - border-right: 1px solid cssUtils.$border-default; - background-color: cssUtils.$highlight-subtle; + padding: 10px cssUtils.$space-m; + border-bottom: 1px solid cssUtils.$border-default; color: cssUtils.$text-neutral-secondary; - @include cssUtils.typography(body, small); - height: 48px; - overflow: hidden; - text-overflow: ellipsis; + @include cssUtils.typography(label, extra-small-heavy); + font-size: 13px; + line-height: 16px; white-space: nowrap; } - - th:last-child { - border-right: 0; - } } tbody { @@ -83,26 +47,29 @@ } td { - height: 48px; + height: 44px; padding: 0; - border-right: 1px solid cssUtils.$border-default; vertical-align: middle; white-space: nowrap; } - - td:last-child { - border-right: 0; - } } } .openui-editable-table-header-container { display: flex; align-items: center; - justify-content: space-between; - gap: cssUtils.$space-s; + gap: cssUtils.$space-2xs; + min-width: 0; } .openui-editable-table-header-icon { + display: flex; flex-shrink: 0; + color: cssUtils.$text-neutral-secondary; +} + +.openui-editable-table-header-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/packages/react-ui/src/components/EditableTable/editableTable.scss b/packages/react-ui/src/components/EditableTable/editableTable.scss index 03b8aa8917..5c56c9e73c 100644 --- a/packages/react-ui/src/components/EditableTable/editableTable.scss +++ b/packages/react-ui/src/components/EditableTable/editableTable.scss @@ -6,17 +6,15 @@ display: flex; flex-direction: row; gap: cssUtils.$space-s; - padding: cssUtils.$space-s cssUtils.$space-s cssUtils.$space-s cssUtils.$space-m; + // No fill, border or shadow: the bar sits directly on the card surface. + padding: cssUtils.$space-s 0 0; justify-content: space-between; align-items: center; - background-color: cssUtils.$elevated; - border-radius: cssUtils.$radius-xl; - border: 1px solid cssUtils.$border-default; - box-shadow: cssUtils.$shadow-s; } .openui-editable-table-changes-count { @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-secondary; } .openui-editable-table-changes-buttons { diff --git a/packages/react-ui/src/components/Emoji/Emoji.tsx b/packages/react-ui/src/components/Emoji/Emoji.tsx new file mode 100644 index 0000000000..850f05dafc --- /dev/null +++ b/packages/react-ui/src/components/Emoji/Emoji.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; + +export interface EmojiProps { + /** The emoji character(s), e.g. "🎉". */ + emoji: string; + /** Accessible label; omit for a purely decorative emoji. */ + label?: string; + className?: string; +} + +/** + * A single emoji sized to the text line like an Icon, so it can lead a + * LeadingText or a Callout. + */ +export const Emoji = ({ emoji, label, className }: EmojiProps) => ( + + {emoji} + +); diff --git a/packages/react-ui/src/components/Emoji/emoji.scss b/packages/react-ui/src/components/Emoji/emoji.scss new file mode 100644 index 0000000000..e2e6cf0203 --- /dev/null +++ b/packages/react-ui/src/components/Emoji/emoji.scss @@ -0,0 +1,11 @@ +// Sized like an Icon: about 1.1em, centered on the line it sits in. Emoji +// fonts carry their own color, so no color is set. +.openui-emoji { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.1em; + line-height: 1; + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; + font-variation-settings: normal; +} diff --git a/packages/react-ui/src/components/Emoji/index.ts b/packages/react-ui/src/components/Emoji/index.ts new file mode 100644 index 0000000000..a7eb4e670e --- /dev/null +++ b/packages/react-ui/src/components/Emoji/index.ts @@ -0,0 +1,2 @@ +export { Emoji } from "./Emoji"; +export type { EmojiProps } from "./Emoji"; diff --git a/packages/react-ui/src/components/EntityList/EntityList.tsx b/packages/react-ui/src/components/EntityList/EntityList.tsx index a42bb16325..05507f2d5d 100644 --- a/packages/react-ui/src/components/EntityList/EntityList.tsx +++ b/packages/react-ui/src/components/EntityList/EntityList.tsx @@ -1,10 +1,12 @@ import clsx from "clsx"; -import { forwardRef } from "react"; +import { forwardRef, type ReactNode } from "react"; import { InlineMarkdownRenderer } from "../InlineMarkdownRenderer"; export interface EntityListRow { - left: string; - right: string; + /** A label string, or a composed node such as an EntityTitle or LeadingText. */ + left: string | ReactNode; + /** A value string, or a node such as a Metric, Tag or Text. */ + right?: string | ReactNode; rightVariant?: "text" | "number"; } @@ -22,27 +24,39 @@ export interface EntityListProps { type RowType = "header" | "body" | "footer"; +const renderCell = (value: string | ReactNode) => + typeof value === "string" ? : value; + function EntityListRowView({ row, rowType }: { row: EntityListRow; rowType: RowType }) { const rightVariant = row.rightVariant ?? "text"; + // A composed left side (EntityTitle, LeadingText) takes the free width and + // centers with the value, instead of the 50/50 label/value split. + const composed = typeof row.left !== "string"; return ( -
- +
- - - - - + {renderCell(row.left)} +
+ {row.right != null && row.right !== "" && ( +
+ {renderCell(row.right)} +
+ )}
); } diff --git a/packages/react-ui/src/components/EntityList/entityList.scss b/packages/react-ui/src/components/EntityList/entityList.scss index 415941c2a6..cd0f6b8ffa 100644 --- a/packages/react-ui/src/components/EntityList/entityList.scss +++ b/packages/react-ui/src/components/EntityList/entityList.scss @@ -13,12 +13,33 @@ align-items: flex-start; gap: cssUtils.$space-m; padding: cssUtils.$space-m 0; - border-bottom: 1px solid cssUtils.$border-default; + // Dashed rules between rows, the same as the tool timeline's step rules. + border-bottom: 1px dashed cssUtils.$border-interactive; color: cssUtils.$text-neutral-primary; &:last-child { border-bottom: none; } + + // A composed label (EntityTitle, LeadingText) takes the free width and + // centers with its value. + &--composed { + align-items: center; + .openui-entity-list__cell-left { + flex: 1 1 auto; + max-width: none; + } + .openui-entity-list__cell-right { + flex: none; + max-width: 50%; + } + } + + // The header is a quiet 14px caption with no rule under it. + &--header { + padding: cssUtils.$space-xs 0; + border-bottom: none; + } } .openui-entity-list__cell-left, @@ -32,6 +53,16 @@ .openui-entity-list__cell-right { text-align: right; + + // text-align doesn't move flex content: a Metric (value + delta) or an + // array of pieces lines up on the right edge like string values. + > .openui-metric, + > .openui-inline-group { + justify-content: flex-end; + } + > .openui-inline-group { + display: flex; + } } .openui-entity-list--default { @@ -46,6 +77,7 @@ .openui-entity-list__row--header { .openui-entity-list__cell-left, .openui-entity-list__cell-right { + @include cssUtils.typography(body, small-heavy); color: cssUtils.$text-neutral-secondary; } } diff --git a/packages/react-ui/src/components/EntityTitle/EntityTitle.tsx b/packages/react-ui/src/components/EntityTitle/EntityTitle.tsx new file mode 100644 index 0000000000..213b3954ec --- /dev/null +++ b/packages/react-ui/src/components/EntityTitle/EntityTitle.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; +import { forwardRef, type ReactNode } from "react"; + +export type EntityTitleLayout = "horizontal" | "vertical"; + +export interface EntityTitleProps { + /** The visual in front of the title: an Avatar, an icon badge or an emoji. */ + leading?: ReactNode; + title: string; + /** A line of context: a string, or composed content such as a Tag, Metric or Link. */ + subtitle?: ReactNode; + layout?: EntityTitleLayout; + className?: string; +} + +/** + * A leading visual with a title and an optional subtitle, for naming a person, + * company, place or thing. The leading is sized to the text block beside it. + */ +export const EntityTitle = forwardRef( + ({ leading, title, subtitle, layout = "horizontal", className }, ref) => ( +
+ {leading && {leading}} +
+ {title} + {subtitle && ( + + {subtitle} + + )} +
+
+ ), +); + +EntityTitle.displayName = "EntityTitle"; diff --git a/packages/react-ui/src/components/EntityTitle/entityTitle.scss b/packages/react-ui/src/components/EntityTitle/entityTitle.scss new file mode 100644 index 0000000000..ef247a00ca --- /dev/null +++ b/packages/react-ui/src/components/EntityTitle/entityTitle.scss @@ -0,0 +1,78 @@ +@use "../../cssUtils" as cssUtils; + +// The leading matches the two-line text block (a 24px title over a 20px +// subtitle) at 44px. Vertical stacks a larger 48px leading over the text. +.openui-entity-title { + --openui-entity-title-leading: 44px; + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + + &--vertical { + --openui-entity-title-leading: 48px; + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + &__leading { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: var(--openui-entity-title-leading); + height: var(--openui-entity-title-leading); + color: cssUtils.$text-neutral-primary; + + > .openui-avatar { + width: 100%; + height: 100%; + vertical-align: 0; + } + // At 44-48px the inline avatar's 70% glyph reads too big; half the + // badge with a lighter stroke keeps it in proportion with the text. + > .openui-avatar .openui-avatar__icon { + width: 50%; + height: 50%; + svg { + stroke-width: 1.75px; + } + } + // An emoji on its own reads at about 70% of the box. + > .openui-emoji { + font-size: calc(var(--openui-entity-title-leading) * 0.7); + } + } + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + @include cssUtils.typography(body, heavy); + line-height: 24px; + color: cssUtils.$text-neutral-primary; + overflow-wrap: anywhere; + } + + &__subtitle { + @include cssUtils.typography(body, small); + line-height: 20px; + color: cssUtils.$text-neutral-secondary; + overflow-wrap: anywhere; + + // Composed content (Tag, Metric, Link, LeadingText) sits on one 20px + // line, a small step below the title. + &--composed { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 20px; + margin-top: 2px; + } + } +} diff --git a/packages/react-ui/src/components/EntityTitle/index.ts b/packages/react-ui/src/components/EntityTitle/index.ts new file mode 100644 index 0000000000..3af9c19583 --- /dev/null +++ b/packages/react-ui/src/components/EntityTitle/index.ts @@ -0,0 +1 @@ +export * from "./EntityTitle"; diff --git a/packages/react-ui/src/components/FollowUpBlock/FollowUpBlock.tsx b/packages/react-ui/src/components/FollowUpBlock/FollowUpBlock.tsx index 06d6c17dc0..39facc8a43 100644 --- a/packages/react-ui/src/components/FollowUpBlock/FollowUpBlock.tsx +++ b/packages/react-ui/src/components/FollowUpBlock/FollowUpBlock.tsx @@ -1,19 +1,25 @@ import clsx from "clsx"; -import { CSSProperties, forwardRef, ReactElement } from "react"; +import { Children, CSSProperties, forwardRef, ReactElement } from "react"; +import { DisclosureSection } from "../_shared/DisclosureSection"; import { FollowUpItemProps } from "../FollowUpItem"; export interface FollowUpBlockProps extends React.HTMLAttributes { children: ReactElement | ReactElement[]; + /** Header label. Defaults to "Related queries". */ + label?: string; + /** Start expanded. Collapsed by default. */ + defaultOpen?: boolean; className?: string; style?: CSSProperties; } const FollowUpBlock = forwardRef((props, ref) => { - const { children, className, style, ...rest } = props; + const { children, label = "Related queries", defaultOpen, className, style, ...rest } = props; return (
-
Related Queries
- {children} + +
{children}
+
); }); diff --git a/packages/react-ui/src/components/FollowUpBlock/followUpBlock.scss b/packages/react-ui/src/components/FollowUpBlock/followUpBlock.scss index 7c16c8cdae..6ce53d5f71 100644 --- a/packages/react-ui/src/components/FollowUpBlock/followUpBlock.scss +++ b/packages/react-ui/src/components/FollowUpBlock/followUpBlock.scss @@ -5,11 +5,9 @@ display: flex; flex-direction: column; - &__header { - color: cssUtils.$text-neutral-secondary; - border-bottom: 1px solid cssUtils.$border-default; - padding-top: cssUtils.$space-s; - padding-bottom: cssUtils.$space-s; - margin-bottom: cssUtils.$space-m; + // Rows sit flush under the header: no rule or gap above the first one. + &__items { + display: flex; + flex-direction: column; } } diff --git a/packages/react-ui/src/components/FollowUpItem/followUpItem.scss b/packages/react-ui/src/components/FollowUpItem/followUpItem.scss index b4fbc38482..31cc4db1c4 100644 --- a/packages/react-ui/src/components/FollowUpItem/followUpItem.scss +++ b/packages/react-ui/src/components/FollowUpItem/followUpItem.scss @@ -2,31 +2,62 @@ .openui-follow-up-item { @include cssUtils.button-reset; - @include cssUtils.typography(body, default); + @include cssUtils.typography(label, small-heavy); box-sizing: border-box; - padding: cssUtils.$space-s cssUtils.$space-000; - border-bottom: 1px solid cssUtils.$border-default; - background-color: transparent; - gap: cssUtils.$space-s; display: flex; align-items: center; justify-content: space-between; + gap: cssUtils.$space-s; width: 100%; - color: cssUtils.$text-neutral-primary; + min-height: 28px; + padding: cssUtils.$space-2xs cssUtils.$space-s; + border-radius: cssUtils.$radius-m; + background-color: transparent; + color: cssUtils.$text-neutral-secondary; cursor: pointer; text-align: left; + transition: + background-color 0.12s ease, + color 0.12s ease; + &-icon { - size: 16px; + display: inline-flex; + flex-shrink: 0; + color: cssUtils.$text-neutral-tertiary; + transition: color 0.12s ease; + + svg { + width: 14px; + height: 14px; + } } &-text { flex: 1; + min-width: 0; white-space: break-spaces; word-break: break-word; - max-width: 100%; text-align: left; + } + + &:hover { + background-color: cssUtils.$highlight-subtle; + color: cssUtils.$text-neutral-primary; + } + &:hover &-icon { color: cssUtils.$text-neutral-primary; } - &:last-child { - border-bottom: none; + &:focus { + outline: none; + } + &:focus-visible { + outline: 2px solid cssUtils.$border-interactive-emphasis; + outline-offset: -2px; + } + + @media (prefers-reduced-motion: reduce) { + &, + &-icon { + transition: none; + } } } diff --git a/packages/react-ui/src/components/FormControl/formControl.scss b/packages/react-ui/src/components/FormControl/formControl.scss index 051357661a..b210d45fcc 100644 --- a/packages/react-ui/src/components/FormControl/formControl.scss +++ b/packages/react-ui/src/components/FormControl/formControl.scss @@ -7,3 +7,49 @@ flex-direction: column; gap: cssUtils.$space-s; } + +// Agent (genui) form control: label, field and hint sit closer, and the hint +// uses the 12px small text. +.openui-genui-form-control { + gap: 0.571em; + @include cssUtils.typography(body, small); + + .openui-hint { + @include cssUtils.typography(label, extra-small); + gap: 0.333em; + } +} + +// An error with no hint to replace floats in the gap below the field instead +// of pushing the next field down. A hint, when present, swaps in place. +.openui-genui-form-control { + position: relative; + + .openui-genui-form-control__floating-error { + position: absolute; + top: calc(100% + 2px); + left: 0; + right: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +// FormRow: short fields side by side, one column when the form is narrow. +.openui-genui-form-row { + container-type: inline-size; + width: 100%; + + &__grid { + display: grid; + grid-template-columns: repeat(var(--openui-form-row-columns, 2), minmax(0, 1fr)); + gap: 16px 12px; + } + + @container (max-width: 28rem) { + &__grid { + grid-template-columns: minmax(0, 1fr); + } + } +} diff --git a/packages/react-ui/src/components/Grid/Grid.tsx b/packages/react-ui/src/components/Grid/Grid.tsx new file mode 100644 index 0000000000..a95ba607cb --- /dev/null +++ b/packages/react-ui/src/components/Grid/Grid.tsx @@ -0,0 +1,17 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export interface GridProps { + /** Most columns at full width; narrower containers drop to fewer. */ + columns?: 2 | 3 | 4; + children?: ReactNode; + className?: string; +} + +/** Equal-width responsive columns for dashboards. */ +export const Grid = ({ columns = 2, children, className }: GridProps) => ( + // The outer div is the size container the column breakpoints query. +
+
{children}
+
+); diff --git a/packages/react-ui/src/components/Grid/grid.scss b/packages/react-ui/src/components/Grid/grid.scss new file mode 100644 index 0000000000..43fa31c299 --- /dev/null +++ b/packages/react-ui/src/components/Grid/grid.scss @@ -0,0 +1,50 @@ +// Equal columns that step down by the Grid's own width (container queries), +// never leaving a lone orphan: 4 → 2 → 1, 3 → 1, 2 → 1. A column is never +// narrower than about 176px. +.openui-grid { + container-type: inline-size; + min-width: 0; + + &__cells { + display: grid; + grid-template-columns: repeat(var(--openui-grid-columns, 2), minmax(0, 1fr)); + gap: 12px; + + // Cells stretch to the row's height, so cards in a row line up. + > * { + min-width: 0; + } + + &--2 { + --openui-grid-columns: 2; + } + &--3 { + --openui-grid-columns: 3; + } + &--4 { + --openui-grid-columns: 4; + } + } +} + +// 4 columns need 4 × 176 + 3 × 12 = 740px; below that, 2. +@container (max-width: 739px) { + .openui-grid__cells--4 { + --openui-grid-columns: 2; + } +} + +// 3 columns need 3 × 176 + 2 × 12 = 552px; below that, 1. +@container (max-width: 551px) { + .openui-grid__cells--3 { + --openui-grid-columns: 1; + } +} + +// 2 columns need 2 × 176 + 12 = 364px; below that, 1 (a 4-col Grid too). +@container (max-width: 363px) { + .openui-grid__cells--2, + .openui-grid__cells--4 { + --openui-grid-columns: 1; + } +} diff --git a/packages/react-ui/src/components/Grid/index.ts b/packages/react-ui/src/components/Grid/index.ts new file mode 100644 index 0000000000..e513090931 --- /dev/null +++ b/packages/react-ui/src/components/Grid/index.ts @@ -0,0 +1,2 @@ +export { Grid } from "./Grid"; +export type { GridProps } from "./Grid"; diff --git a/packages/react-ui/src/components/Heading/Heading.tsx b/packages/react-ui/src/components/Heading/Heading.tsx new file mode 100644 index 0000000000..dad15ffece --- /dev/null +++ b/packages/react-ui/src/components/Heading/Heading.tsx @@ -0,0 +1,53 @@ +import clsx from "clsx"; +import type { CSSProperties, ReactNode } from "react"; + +export interface HeadingTextStyle { + font: string; + axis: string; + letterSpacing: string; +} + +export interface HeadingProps { + title: string; + description?: string; + icon?: ReactNode; + titleStyle: HeadingTextStyle; + descriptionStyle: HeadingTextStyle; + size?: "default" | "lg"; + className?: string; +} + +const toStyle = (s: HeadingTextStyle): CSSProperties => ({ + font: s.font, + fontVariationSettings: s.axis, + letterSpacing: s.letterSpacing, +}); + +/** A title with an optional supporting line underneath, and an optional icon. */ +export const Heading = ({ + title, + description, + icon, + titleStyle, + descriptionStyle, + size = "default", + className, +}: HeadingProps) => ( +
+
+ {icon && ( + + {icon} + + )} +
+ {title} +
+
+ {description && ( +
+ {description} +
+ )} +
+); diff --git a/packages/react-ui/src/components/Heading/heading.scss b/packages/react-ui/src/components/Heading/heading.scss new file mode 100644 index 0000000000..55252d6f62 --- /dev/null +++ b/packages/react-ui/src/components/Heading/heading.scss @@ -0,0 +1,46 @@ +@use "../../cssUtils" as cssUtils; + +.openui-heading { + display: flex; + flex-direction: column; + min-width: 0; + + &__title-row { + display: flex; + align-items: flex-start; + gap: 0.4em; + } + + // Same line-height frame as TextWithIcon, sized off the title's font. + &__icon { + display: inline-flex; + flex: none; + align-items: center; + height: 1lh; + color: cssUtils.$text-neutral-primary; + // Icon at the title size, with the same 1.5 stroke as TextWithIcon. + svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + } + } + + &__title { + color: cssUtils.$text-neutral-primary; + min-width: 0; + } + + // Gaps are in the description's own em, so they scale with the size. + &__description { + margin-top: 0.15em; + color: cssUtils.$text-neutral-secondary; + } + + // Hero: more air between the h1 title and its description. + &--lg { + .openui-heading__description { + margin-top: 0.5em; + } + } +} diff --git a/packages/react-ui/src/components/Heading/index.ts b/packages/react-ui/src/components/Heading/index.ts new file mode 100644 index 0000000000..dd0d05a468 --- /dev/null +++ b/packages/react-ui/src/components/Heading/index.ts @@ -0,0 +1,2 @@ +export { Heading } from "./Heading"; +export type { HeadingProps, HeadingTextStyle } from "./Heading"; diff --git a/packages/react-ui/src/components/IconButton/iconButton.scss b/packages/react-ui/src/components/IconButton/iconButton.scss index a4711f7f8a..8aeb130249 100644 --- a/packages/react-ui/src/components/IconButton/iconButton.scss +++ b/packages/react-ui/src/components/IconButton/iconButton.scss @@ -214,3 +214,49 @@ border-radius: cssUtils.$radius-full; } } + +// ─── Agent (genui) icon buttons ─── +// Round, 32px or 40px, matching the agent Button pills. Scoped to +// .openui-genui-icon-button so app chrome icon buttons are unchanged. +.openui-icon-button.openui-genui-icon-button { + border-radius: 999px; + box-shadow: none; + + &--default { + width: 32px; + height: 32px; + & svg { + width: 16px; + height: 16px; + } + } + &--large { + width: 40px; + height: 40px; + & svg { + width: 18px; + height: 18px; + } + } + + // Primary: the black inverted fill (white in dark mode), like Button. + &.openui-icon-button-primary { + background-color: cssUtils.$inverted-background; + border-color: cssUtils.$inverted-background; + color: cssUtils.$background; + &:not(:disabled):hover { + background-color: cssUtils.$inverted-background; + opacity: 0.85; + } + &:not(:disabled):active { + opacity: 0.75; + } + &:disabled { + opacity: 0.4; + } + } + + &.openui-icon-button-secondary { + box-shadow: none; + } +} diff --git a/packages/react-ui/src/components/Image/Image.tsx b/packages/react-ui/src/components/Image/Image.tsx index 2774ee6de1..e91314edf5 100644 --- a/packages/react-ui/src/components/Image/Image.tsx +++ b/packages/react-ui/src/components/Image/Image.tsx @@ -1,12 +1,13 @@ import * as AspectRatio from "@radix-ui/react-aspect-ratio"; import clsx from "clsx"; -import React, { forwardRef, useState } from "react"; +import React, { forwardRef, useCallback, useState } from "react"; -type AspectRatioType = "1:1" | "3:2" | "3:4" | "4:3" | "16:9"; +type AspectRatioType = "auto" | "1:1" | "3:2" | "3:4" | "4:3" | "16:9" | "9:16"; type ScaleType = "fit" | "fill"; export interface ImageProps extends React.ImgHTMLAttributes { - src: string; + /** Empty or missing keeps the loading frame and renders no . */ + src?: string; alt?: string; styles?: React.CSSProperties; className?: string; @@ -14,12 +15,13 @@ export interface ImageProps extends React.ImgHTMLAttributes { scale?: ScaleType; } -const aspectRatioMap: Record = { +const aspectRatioMap: Record, number> = { "1:1": 1, "3:2": 3 / 2, "3:4": 3 / 4, "4:3": 4 / 3, "16:9": 16 / 9, + "9:16": 9 / 16, }; const scaleMap: Record = { @@ -30,30 +32,64 @@ const scaleMap: Record = { export const Image = forwardRef((props, ref) => { const { src, alt, styles, className, aspectRatio = "3:2", scale = "fill", ...rest } = props; const [hasError, setHasError] = useState(false); + const [loaded, setLoaded] = useState(false); + + // Forward the ref, and catch images that finished loading before hydration + // attached onLoad (cached or server-rendered), so the shimmer doesn't stick. + const setRef = useCallback( + (node: HTMLImageElement | null) => { + if (node?.complete && node.naturalWidth > 0) setLoaded(true); + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); const imageClasses = clsx( "openui-image", { - [`${scaleMap[scale]}`]: scale, + [`${scaleMap[scale]}`]: scale && aspectRatio !== "auto", + "openui-image--auto": aspectRatio === "auto", "openui-image--error": hasError, }, className, ); - const image = ( + // No src yet (still streaming, or the agent sent an empty one): keep the + // loading frame and render no , since src="" makes the browser + // re-request the page. + const image = src ? ( {alt} setHasError(false)} - onError={() => setHasError(true)} + onLoad={() => { + setHasError(false); + setLoaded(true); + }} + onError={() => { + setHasError(true); + setLoaded(true); + }} {...rest} /> - ); + ) : null; - return {image}; + // "auto" keeps the image's own proportions; the others crop it to the frame. + // The frame clips and rounds the image, so the hover zoom stays inside it. + const frameClass = clsx( + "openui-image-frame", + (!loaded || !src) && "openui-image-frame--loading", + aspectRatio === "auto" && "openui-image-frame--auto", + ); + if (aspectRatio === "auto") return
{image}
; + return ( + + {image} + + ); }); Image.displayName = "Image"; diff --git a/packages/react-ui/src/components/Image/image.scss b/packages/react-ui/src/components/Image/image.scss index f8ec9fae6b..fe2204ed44 100644 --- a/packages/react-ui/src/components/Image/image.scss +++ b/packages/react-ui/src/components/Image/image.scss @@ -1,14 +1,70 @@ @use "../../cssUtils.scss" as cssUtils; +// The frame owns the border and radius and clips the image, so the hover +// zoom (clickable cards only) scales the picture without growing the frame. +// Radius defaults to 14px. Host components override it internally by setting +// --openui-image-radius; it is not a prop, so the agent can't change it. +.openui-image-frame { + box-sizing: border-box; + overflow: hidden; + border-radius: var(--openui-image-radius, 14px); + border: 1px solid cssUtils.$border-default; + background-color: cssUtils.$foreground; + + // The hover zoom is a click affordance, so it runs only when a clickable + // card holds the image; a plain Image stays still. + [class*="--clickable"]:hover & .openui-image { + transform: scale(1.04); + } + + // Until the image loads: a soft shimmer in the frame, the image hidden. + // An "auto" frame has no height yet, so it holds a 3:2 box meanwhile. + &--loading { + background-image: linear-gradient( + 100deg, + transparent 20%, + color-mix(in oklab, cssUtils.$text-neutral-primary 6%, transparent) 50%, + transparent 80% + ); + background-color: cssUtils.$sunk; + background-size: 200% 100%; + animation: openui-image-shimmer 1.4s ease-in-out infinite; + + .openui-image { + opacity: 0; + } + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } + &--loading.openui-image-frame--auto { + aspect-ratio: 3 / 2; + } +} + +@keyframes openui-image-shimmer { + from { + background-position: 150% 0; + } + to { + background-position: -50% 0; + } +} + .openui-image { display: flex; box-sizing: border-box; max-width: 100%; height: auto; overflow: clip; - border-radius: cssUtils.$radius-m; - border: 1px solid cssUtils.$border-default; - background-color: cssUtils.$foreground; + transition: + transform 300ms ease-out, + opacity 200ms ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + transform: none !important; + } &-fit { object-fit: contain; @@ -22,6 +78,12 @@ height: 100%; } + // Natural proportions: full width, height from the image itself. + &--auto { + width: 100%; + height: auto; + } + &--error { visibility: hidden; } diff --git a/packages/react-ui/src/components/ImageGallery/GalleryModal.tsx b/packages/react-ui/src/components/ImageGallery/GalleryModal.tsx index e10139f46e..ce33ca244c 100644 --- a/packages/react-ui/src/components/ImageGallery/GalleryModal.tsx +++ b/packages/react-ui/src/components/ImageGallery/GalleryModal.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; -import { ChevronLeft, ChevronRight, X } from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import React, { useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { IconButton } from "../IconButton"; import { useTheme } from "../ThemeProvider"; @@ -19,43 +19,10 @@ export const GalleryModal: React.FC = ({ setSelectedImageIndex, onClose, }) => { - const [scrollButtons, setScrollButtons] = useState({ showLeft: false, showRight: false }); const carouselRef = useRef(null); - const modalContentRef = useRef(null); const { portalThemeClassName } = useTheme(); - // Check if scrolling is needed - const checkScroll = useCallback(() => { - if (!carouselRef.current) return; - - const container = carouselRef.current; - setScrollButtons({ - showLeft: container.scrollLeft > 0, - showRight: container.scrollLeft + container.offsetWidth < container.scrollWidth, - }); - }, []); - - useEffect(() => { - if (!carouselRef.current) { - return; - } - - const container = carouselRef.current; - // Initial check - checkScroll(); - - const resizeObserver = new ResizeObserver(checkScroll); - resizeObserver.observe(container); - - container.addEventListener("scroll", checkScroll); - - return () => { - container.removeEventListener("scroll", checkScroll); - resizeObserver.disconnect(); - }; - }, [checkScroll]); - - // Handle modal lifecycle events (scroll lock, close on escape, close on outside click) + // Handle lightbox lifecycle events (scroll lock, close on escape, arrow key navigation) useEffect(() => { // Disable body scroll document.body.style.overflow = "hidden"; @@ -63,45 +30,35 @@ export const GalleryModal: React.FC = ({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); - } - }; - - const handleOutsideClick = (e: MouseEvent) => { - if (modalContentRef.current && !modalContentRef.current.contains(e.target as Node)) { - onClose(); + } else if (e.key === "ArrowLeft" && selectedImageIndex > 0) { + setSelectedImageIndex(selectedImageIndex - 1); + } else if (e.key === "ArrowRight" && selectedImageIndex < images.length - 1) { + setSelectedImageIndex(selectedImageIndex + 1); } }; document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleOutsideClick); // Cleanup return () => { document.body.style.overflow = "auto"; document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleOutsideClick); }; - }, [onClose]); + }, [onClose, selectedImageIndex, setSelectedImageIndex, images.length]); - const scroll = (direction: "left" | "right") => { - if (carouselRef.current) { - const container = carouselRef.current; - const containerRect = container.getBoundingClientRect(); - const scrollAmount = containerRect.width * 0.28; - - if (direction === "left") { - container.scrollBy({ - left: -scrollAmount, - behavior: "smooth", - }); - } else { - container.scrollBy({ - left: scrollAmount, - behavior: "smooth", - }); - } - } - }; + // Keep the active thumbnail in view + useEffect(() => { + const active = carouselRef.current?.children[selectedImageIndex] as HTMLElement | undefined; + active?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + }, [selectedImageIndex]); + + // Close when the empty backdrop around the image is clicked + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }, + [onClose], + ); // Memoize thumbnail click handler const handleThumbnailClick = useCallback( @@ -110,100 +67,50 @@ export const GalleryModal: React.FC = ({ ); return createPortal( -
-
-
- - All Photos - - } - onClick={onClose} - aria-label="Close gallery" - /> -
-
- {images[selectedImageIndex]?.alt -
-
- {scrollButtons.showLeft && ( -
- scroll("left")} - aria-label="Scroll images left" - icon={} - variant="secondary" - size="extra-small" - /> -
- )} - -
- {images.map((image, index) => ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleThumbnailClick(index)(); - } - }} - > - -
- ))} +
+ } + onClick={onClose} + aria-label="Close gallery" + /> +
+ {images[selectedImageIndex]?.alt +
+
+ {images.map((image, index) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleThumbnailClick(index)(); + } + }} + > +
- - {scrollButtons.showRight && ( -
- scroll("right")} - aria-label="Scroll images right" - icon={} - variant="secondary" - size="extra-small" - /> -
- )} -
+ ))}
, document.body, diff --git a/packages/react-ui/src/components/ImageGallery/ImageGallery.tsx b/packages/react-ui/src/components/ImageGallery/ImageGallery.tsx index 5e9b23daac..e97ce3bdf5 100644 --- a/packages/react-ui/src/components/ImageGallery/ImageGallery.tsx +++ b/packages/react-ui/src/components/ImageGallery/ImageGallery.tsx @@ -1,6 +1,5 @@ import clsx from "clsx"; import React, { useCallback, useMemo, useState } from "react"; -import { Button } from "../Button"; import { GalleryModal } from "./GalleryModal"; export interface ImageItem { @@ -29,27 +28,28 @@ const getLayoutClassName = (imageCount: number): string => { } }; -export const ImageGallery: React.FC = ({ images }) => { - const [showAll, setShowAll] = useState(false); +export const ImageGallery: React.FC = ({ images: allImages }) => { + // Skip images without a src yet (mid-stream or empty): src="" is invalid. + const images = useMemo(() => allImages.filter((image) => !!image?.src), [allImages]); + // Clicking any tile opens the lightbox, which browses every image, including + // ones past the first five that the grid doesn't show. + const [isOpen, setIsOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // Memoize layout class to prevent recalculation const layoutClass = useMemo(() => getLayoutClassName(images.length), [images.length]); - // Memoize whether to show button - const shouldShowButton = useMemo(() => images.length > MAX_GRID_IMAGES, [images.length]); - // Memoize visible images const visibleImages = useMemo(() => images.slice(0, MAX_GRID_IMAGES), [images]); // Memoize callbacks - const toggleShowAll = useCallback(() => { - setShowAll((prev) => !prev); + const closeModal = useCallback(() => { + setIsOpen(false); }, []); const handleImageClick = useCallback((index: number) => { setSelectedImageIndex(index); - setShowAll(true); + setIsOpen(true); }, []); const setSelectedImageIndexMemoized = useCallback((index: number) => { @@ -68,21 +68,14 @@ export const ImageGallery: React.FC = ({ images }) => { {image.alt
))} - {shouldShowButton && ( -
- -
- )}
- {showAll && ( + {isOpen && ( )}
diff --git a/packages/react-ui/src/components/ImageGallery/imageGallery.scss b/packages/react-ui/src/components/ImageGallery/imageGallery.scss index 3224ae322f..9e631bb5c8 100644 --- a/packages/react-ui/src/components/ImageGallery/imageGallery.scss +++ b/packages/react-ui/src/components/ImageGallery/imageGallery.scss @@ -12,7 +12,7 @@ display: grid; width: 100%; max-height: 376px; - grid-gap: cssUtils.$space-s; + grid-gap: cssUtils.$space-2xs; } // Layout variations @@ -86,57 +86,32 @@ } } - &__show-all-button { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - bottom: 5px; - right: 5px; - } - - // Modal styles + // Lightbox styles. The backdrop is always dark, in both themes, so the + // photo reads as the focus. &__modal { position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; + inset: 0; display: flex; - justify-content: center; - align-items: center; + flex-direction: column; + gap: 0.75em; + padding: 3em 1em 1em; + box-sizing: border-box; z-index: 1000; animation: fadeIn 0.3s ease; - backdrop-filter: blur(3px); - background-color: cssUtils.$overlay; + background-color: rgba(0, 0, 0, 0.92); } - &__modal-content { - width: calc(100% - 80px); - height: calc(100% - 80px); - max-width: 720px; - max-height: 640px; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; - border: 1px solid cssUtils.$border-default; - background-color: cssUtils.$foreground; - border-radius: cssUtils.$radius-xl; - gap: cssUtils.$space-xl; - padding-top: cssUtils.$space-xl; - padding-bottom: cssUtils.$space-xl; - } - &__modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding-left: cssUtils.$space-xl; - padding-right: cssUtils.$space-xl; - } - &__modal-heading { - @include cssUtils.typography(heading, extra-small); - color: cssUtils.$text-neutral-primary; + &__modal-close.openui-icon-button { + position: absolute; + top: 0.75em; + right: 0.75em; + color: cssUtils.$text-white; + + &:not(:disabled):hover, + &:not(:disabled):active { + background-color: rgba(255, 255, 255, 0.12); + border-color: transparent; + } } &__modal-main { @@ -145,86 +120,63 @@ display: flex; justify-content: center; align-items: center; - padding-left: cssUtils.$space-xl; - padding-right: cssUtils.$space-xl; + // Show the photo at its native size at most, never upscaled, and + // shrink it to fit when it is larger than the lightbox img { + display: block; + width: auto; + height: auto; max-width: 100%; max-height: 100%; object-fit: contain; - border-radius: cssUtils.$radius-s; - } - &:hover { - .openui-gallery__modal-details { - display: flex; - } } } - &__modal-carousel-container { - position: relative; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - } - &__modal-carousel { display: flex; - gap: cssUtils.$space-s; + justify-content: flex-start; + gap: 0.375em; overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none; - padding-left: cssUtils.$space-xl; - padding-right: cssUtils.$space-xl; + // Room for the active ring so it is not clipped by the scroll container + padding: 0.125em; + flex-shrink: 0; &::-webkit-scrollbar { display: none; } } - &__modal-carousel-button-container { - position: absolute; - top: 50%; - transform: translateY(-50%); - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - background-color: cssUtils.$foreground; - border-radius: cssUtils.$radius-xs; - overflow: hidden; - &-left { - left: 24px; - } - &-right { - right: 24px; - } - } - &__modal-thumbnail { - flex: 1; - height: 116px; - min-width: 174px; + flex: 0 0 auto; + width: 3.5em; + height: 3.5em; cursor: pointer; - opacity: 0.6; - border-radius: cssUtils.$radius-s; - border: 1px solid transparent; + opacity: 0.5; + border-radius: cssUtils.$radius-m; overflow: hidden; - transition: opacity 0.2s ease; - box-sizing: border-box; + transition: + opacity 0.2s ease, + box-shadow 0.2s ease; &:hover { opacity: 0.8; } + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6); + } + &--active { opacity: 1; - border-color: cssUtils.$border-interactive-selected; - border-radius: cssUtils.$radius-s; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9); } img { + display: block; width: 100%; height: 100%; object-fit: cover; @@ -288,13 +240,3 @@ } } } - -@media (max-width: 768px) { - .openui-gallery__modal-content { - width: 100%; - height: 100%; - border-radius: 0; - max-height: none; - max-width: none; - } -} diff --git a/packages/react-ui/src/components/ImageTitle/ImageTitle.tsx b/packages/react-ui/src/components/ImageTitle/ImageTitle.tsx new file mode 100644 index 0000000000..115036840c --- /dev/null +++ b/packages/react-ui/src/components/ImageTitle/ImageTitle.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; +import { forwardRef, type ReactNode } from "react"; + +export interface ImageTitleProps { + /** The image on top, usually an Image. */ + image?: ReactNode; + /** What sits under the image: a title Text, an EntityTitle, an author line, a price. */ + children?: ReactNode; + className?: string; +} + +/** An image with a composable slot under it. */ +export const ImageTitle = forwardRef( + ({ image, children, className }, ref) => ( +
+ {image &&
{image}
} + {children &&
{children}
} +
+ ), +); + +ImageTitle.displayName = "ImageTitle"; diff --git a/packages/react-ui/src/components/ImageTitle/imageTitle.scss b/packages/react-ui/src/components/ImageTitle/imageTitle.scss new file mode 100644 index 0000000000..849398b4e6 --- /dev/null +++ b/packages/react-ui/src/components/ImageTitle/imageTitle.scss @@ -0,0 +1,31 @@ +@use "../../cssUtils" as cssUtils; + +// An image over a stack of composed content. Spacing is in em off 16px. +.openui-image-title { + display: flex; + flex-direction: column; + font-size: 16px; + // In a row (Stack, grid) items share the width evenly instead of + // collapsing to their text, and wrap below 10em. + flex: 1 1 10em; + min-width: 10em; + + &__image { + margin-bottom: 0.5em; + } + + &__content { + display: flex; + flex-direction: column; + align-items: flex-start; + // No extra gap: lines sit flush like EntityTitle's title and subtitle. + gap: 0; + min-width: 0; + + // A row of pills needs a little air from the text above it. + > .openui-tag-block, + > .openui-inline-group:has(> .openui-tag) { + margin-top: 0.375em; + } + } +} diff --git a/packages/react-ui/src/components/ImageTitle/index.ts b/packages/react-ui/src/components/ImageTitle/index.ts new file mode 100644 index 0000000000..299293acb1 --- /dev/null +++ b/packages/react-ui/src/components/ImageTitle/index.ts @@ -0,0 +1 @@ +export * from "./ImageTitle"; diff --git a/packages/react-ui/src/components/InlineGroup/InlineGroup.tsx b/packages/react-ui/src/components/InlineGroup/InlineGroup.tsx new file mode 100644 index 0000000000..b35f6faf01 --- /dev/null +++ b/packages/react-ui/src/components/InlineGroup/InlineGroup.tsx @@ -0,0 +1,11 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +/** Lays several small pieces (a Metric and a Tag, two Tags) out on one line. */ +export const InlineGroup = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => {children}; diff --git a/packages/react-ui/src/components/InlineGroup/index.ts b/packages/react-ui/src/components/InlineGroup/index.ts new file mode 100644 index 0000000000..c89cf3dc46 --- /dev/null +++ b/packages/react-ui/src/components/InlineGroup/index.ts @@ -0,0 +1 @@ +export * from "./InlineGroup"; diff --git a/packages/react-ui/src/components/InlineGroup/inlineGroup.scss b/packages/react-ui/src/components/InlineGroup/inlineGroup.scss new file mode 100644 index 0000000000..3c220c8dbc --- /dev/null +++ b/packages/react-ui/src/components/InlineGroup/inlineGroup.scss @@ -0,0 +1,10 @@ +// A centered row with a small em gap, so a slot given several pieces needs no +// direction or gap of its own. +.openui-inline-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375em; + min-width: 0; + max-width: 100%; +} diff --git a/packages/react-ui/src/components/InlineHeader/inlineHeader.scss b/packages/react-ui/src/components/InlineHeader/inlineHeader.scss index 3c58755b61..ea14cf6738 100644 --- a/packages/react-ui/src/components/InlineHeader/inlineHeader.scss +++ b/packages/react-ui/src/components/InlineHeader/inlineHeader.scss @@ -12,6 +12,9 @@ gap: cssUtils.$space-xs; color: cssUtils.$text-neutral-primary; @include cssUtils.typography(body, heavy); + // Headings are Bold; Medium is for links and actions. + font-weight: cssUtils.$font-weight-bold; + font-variation-settings: cssUtils.$font-variation-bold; } &-description { diff --git a/packages/react-ui/src/components/InlineMarkdownRenderer/InlineMarkdownRenderer.tsx b/packages/react-ui/src/components/InlineMarkdownRenderer/InlineMarkdownRenderer.tsx index e13b89e8b1..1a21ab71e0 100644 --- a/packages/react-ui/src/components/InlineMarkdownRenderer/InlineMarkdownRenderer.tsx +++ b/packages/react-ui/src/components/InlineMarkdownRenderer/InlineMarkdownRenderer.tsx @@ -3,6 +3,7 @@ import { memo, ReactNode } from "react"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkCitations } from "../_shared/remark/remarkCitations"; +import { remarkHighlight } from "../_shared/remark/remarkHighlight"; import { TextContentCitation } from "../Citation"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { useTheme } from "../ThemeProvider"; @@ -40,6 +41,7 @@ export const InlineMarkdownRenderer = memo( remarkCitations, [remarkBreaks, { breaks: false }], [remarkGfm, { singleTilde: false }], + remarkHighlight, ], components: { p: Fragment, diff --git a/packages/react-ui/src/components/Input/_genuiField.scss b/packages/react-ui/src/components/Input/_genuiField.scss new file mode 100644 index 0000000000..42d379aa6e --- /dev/null +++ b/packages/react-ui/src/components/Input/_genuiField.scss @@ -0,0 +1,117 @@ +@use "../../cssUtils" as cssUtils; + +// ─── Agent (genui) fields ─── +// One look for every text-like field the agent renders (Input, TextArea, +// Select, DatePicker): 14px text on a white surface with 8px corners. +// Scoped to genui classes so app chrome that uses the base components keeps +// its own sizes. The surface can be the control itself or a wrapper around +// it (Input with its leading icon), so states key off :focus-within and :has. + +$height: 44px; +$inline-height: 32px; +// Corner radius for every field, single- or multi-line. +$radius: cssUtils.$radius-m; + +@mixin surface { + box-sizing: border-box; + @include cssUtils.typography(body, small); + color: cssUtils.$text-neutral-primary; + background-color: cssUtils.$foreground; + border: 1px solid cssUtils.$border-interactive; + box-shadow: none; + transition: + box-shadow 0.16s ease, + border-color 0.16s ease, + background-color 0.16s ease; + + &:hover:not(:has(:disabled)):not([data-disabled]) { + border-color: cssUtils.$border-interactive-emphasis; + } + + &:focus-within, + &[data-state="open"] { + outline: none; + border-color: cssUtils.$border-interactive-emphasis; + box-shadow: 0 0 0 3px cssUtils.$sunk; + } + + &:has(:disabled), + &:disabled, + &[data-disabled] { + opacity: 0.5; + cursor: not-allowed; + } +} + +@mixin error { + border-color: cssUtils.$border-danger-emphasis; + box-shadow: 0 0 0 3px cssUtils.$border-danger; +} + +// Single-line fields. +@mixin pill { + @include surface; + height: $height; + padding: 0 1.143em; + border-radius: $radius; +} + +// Multi-line fields. +@mixin block { + @include surface; + padding: 0.857em 1.143em; + border-radius: $radius; +} + +// Dense layouts (table cells, inline rows): no chrome until hovered or focused. +@mixin inline { + height: $inline-height; + padding: 0 0.571em; + border-radius: cssUtils.$radius-s; + background-color: transparent; + border-color: transparent; + box-shadow: none; + + &:hover:not(:has(:disabled)):not([data-disabled]) { + background-color: cssUtils.$sunk; + border-color: transparent; + } + + &:focus-within, + &[data-state="open"] { + background-color: cssUtils.$foreground; + border-color: cssUtils.$border-interactive-emphasis; + box-shadow: none; + } +} + +// The bare control inside a wrapper surface. +@mixin control { + flex: 1; + min-width: 0; + height: 100%; + margin: 0; + padding: 0; + border: none; + outline: none; + background: transparent; + font: inherit; + letter-spacing: inherit; + font-variation-settings: inherit; + color: inherit; + + &::placeholder { + color: cssUtils.$text-neutral-secondary; + } + + &:disabled { + cursor: not-allowed; + } +} + +@mixin icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: cssUtils.$text-neutral-secondary; +} diff --git a/packages/react-ui/src/components/Input/input.scss b/packages/react-ui/src/components/Input/input.scss index 4f88abcdd3..98adfa5fc0 100644 --- a/packages/react-ui/src/components/Input/input.scss +++ b/packages/react-ui/src/components/Input/input.scss @@ -1,4 +1,5 @@ @use "../../cssUtils" as cssUtils; +@use "./genuiField" as field; .openui-input { @include cssUtils.typography(body, default); @@ -60,3 +61,31 @@ } } } + +// ─── Agent (genui) input ─── +// A pill wrapper carries the surface so a leading icon can sit beside the +// bare . See ./_genuiField.scss. +.openui-genui-input { + @include field.pill; + display: flex; + align-items: center; + gap: 0.571em; + width: 100%; + + &__icon { + @include field.icon; + } + + &__control { + @include field.control; + } + + &--inline { + @include field.inline; + } + + &--error, + &--error:focus-within { + @include field.error; + } +} diff --git a/packages/react-ui/src/components/Label/label.scss b/packages/react-ui/src/components/Label/label.scss index 27d1174c72..fc5e0e710c 100644 --- a/packages/react-ui/src/components/Label/label.scss +++ b/packages/react-ui/src/components/Label/label.scss @@ -14,3 +14,8 @@ .openui-label-required-asterisk { margin-left: cssUtils.$space-3xs; } + +// Agent (genui) field labels: 14px Medium. +.openui-label.openui-genui-label { + @include cssUtils.typography(label, small-heavy); +} diff --git a/packages/react-ui/src/components/Link/Link.tsx b/packages/react-ui/src/components/Link/Link.tsx new file mode 100644 index 0000000000..a72f83dbf6 --- /dev/null +++ b/packages/react-ui/src/components/Link/Link.tsx @@ -0,0 +1,114 @@ +import clsx from "clsx"; +import { AnchorHTMLAttributes, memo, useEffect, useRef, useState } from "react"; +import { getFaviconUrl } from "../Sources/SourceContext"; +import { useTheme } from "../ThemeProvider"; + +// Per-favicon outcome, so a domain doesn't retry and flicker on every streamed +// re-render: "ok" shows the cached image at once, "miss" goes straight to the globe. +const faviconStatus = new Map(); + +type Status = "loading" | "ok" | "miss"; +const initialStatus = (src: string): Status => + src ? (faviconStatus.get(src) ?? "loading") : "miss"; + +// Inline (not the lazily imported icon set) so the slot is never empty. +const Globe = () => ( + + + + + +); + +/** + * Site favicon sized to the surrounding text. The globe shows while it loads + * and stays if it fails, so the slot is never blank. + */ +export const LinkFavicon = memo(({ href }: { href?: string }) => { + const src = getFaviconUrl(href, 64); + const ref = useRef(null); + const [status, setStatus] = useState(() => initialStatus(src)); + + const settle = (next: "ok" | "miss") => { + if (src) faviconStatus.set(src, next); + setStatus(next); + }; + + // A server-rendered or cached image can finish before React attaches + // onLoad/onError, so settle from the element's own state too. + const check = (img: HTMLImageElement | null) => { + if (!img?.complete) return; + settle(img.naturalWidth > 16 ? "ok" : "miss"); + }; + + useEffect(() => { + setStatus(initialStatus(src)); + check(ref.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src]); + + return ( + + ); +}); + +LinkFavicon.displayName = "LinkFavicon"; + +export const isWebUrl = (href?: string) => !!href && /^https?:\/\//i.test(href); + +export type FaviconLinkProps = AnchorHTMLAttributes; + +/** + * External link with the site's favicon before its text. Non-web hrefs + * (mailto:, #anchors, relative paths) render as a plain link. + */ +export const FaviconLink = ({ href, children, className, ...props }: FaviconLinkProps) => { + const { mode } = useTheme(); + return ( + + {isWebUrl(href) && ( + <> + + {/* Word joiner: keeps the icon on the same line as the first word. */} + {"⁠"} + + )} + {children} + + ); +}; diff --git a/packages/react-ui/src/components/Link/index.ts b/packages/react-ui/src/components/Link/index.ts new file mode 100644 index 0000000000..8582665813 --- /dev/null +++ b/packages/react-ui/src/components/Link/index.ts @@ -0,0 +1,2 @@ +export { FaviconLink, LinkFavicon, isWebUrl } from "./Link"; +export type { FaviconLinkProps } from "./Link"; diff --git a/packages/react-ui/src/components/Link/link.scss b/packages/react-ui/src/components/Link/link.scss new file mode 100644 index 0000000000..2cd9120016 --- /dev/null +++ b/packages/react-ui/src/components/Link/link.scss @@ -0,0 +1,49 @@ +@use "../../cssUtils" as cssUtils; + +// Favicon before a link's text, sized in em so it tracks the font size. +// Still within the line box, so it never changes the line height. +.openui-link-favicon { + position: relative; + display: inline-flex; + width: 1em; + height: 1em; + margin-inline-end: 0.3em; + vertical-align: -0.15em; + border-radius: 3px; + overflow: hidden; + color: cssUtils.$text-neutral-secondary; + + img, + svg { + width: 100%; + height: 100%; + object-fit: contain; + } + + // The image loads on top of the globe and only appears once it has loaded. + img { + position: absolute; + inset: 0; + opacity: 0; + } +} + +.openui-link-favicon--loaded img { + position: static; + opacity: 1; +} + +// Many favicons are dark glyphs on transparent; give them a light backing in +// dark mode rather than filtering (which would distort brand colors). +.openui-favicon-link--dark .openui-link-favicon--loaded { + background-color: #fff; + padding: 1px; +} + +// A link keeps focus after it is clicked, and any later keypress (even a +// screenshot shortcut) makes it :focus-visible. Keep that ring light. +a.openui-favicon-link:focus-visible { + outline: 1.5px solid color-mix(in oklab, currentColor 35%, transparent); + outline-offset: 2px; + border-radius: 3px; +} diff --git a/packages/react-ui/src/components/ListBlock/ListBlock.tsx b/packages/react-ui/src/components/ListBlock/ListBlock.tsx index 629998d741..e0a55351eb 100644 --- a/packages/react-ui/src/components/ListBlock/ListBlock.tsx +++ b/packages/react-ui/src/components/ListBlock/ListBlock.tsx @@ -7,20 +7,18 @@ import { isValidElement, ReactElement, } from "react"; -import { ListItemProps, ListItemSize, ListItemVariant } from "../ListItem"; +import { ListItemProps, ListItemVariant } from "../ListItem"; export interface ListBlockProps { - /** Controls the indicator shown on every ListItem. Defaults to "number". */ + /** "number" puts an accent number before every title; "plain" shows none. Defaults to "number". */ variant?: ListItemVariant; - /** "small" tightens spacing and typography (used inside cards). Defaults to "default". */ - size?: ListItemSize; children: ReactElement | ReactElement[]; className?: string; style?: CSSProperties; } const ListBlock = forwardRef((props, ref) => { - const { children, variant = "number", size = "default", className, style } = props; + const { children, variant = "number", className, style } = props; const childArray = Children.toArray(children); const listHasSubtitle = childArray.some( @@ -31,7 +29,6 @@ const ListBlock = forwardRef((props, ref) => { if (isValidElement(child)) { return cloneElement(child as ReactElement, { variant, - size, listHasSubtitle, index, }); @@ -40,15 +37,7 @@ const ListBlock = forwardRef((props, ref) => { }); return ( -
+
{enhancedChildren}
); diff --git a/packages/react-ui/src/components/ListBlock/listBlock.scss b/packages/react-ui/src/components/ListBlock/listBlock.scss index 202a91b16b..6731aa590d 100644 --- a/packages/react-ui/src/components/ListBlock/listBlock.scss +++ b/packages/react-ui/src/components/ListBlock/listBlock.scss @@ -8,7 +8,3 @@ width: 100%; gap: cssUtils.$space-l; } - -.openui-list-block--small { - gap: cssUtils.$space-m; -} diff --git a/packages/react-ui/src/components/ListBlock/stories/ListBlock.stories.tsx b/packages/react-ui/src/components/ListBlock/stories/ListBlock.stories.tsx index 8548f498c9..d925794fba 100644 --- a/packages/react-ui/src/components/ListBlock/stories/ListBlock.stories.tsx +++ b/packages/react-ui/src/components/ListBlock/stories/ListBlock.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChevronRight, User } from "lucide-react"; +import { Avatar } from "../../Avatar"; import { ListItem } from "../../ListItem"; import { ListBlock } from "../ListBlock"; @@ -27,7 +28,7 @@ const meta: Meta = { argTypes: { variant: { control: "select", - options: ["number", "icon", "image"], + options: ["number", "plain"], description: "Controls the indicator type for every ListItem in the list.", table: { category: "Appearance", defaultValue: { summary: "number" } }, }, @@ -94,29 +95,41 @@ export const NumberClickable: Story = { export const IconWithSubtitle: Story = { render: () => ( - - } title="John Doe" subtitle="Software Engineer" /> - } title="Jane Smith" subtitle="Product Designer" /> - } title="Ankit Das" subtitle="Software Developer" /> + + } />} + title="John Doe" + subtitle="Software Engineer" + /> + } />} + title="Jane Smith" + subtitle="Product Designer" + /> + } />} + title="Ankit Das" + subtitle="Software Developer" + /> ), }; export const IconNoSubtitle: Story = { render: () => ( - - } title="John Doe" /> - } title="Jane Smith" /> - } title="Ankit Das" /> + + } />} title="John Doe" /> + } />} title="Jane Smith" /> + } />} title="Ankit Das" /> ), }; export const IconClickable: Story = { render: () => ( - + } + leading={} />} title="John Doe" subtitle="Software Engineer" actionLabel="View Profile" @@ -124,7 +137,7 @@ export const IconClickable: Story = { onClick={() => alert("John Doe")} /> } + leading={} />} title="Jane Smith" subtitle="Product Designer" actionLabel="View Profile" @@ -132,7 +145,7 @@ export const IconClickable: Story = { onClick={() => alert("Jane Smith")} /> } + leading={} />} title="Ankit Das" subtitle="Software Developer" actionLabel="View Profile" @@ -145,19 +158,19 @@ export const IconClickable: Story = { export const ImageWithSubtitle: Story = { render: () => ( - + } />} title="John Doe" subtitle="Software Engineer" /> } />} title="Jane Smith" subtitle="Product Designer" /> } />} title="Ankit Das" subtitle="Software Developer" /> @@ -167,33 +180,42 @@ export const ImageWithSubtitle: Story = { export const ImageNoSubtitle: Story = { render: () => ( - - - - + + } />} + title="John Doe" + /> + } />} + title="Jane Smith" + /> + } />} + title="Ankit Das" + /> ), }; export const ImageClickable: Story = { render: () => ( - + } />} title="John Doe" subtitle="Software Engineer" actionIcon={} onClick={() => alert("John Doe")} /> } />} title="Jane Smith" subtitle="Product Designer" actionIcon={} onClick={() => alert("Jane Smith")} /> } />} title="Ankit Das" subtitle="Software Developer" actionIcon={} diff --git a/packages/react-ui/src/components/ListItem/ListItem.tsx b/packages/react-ui/src/components/ListItem/ListItem.tsx index 41d96db185..cfc2e96e9a 100644 --- a/packages/react-ui/src/components/ListItem/ListItem.tsx +++ b/packages/react-ui/src/components/ListItem/ListItem.tsx @@ -1,22 +1,25 @@ import clsx from "clsx"; import React, { CSSProperties, ReactNode } from "react"; -export type ListItemVariant = "icon" | "image" | "number"; -export type ListItemSize = "default" | "small"; +/** number shows a small accent number inline with the title; plain shows none. */ +export type ListItemVariant = "number" | "plain"; export interface ListItemProps { className?: string; style?: CSSProperties; + /** A string, or composed content such as Text, a Link or a Tag. */ title?: ReactNode; + /** A string, or composed content such as a Metric, Tag or LeadingText. */ subtitle?: ReactNode; variant?: ListItemVariant; - size?: ListItemSize; - icon?: ReactNode; - image?: { src?: string; alt: string }; + /** A visual before the text, usually an Avatar (photo, logo or icon badge). */ + leading?: ReactNode; index?: number; listHasSubtitle?: boolean; actionLabel?: ReactNode; actionIcon?: ReactNode; + /** Controls on the right (a Button, Buttons or IconButton), in place of the chevron. */ + actionSlot?: ReactNode; onClick?: () => void; } @@ -25,27 +28,26 @@ const ListItem = React.forwardRef((props, ref) => className, style, variant = "number", - size = "default", - icon, - image, + leading, index = 0, listHasSubtitle, title, subtitle, actionIcon, actionLabel, + actionSlot, onClick, ...rest } = props; const hasAction = !!onClick; + const composed = (value: ReactNode) => value != null && typeof value !== "string"; return (
((props, ref) => role={hasAction ? "button" : undefined} tabIndex={hasAction ? 0 : undefined} > -
- {variant === "number" && ( -
{index + 1}
- )} - {variant === "icon" && icon} - {variant === "image" && image && (image.src || image.alt) && ( -
- {image.alt} -
- )} -
+ {leading && ( +
+ {leading} +
+ )} + {variant === "number" && {index + 1}}
- {title &&
{title}
} - {subtitle &&
{subtitle}
} + {title && ( +
+ {title} +
+ )} + {subtitle && ( +
+ {subtitle} +
+ )}
- {hasAction && (actionIcon || actionLabel) && ( + {actionSlot &&
{actionSlot}
} + {!actionSlot && hasAction && (actionIcon || actionLabel) && (
{actionLabel &&
{actionLabel}
} {actionIcon &&
{actionIcon}
} diff --git a/packages/react-ui/src/components/ListItem/listItem.scss b/packages/react-ui/src/components/ListItem/listItem.scss index 35fcc3b6b0..2f49de8cce 100644 --- a/packages/react-ui/src/components/ListItem/listItem.scss +++ b/packages/react-ui/src/components/ListItem/listItem.scss @@ -5,22 +5,6 @@ flex-direction: column; gap: cssUtils.$space-m; width: 100%; - - &-with-action { - border-bottom: 1px solid cssUtils.$border-default; - padding-bottom: cssUtils.$space-m; - - &:hover { - .openui-list-item-indicator { - scale: 1.1; - } - } - - &:last-child { - border-bottom: none; - padding-bottom: 0; - } - } } .openui-list-item { @@ -37,54 +21,46 @@ align-items: center; } - &-indicator { - margin-top: cssUtils.$space-2xs; - display: flex; + // The number sits 8px from the title, closer than a leading visual. + &:has(> .openui-list-item-number) { + gap: 8px; + } + &:not(:has(.openui-list-item-subtitle)) > .openui-list-item-number { + margin-top: 0; + } + + // Leading visual (an Avatar, or an Icon shown as a neutral badge): 40px + // beside a title and subtitle, 24px beside a title alone. + &-leading { + --openui-list-item-leading: 40px; + display: inline-flex; + flex: none; align-items: center; justify-content: center; - width: 40px; - height: 40px; - background-color: cssUtils.$sunk; - border-radius: cssUtils.$radius-m; - color: cssUtils.$text-neutral-secondary; - overflow: hidden; - transition: background-color 0.2s ease; - flex-shrink: 0; + width: var(--openui-list-item-leading); + height: var(--openui-list-item-leading); + margin-top: cssUtils.$space-2xs; &-no-subtitle { - height: 24px; - width: 24px; + --openui-list-item-leading: 24px; margin-top: 0; - border-radius: cssUtils.$radius-s; - } - - &-clickable { - background-color: cssUtils.$sunk; } - // Inner wrapper for number variant — applies label typography to the number text - &-number { - color: cssUtils.$text-neutral-secondary; - @include cssUtils.typography(label, default); - } - - // Inner wrapper for image variant — clips image to a rounded square that fills the indicator - &-image { - border-radius: cssUtils.$radius-s; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; + > .openui-avatar { width: 100%; height: 100%; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; + vertical-align: 0; + } + > .openui-avatar .openui-avatar__icon { + width: 50%; + height: 50%; + svg { + stroke-width: 1.75px; } } + > .openui-emoji { + font-size: calc(var(--openui-list-item-leading) * 0.7); + } } &-content-wrapper { @@ -92,25 +68,60 @@ flex-direction: row; align-items: flex-start; justify-content: space-between; + gap: cssUtils.$space-m; width: 100%; + min-width: 0; } &-content { display: flex; flex-direction: column; flex: 1; + min-width: 0; } &-title { + min-width: 0; color: cssUtils.$text-neutral-primary; @include cssUtils.typography(body, default); } + // The number is a small black accent chip (inverted in dark mode), + // centered on the title's first 24px line, with the subtitle aligned + // under the title text. + &-number { + display: inline-flex; + flex: none; + margin-top: 3px; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + box-sizing: border-box; + border-radius: 999px; + background-color: cssUtils.$text-neutral-primary; + color: cssUtils.$background; + @include cssUtils.typography(label, extra-small-heavy); + line-height: 1; + font-variant-numeric: tabular-nums; + } + &-subtitle { color: cssUtils.$text-neutral-secondary; @include cssUtils.typography(body, default); } + // Composed title or subtitle content sits on one line, 6px apart. + &-title--composed, + &-subtitle--composed { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 24px; + } + &-action { display: flex; flex-direction: row; @@ -120,6 +131,19 @@ flex-shrink: 0; } + &-action-slot { + display: flex; + align-items: center; + flex-shrink: 0; + align-self: center; + + // A Buttons group sizes to its buttons here instead of the full row. + .openui-buttons { + width: auto; + flex-wrap: nowrap; + } + } + &-action-label { color: cssUtils.$text-neutral-secondary; @include cssUtils.typography(body, default); @@ -132,10 +156,6 @@ &:hover, &:focus { - .openui-list-item-indicator-clickable { - background-color: cssUtils.$highlight-strong; - } - .openui-list-item-action-label { color: cssUtils.$text-neutral-primary; } @@ -145,33 +165,3 @@ } } } - -// Compact variant (ListBlock size="small") — used inside card blocks. -.openui-list-item-wrapper--small { - gap: cssUtils.$space-s; - - .openui-list-item { - gap: cssUtils.$space-s-m; - } - - .openui-list-item-indicator { - width: 36px; - height: 36px; - - &-no-subtitle { - width: 20px; - height: 20px; - } - - &-number { - @include cssUtils.typography(number, small); - } - } - - .openui-list-item-title, - .openui-list-item-subtitle, - .openui-list-item-action-label, - .openui-list-item-action-icon { - @include cssUtils.typography(body, small); - } -} diff --git a/packages/react-ui/src/components/ListItem/stories/ListItem.stories.tsx b/packages/react-ui/src/components/ListItem/stories/ListItem.stories.tsx index bd661bae37..431338e3cd 100644 --- a/packages/react-ui/src/components/ListItem/stories/ListItem.stories.tsx +++ b/packages/react-ui/src/components/ListItem/stories/ListItem.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ChevronRight, User } from "lucide-react"; +import { Avatar } from "../../Avatar"; import { ListItem } from "../ListItem"; const meta: Meta = { @@ -24,13 +25,12 @@ const meta: Meta = { argTypes: { variant: { control: "select", - options: ["number", "icon", "image"], + options: ["number", "plain"], table: { category: "Appearance", defaultValue: { summary: "number" } }, }, title: { control: "text", table: { category: "Content" } }, subtitle: { control: "text", table: { category: "Content" } }, - icon: { control: false, table: { category: "Content" } }, - image: { control: false, table: { category: "Content" } }, + leading: { control: false, table: { category: "Content" } }, index: { control: "number", table: { category: "Behavior" } }, listHasSubtitle: { control: "boolean", table: { category: "Behavior" } }, actionLabel: { control: "text", table: { category: "Content" } }, @@ -67,8 +67,8 @@ export const NumberClickable: Story = { export const Icon: Story = { args: { - variant: "icon", - icon: , + variant: "plain", + leading: } />, listHasSubtitle: true, title: "Icon List Item", subtitle: "The indicator shows an icon", @@ -77,8 +77,8 @@ export const Icon: Story = { export const IconClickable: Story = { args: { - variant: "icon", - icon: , + variant: "plain", + leading: } />, listHasSubtitle: true, title: "Clickable Icon Item", subtitle: "Click me to trigger an action", @@ -90,8 +90,8 @@ export const IconClickable: Story = { export const Image: Story = { args: { - variant: "image", - image: { src: "https://i.pravatar.cc/40", alt: "Avatar" }, + variant: "plain", + leading: } />, listHasSubtitle: true, title: "Image List Item", subtitle: "The indicator shows a thumbnail", @@ -100,8 +100,8 @@ export const Image: Story = { export const ImageClickable: Story = { args: { - variant: "image", - image: { src: "https://i.pravatar.cc/40", alt: "Avatar" }, + variant: "plain", + leading: } />, listHasSubtitle: true, title: "Clickable Image Item", subtitle: "Click me to trigger an action", diff --git a/packages/react-ui/src/components/MarkDownRenderer/MarkDownRenderer.tsx b/packages/react-ui/src/components/MarkDownRenderer/MarkDownRenderer.tsx index f913bf4ff9..adfb02905d 100644 --- a/packages/react-ui/src/components/MarkDownRenderer/MarkDownRenderer.tsx +++ b/packages/react-ui/src/components/MarkDownRenderer/MarkDownRenderer.tsx @@ -93,6 +93,7 @@ export const MarkDownRenderer = memo((props: MarkDownRendererProps) => { className={clsx( props["variant"] && variantStyles[props["variant"] as keyof typeof variantStyles], "openui-markdown-renderer", + mode === "dark" && "openui-markdown-renderer--dark", props.className, )} > diff --git a/packages/react-ui/src/components/MarkDownRenderer/markDownRenderer.scss b/packages/react-ui/src/components/MarkDownRenderer/markDownRenderer.scss index 21fd870f1c..796949227a 100644 --- a/packages/react-ui/src/components/MarkDownRenderer/markDownRenderer.scss +++ b/packages/react-ui/src/components/MarkDownRenderer/markDownRenderer.scss @@ -1,22 +1,15 @@ @use "../../cssUtils" as cssUtils; .openui-markdown-renderer-code { - border-radius: 4px; - padding: 2px 4px; + border-radius: 6px; + // ~20px pill inside the 24px body line; inline padding doesn't change line height. + padding: 3px 5px; + // line-height 1 keeps the smaller mono line from growing the 24px line box. + line-height: 1; background-color: cssUtils.$sunk; - border: 1px solid cssUtils.$border-default; color: cssUtils.$text-neutral-primary; - @include cssUtils.typography(body, link); -} - -.openui-markdown-renderer-code::after { - content: ""; - display: hidden; -} - -.openui-markdown-renderer-code::before { - content: ""; - display: hidden; + font-family: cssUtils.$font-code; + font-size: 12px; } /* Card Styles */ @@ -33,170 +26,271 @@ } /* Link Styles */ +// typeset: links keep the text color, set in Medium with a faint underline +// that darkens on hover. .openui-markdown-renderer-link { - color: cssUtils.$text-info-primary; + color: inherit; + font-weight: cssUtils.$font-weight-medium; + font-variation-settings: cssUtils.$font-variation-medium; cursor: pointer; - text-decoration: none; - transition: color 0.2s ease-in-out; - @include cssUtils.typography(body, link); + text-decoration-line: underline; + text-decoration-color: color-mix(in oklab, currentColor 18%, transparent); + text-underline-offset: 0.2em; + transition: text-decoration-color 0.2s ease-in-out; &:hover { - text-decoration: underline; + text-decoration-color: currentColor; + } + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + border-radius: 0.125em; + } + :is(h1, h2, h3, h4, h5, h6) & { + font-weight: inherit; + font-variation-settings: inherit; } } /* generic tag styles */ +// Prose rhythm adapted from shadcn/typeset: blocks only take space ABOVE them +// (append-safe while streaming), spacing is in em so it scales with the body +// size, and headings own the space below them. Headings use typeset's em scale +// (h1 1.75em / h2 1.25em / h3 1.125em) in Medium with -1.2% tracking. +$md-flow: 1.25em; +$md-heading-tracking: -0.012em; +// Embedded components opt out with .not-markdown or data-not-markdown. +$md-skip: ".not-markdown, [data-not-markdown], .not-markdown *, [data-not-markdown] *"; + +// Element selector scoped to markdown content. Keeps element specificity +// (0,1,1) so host page styles like `main h1` don't win. +@mixin md($selector) { + #{$selector} { + &:not(:where(#{$md-skip})) { + @content; + } + } +} + +// The font shorthand resets font-variation-settings, so every rule that sets +// `font` sets the "wght" axis right after it. Text passes its weight in +// through --openui-text-variation. +$md-body-variation: var(--openui-text-variation, #{cssUtils.$font-variation-regular}); + +// Headings are Bold ("wght" 640, font-weight 600 fallback); Medium is kept +// for links and actions. +@mixin md-heading($size, $line-height) { + font: cssUtils.$font-weight-bold #{$size}/#{$line-height} cssUtils.$font-heading; + font-variation-settings: cssUtils.$font-variation-bold; + letter-spacing: $md-heading-tracking; + color: cssUtils.$text-neutral-primary; +} + .openui-markdown-renderer { color: cssUtils.$text-neutral-primary; @include cssUtils.typography(body, default); + font-variation-settings: $md-body-variation; max-width: none; + overflow-wrap: break-word; - /* Heading Styles */ - & h1, - & h2, - & h3, - & h4, - & h5, - & h6 { - @include cssUtils.typography(heading, extra-small); - color: cssUtils.$text-neutral-primary; - margin-bottom: 18px; - &:has(+ p) { - margin-bottom: 8px; - } - + strong { - @include cssUtils.typography(heading, extra-small); - } + @include md("p, ul, ol, blockquote, :where(.openui-table-container), details, figure, img") { + margin-block: $md-flow 0; } - /* Paragraph Styles */ - & p { - color: cssUtils.$text-neutral-primary; + @include md("p") { @include cssUtils.typography(body, default); - margin-bottom: 0; - &:has(+ p) { - margin-bottom: 18px; - } - } - - & strong { + font-variation-settings: $md-body-variation; color: cssUtils.$text-neutral-primary; - @include cssUtils.typography(body, heavy); } - & li { - margin-bottom: 8px; - @include cssUtils.typography(body, default); - :last-child { - margin-bottom: 0; - } - &::marker { - @include cssUtils.typography(body, default); - color: cssUtils.$text-neutral-secondary; - } + // Headings: more space above than the flow, and they own the space below. + @include md("h1") { + @include md-heading(1.75em, 1.3); + margin-block: $md-flow 0; + } + @include md("h2") { + @include md-heading(1.25em, 1.4); + margin-block: calc(#{$md-flow} * 1.4) 0; + } + @include md("h3") { + @include md-heading(1.125em, 1.45); + margin-block: $md-flow 0; + } + @include md("h4") { + @include md-heading(1em, 1.5); + margin-block: $md-flow 0; } - & li ul { - margin-top: 8px; + @include md("h5") { + @include md-heading(0.875em, 1.5); + color: cssUtils.$text-neutral-secondary; + margin-block: calc(#{$md-flow} / 0.875) 0; + } + @include md("h6") { + @include md-heading(0.8125em, 1.5); + letter-spacing: 0.08em; + text-transform: uppercase; + color: cssUtils.$text-neutral-secondary; + margin-block: calc(#{$md-flow} / 0.8125) 0; + } + @include md("h1 + *, h2 + *, h3 + *, h4 + *, h5 + *, h6 + *") { + margin-block-start: 1em; + } + @include md(":is(h1, h2, h3, h4) code") { + font-size: 0.9em; } - & li li { - margin-top: 8px; + @include md("strong, b") { + font-weight: cssUtils.$font-weight-medium; + font-variation-settings: cssUtils.$font-variation-medium; + color: cssUtils.$text-neutral-primary; } - & li ol { - margin-top: 8px; + @include md(":is(h1, h2, h3, h4, h5, h6) :is(strong, b)") { + font-weight: inherit; + font-variation-settings: inherit; } - & ol { - list-style-type: decimal; - padding-left: 20px; - margin-bottom: 20px; - @include cssUtils.typography(body, default); + + // ==key phrase== highlight: soft green pill behind the text, text stays + // primary. clone keeps the rounded ends on every line when it wraps. + // The success green is re-alphaed: 20% reads on white, dark needs 30% to + // stay visible against the dark surface. + @include md("mark") { + background-color: cssUtils.$success-background; + background-color: oklch(from cssUtils.$success-background l c h / 0.2); color: cssUtils.$text-neutral-primary; - :last-child { - margin-bottom: 0; + border-radius: 4px; + padding: 0.05em 0.2em; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + @media (forced-colors: active) { + background-color: Mark; + color: MarkText; } } + &.openui-markdown-renderer--dark mark:not(:where(#{$md-skip})) { + background-color: oklch(from cssUtils.$success-background l c h / 0.3); + } - & ul { - list-style-type: disc; - padding-left: 20px; - margin-bottom: 24px; + // Lists: tighter rhythm inside, markers in secondary. + @include md("ul, ol") { @include cssUtils.typography(body, default); + font-variation-settings: $md-body-variation; color: cssUtils.$text-neutral-primary; - :last-child { - margin-bottom: 0; - } + padding-inline-start: 1.5em; + } + @include md("ul") { + list-style-type: disc; + } + @include md("ul ul") { + list-style-type: circle; + } + @include md("ul ul ul") { + list-style-type: square; + } + @include md("ol") { + list-style-type: decimal; + } + @include md("li") { + margin-block-start: 0.5em; + padding-inline-start: 0.4em; + } + & li:not(:where(#{$md-skip}))::marker { + color: cssUtils.$text-neutral-secondary; + } + @include md("li > p, li > ul, li > ol, li > blockquote, li > :where(.openui-table-container)") { + margin-block-start: 0.5em; } - & .openui-code-block-syntax-highlighter { - padding: 25px !important; - margin-top: 8px !important; - margin-bottom: 8px !important; + // GFM task lists keep the list indent: the checkmark hangs in the marker + // gutter so task text lines up with bullet and numbered item text. + @include md("ul.contains-task-list") { + list-style-type: none; + } + & .task-list-item { + position: relative; + // Round 16px checkmark in place of the native square box. + > input[type="checkbox"] { + appearance: none; + position: absolute; + top: calc((1lh - 16px) / 2); + left: calc(-16px - 0.15em); + width: 16px; + height: 16px; + margin: 0; + border: 1px solid cssUtils.$border-interactive; + border-radius: 50%; + background-color: transparent; - &:first-child { - margin-top: 0 !important; - } + &:checked { + // Primary fill with a background-colored check: black on light, + // inverted to white in dark mode. + border-color: cssUtils.$text-neutral-primary; + background-color: cssUtils.$text-neutral-primary; - &:last-child { - margin-bottom: 0 !important; + &::after { + content: ""; + position: absolute; + inset: 0; + background-color: cssUtils.$background; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Cpath d='M4 7.2l2 2L10 5' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") + center / contain no-repeat; + } + } } } - & hr { - margin-top: 48px; - margin-bottom: 48px; - + * { - margin-top: 0; - } + @include md("del, s") { + color: cssUtils.$text-neutral-secondary; } - /* Blockquote Styles */ - & blockquote { - color: cssUtils.$text-neutral-primary; - border-left: 4px solid color-mix(in oklab, cssUtils.$text-neutral-primary 70%, transparent); - padding: 0px 0 0px 8px; - margin-left: 1rem; - font-style: italic; - margin-left: 0; - margin-top: 25px; - margin-bottom: 25px; - padding-left: 16px; - quotes: "\201C" "\201D" "\2018" "\2019"; - line-height: 1.625; + // Fenced code renders as pre > CodeBlock; the pre carries the flow margin. + @include md("pre") { + font: inherit; + font-variation-settings: inherit; + margin-block: $md-flow 0; } - & blockquote strong { - @include cssUtils.typography(body, heavy); - } + // Code block padding, radius and font come from the CodeBlock component. - & blockquote > *:first-child::before { - content: open-quote; - font-size: 1em; - line-height: 1; - vertical-align: top; - margin-right: 0.2em; + @include md("hr") { + border: 0; + border-block-start: 1px solid cssUtils.$border-default; + margin-block: calc(#{$md-flow} * 2.4) 0; } - - & blockquote > *:last-child::after { - content: close-quote; - font-size: 1em; - line-height: 1.5; - vertical-align: top; - margin-left: 0.2em; + @include md("hr + h1, hr + h2, hr + h3, hr + h4") { + margin-block-start: $md-flow; } - & blockquote * { - font-style: inherit; - color: inherit; - line-height: inherit; + // typeset: a plain rule on the left, text stays in the body color. + @include md("blockquote") { + border-inline-start: 2px solid cssUtils.$border-default; + margin-inline: 0; + padding-inline-start: 1em; } - & .openui-table-container { - margin-bottom: 20px; + // Tables use the Table component's own styles. Only header alignment is + // adjusted: th centers by default, so start-align unless GFM sets an + // inline alignment, and let the label flow as text so that applies. + & .openui-table-head { + text-align: start; + .openui-table-head-content { + display: block; + } + .openui-table-head-label { + display: inline; + } } - > *:first-child { - margin-top: 0; + @include md("img") { + max-width: 100%; + height: auto; + border-radius: 12px; + } + @include md("p img") { + margin-block-start: 0; } - > *:last-child { - margin-bottom: 0; + // First child adds no space above; last so it wins ties. + & > :where(:first-child), + & > :where(:first-child) > :where(:first-child), + & :where(li, blockquote, td, th, figure) > :where(:first-child) { + margin-block-start: 0 !important; } } diff --git a/packages/react-ui/src/components/Metric/Metric.tsx b/packages/react-ui/src/components/Metric/Metric.tsx new file mode 100644 index 0000000000..983b0e5574 --- /dev/null +++ b/packages/react-ui/src/components/Metric/Metric.tsx @@ -0,0 +1,72 @@ +import clsx from "clsx"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { forwardRef } from "react"; + +export type MetricVariant = "horizontal" | "vertical"; +export type MetricSize = "sm" | "default" | "large"; +export type MetricDeltaTone = "positive" | "negative" | "neutral"; + +export interface MetricProps { + value: string; + delta?: string; + /** An earlier value shown struck through after the current one (was → now). */ + previous?: string; + variant?: MetricVariant; + size?: MetricSize; + className?: string; +} + +/** A leading "+" reads as positive and a leading "-" (or "−") as negative. */ +export function getDeltaTone(delta?: string): MetricDeltaTone { + const first = delta?.trim()[0]; + if (first === "+") return "positive"; + if (first === "-" || first === "−") return "negative"; + return "neutral"; +} + +/** Up or down arrow for a +/- change, with the sign dropped in favor of it. */ +const MetricDelta = ({ delta }: { delta: string }) => { + const tone = getDeltaTone(delta); + const Arrow = tone === "positive" ? ArrowUp : tone === "negative" ? ArrowDown : null; + const text = Arrow ? delta.trim().slice(1).trim() : delta; + return ( + + {Arrow && } + {Arrow && {tone === "positive" ? "up " : "down "}} + {text} + + ); +}; + +/** + * A standalone value with an optional change. Labels are composed around it + * (Text, Heading, card titles). Horizontal puts the change beside the value; + * vertical puts it underneath. + */ +export const Metric = forwardRef( + ({ value, delta, previous, variant, size, className }, ref) => ( +
+ + {value} + {previous && ( + + was + {previous} + + )} + + {delta && } +
+ ), +); + +Metric.displayName = "Metric"; diff --git a/packages/react-ui/src/components/Metric/index.ts b/packages/react-ui/src/components/Metric/index.ts new file mode 100644 index 0000000000..6cc526ae57 --- /dev/null +++ b/packages/react-ui/src/components/Metric/index.ts @@ -0,0 +1,2 @@ +export { Metric, getDeltaTone } from "./Metric"; +export type { MetricDeltaTone, MetricProps, MetricSize, MetricVariant } from "./Metric"; diff --git a/packages/react-ui/src/components/Metric/metric.scss b/packages/react-ui/src/components/Metric/metric.scss new file mode 100644 index 0000000000..6c4eed8734 --- /dev/null +++ b/packages/react-ui/src/components/Metric/metric.scss @@ -0,0 +1,109 @@ +@use "../../cssUtils" as cssUtils; + +.openui-metric { + display: flex; + min-width: 0; + color: cssUtils.$text-neutral-primary; + + &__main { + display: inline-flex; + align-items: baseline; + gap: 0.4em; + min-width: 0; + } + + // A previous value, struck through after the current one (old → new price). + &__previous { + font-variant-numeric: tabular-nums; + color: cssUtils.$text-neutral-secondary; + text-decoration-line: line-through; + } + + &__value { + font-variant-numeric: tabular-nums; + color: cssUtils.$text-neutral-primary; + // A value like "182 ms" never breaks across lines. + white-space: nowrap; + } + + // Inline (not flex) so the change shares the value's text baseline; the + // arrow sits on that baseline like a glyph. + &__delta { + display: inline; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: cssUtils.$text-neutral-secondary; + &--positive { + color: cssUtils.$text-success-primary; + } + &--negative { + color: cssUtils.$text-danger-primary; + } + } + + &__delta-arrow { + display: inline-block; + width: 0.9em; + height: 0.9em; + margin-right: 0.1em; + vertical-align: -0.1em; + } + + &__sr { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + // Horizontal: change beside the value. + &--horizontal { + flex-direction: row; + align-items: baseline; + gap: cssUtils.$space-xs; + } + + // Vertical: change directly under the value, no gap. + &--vertical { + flex-direction: column; + align-items: flex-start; + gap: 0; + } + + // Values are Medium; headings are Bold. sm is 14px, default 16px, and + // large steps up the type scale to 18px. The font shorthand resets + // font-variant-numeric, so tabular figures are set after it. + &--size-sm { + .openui-metric__value { + @include cssUtils.typography(body, small-heavy); + } + .openui-metric__delta, + .openui-metric__previous { + @include cssUtils.typography(body, small); + font-size: cssUtils.$font-size-xs; + } + } + &--size-default { + .openui-metric__value { + @include cssUtils.typography(body, heavy); + } + .openui-metric__delta, + .openui-metric__previous { + @include cssUtils.typography(body, small); + } + } + &--size-large { + .openui-metric__value { + @include cssUtils.typography(body, large-heavy); + } + .openui-metric__delta, + .openui-metric__previous { + @include cssUtils.typography(body, small); + } + } + &[class*="--size-"] :is(.openui-metric__value, .openui-metric__delta, .openui-metric__previous) { + font-variant-numeric: tabular-nums; + } +} diff --git a/packages/react-ui/src/components/Modal/Modal.scss b/packages/react-ui/src/components/Modal/Modal.scss index 3a2d41e5f8..517ba8e344 100644 --- a/packages/react-ui/src/components/Modal/Modal.scss +++ b/packages/react-ui/src/components/Modal/Modal.scss @@ -22,7 +22,7 @@ background: cssUtils.$foreground; color: cssUtils.$text-neutral-primary; border: 1px solid cssUtils.$border-default; - border-radius: 12px; + border-radius: 14px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; @@ -51,13 +51,18 @@ display: flex; align-items: center; justify-content: space-between; - padding: cssUtils.$space-m; - border-bottom: 1px solid cssUtils.$border-default; + gap: cssUtils.$space-m; + // No rule under the header; the title sits on the same 20px inset as the + // body, with the close button pulled into the corner. + padding: 20px 14px 0 20px; } +// The same 22px Bold as Text "h2". .openui-modal-title { - @include cssUtils.typography(body, heavy); margin: 0; + font: #{cssUtils.$font-weight-bold} 22px / #{cssUtils.$line-height-heading} + #{cssUtils.$font-heading}; + letter-spacing: -0.012em; } .openui-modal-close { @@ -77,12 +82,13 @@ } .openui-modal-body { - padding: cssUtils.$space-m; + // 16px under the title, 20px between body pieces (text, then buttons). + padding: 16px 20px 20px; overflow-y: auto; flex: 1; display: flex; flex-direction: column; - gap: cssUtils.$space-m; + gap: 20px; } .openui-modal-sr-only { diff --git a/packages/react-ui/src/components/OptionCards/OptionCards.tsx b/packages/react-ui/src/components/OptionCards/OptionCards.tsx index c2798deb68..22245b6cca 100644 --- a/packages/react-ui/src/components/OptionCards/OptionCards.tsx +++ b/packages/react-ui/src/components/OptionCards/OptionCards.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; import { InlineMarkdownRenderer } from "../InlineMarkdownRenderer"; import { getRowConfiguration } from "../_shared/cards/smallCardBlockUtils"; +import { SelectionCheck } from "./SelectionCheck"; export type OptionCardsSelectionType = "single" | "multiple"; @@ -9,11 +10,12 @@ export interface OptionCardsItem { value: string; /** Inline markdown supported. */ title: string; - /** Inline markdown supported. */ - subtitle?: string; - topContent?: ReactNode; - /** Controls the top slot sizing: a small icon tile or a larger image tile. */ - topContentVariant?: "icon" | "image"; + /** A string (inline markdown supported) or composed nodes. */ + subtitle?: ReactNode; + /** Icon, Avatar, Emoji or Image shown above the title. */ + leading?: ReactNode; + /** Controls the leading slot sizing: a round icon tile or a larger image tile. */ + leadingVariant?: "icon" | "image"; disabled?: boolean; } @@ -73,8 +75,8 @@ const OptionCards = forwardRef((props, ref) => {rowItems.map((item) => { const isSelected = selected.includes(item.value); const isDisabled = disabled || item.disabled === true; - const hasTopContent = item.topContent != null; - const topVariant = item.topContentVariant ?? "icon"; + const hasLeading = item.leading != null; + const leadingVariant = item.leadingVariant ?? "icon"; return (
@@ -90,15 +92,16 @@ const OptionCards = forwardRef((props, ref) => )} onClick={() => onToggle?.(item.value)} > +
- {hasTopContent ? ( + {hasLeading ? (
- {item.topContent} + {item.leading}
) : null}
@@ -107,7 +110,11 @@ const OptionCards = forwardRef((props, ref) =>
{item.subtitle ? (
- + {typeof item.subtitle === "string" ? ( + + ) : ( + item.subtitle + )}
) : null}
diff --git a/packages/react-ui/src/components/OptionCards/SelectionCheck.tsx b/packages/react-ui/src/components/OptionCards/SelectionCheck.tsx new file mode 100644 index 0000000000..bb44e53c0f --- /dev/null +++ b/packages/react-ui/src/components/OptionCards/SelectionCheck.tsx @@ -0,0 +1,33 @@ +import clsx from "clsx"; + +// The 18px round check chip shared with Checklist: an empty ring until +// selected, then filled black with a white check. Inline so the mark never +// waits on the lazily loaded icon set. The check is always drawn so it can +// animate in: the ring fills and the stroke draws itself. +export const SelectionCheck = ({ + checked, + className, +}: { + checked: boolean; + className?: string; +}) => ( + + + + + +); diff --git a/packages/react-ui/src/components/OptionCards/optionCards.scss b/packages/react-ui/src/components/OptionCards/optionCards.scss index 8003deb87d..cef55c900d 100644 --- a/packages/react-ui/src/components/OptionCards/optionCards.scss +++ b/packages/react-ui/src/components/OptionCards/optionCards.scss @@ -59,15 +59,72 @@ } } +// The 18px round check shared by OptionCards and Chips (and drawn the same +// way as Checklist's): an empty ring, filled black once selected. +.openui-selection-check { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 18px; + height: 18px; + box-sizing: border-box; + border-radius: 999px; + border: 1.5px solid cssUtils.$border-interactive-emphasis; + color: cssUtils.$background; + transition: + background-color 0.16s ease-out, + border-color 0.16s ease-out, + transform 0.16s ease-out; + + svg { + width: 10px; + height: 10px; + } + + // The stroke draws itself once the ring has filled. + path { + stroke-dasharray: 1; + stroke-dashoffset: 1; + transition: stroke-dashoffset 0.18s ease-out 0.06s; + } + + &--checked { + border-color: cssUtils.$inverted-background; + background-color: cssUtils.$inverted-background; + animation: openui-selection-check-pop 0.2s ease-out; + + path { + stroke-dashoffset: 0; + } + } +} + +@keyframes openui-selection-check-pop { + 0% { + transform: scale(0.7); + } + 60% { + transform: scale(1.08); + } + 100% { + transform: scale(1); + } +} + .openui-option-card { + // Images on an option card keep the tighter radius they had. + --openui-image-radius: #{cssUtils.$radius-m}; @include cssUtils.button-reset; + @include cssUtils.typography(body, small); + position: relative; display: flex; align-items: flex-start; justify-content: flex-start; width: 100%; height: 100%; box-sizing: border-box; - padding: cssUtils.$space-s-m; + padding: 1.143em; border: 1px solid cssUtils.$border-interactive; border-radius: cssUtils.$radius-2xl; background: transparent; @@ -76,20 +133,31 @@ transition: opacity 0.16s ease-out, border-color 0.16s ease-out, - background-color 0.16s ease-out; + background-color 0.16s ease-out, + transform 0.16s ease-out; - &:hover:not(:disabled):not(.openui-option-card--selected) { + &:hover:not(:disabled) { border-color: cssUtils.$border-interactive-emphasis; + background-color: cssUtils.$sunk; + transform: scale(1.01); + } + + &:active:not(:disabled) { + background-color: cssUtils.$highlight-strong; + transform: scale(0.98); + transition-duration: 0.08s; } &:focus-visible { - outline: 2px solid cssUtils.$border-accent; + outline: 2px solid cssUtils.$inverted-background; outline-offset: 2px; } - &--selected { - background: cssUtils.$sunk; - border-color: cssUtils.$border-interactive-selected; + // Selected: a grey fill in place of the outline, plus the check. + &--selected, + &--selected:hover:not(:disabled) { + border-color: transparent; + background-color: cssUtils.$highlight-strong; } &--disabled { @@ -98,16 +166,25 @@ } } +// The check sits in the top-right corner; the content keeps clear of it. +.openui-option-card__check { + position: absolute; + top: 1em; + right: 1em; +} + .openui-option-card__content { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; - gap: cssUtils.$space-m; + gap: 0.857em; width: 100%; + padding-right: 1.714em; + box-sizing: border-box; } -.openui-option-card__top { +.openui-option-card__leading { align-self: flex-start; flex-shrink: 0; @@ -115,11 +192,34 @@ display: inline-flex; align-items: center; justify-content: center; - width: 2rem; - height: 2rem; - border-radius: cssUtils.$radius-s; - background: cssUtils.$sunk; + min-width: 2rem; + min-height: 2rem; + border-radius: 999px; color: cssUtils.$text-neutral-primary; + + // A bare Icon or Emoji gets a round sunk tile; an Avatar fills the same + // 2rem circle on its own. + &:has(> svg), + &:has(> .openui-emoji) { + width: 2rem; + height: 2rem; + background: cssUtils.$sunk; + + svg { + width: 16px; + height: 16px; + } + } + + .openui-emoji { + font-size: 1.125rem; + line-height: 1; + } + + .openui-avatar { + width: 2rem; + height: 2rem; + } } &--image { @@ -139,7 +239,7 @@ .openui-option-card__text { display: flex; flex-direction: column; - gap: cssUtils.$space-3xs; + gap: 0.143em; min-width: 0; } @@ -147,7 +247,7 @@ color: cssUtils.$text-neutral-primary; word-wrap: break-word; overflow-wrap: break-word; - @include cssUtils.typography(body, heavy); + @include cssUtils.typography(label, small-heavy); } .openui-option-card__subtitle { @@ -155,11 +255,25 @@ word-wrap: break-word; overflow-wrap: break-word; @include cssUtils.typography(body, small); + + // A composed Text keeps the subtitle's size and secondary color. + .openui-text-content, + .openui-markdown-renderer, + .openui-markdown-renderer p { + margin: 0; + font: inherit; + letter-spacing: inherit; + font-variation-settings: inherit; + color: inherit; + } } -.openui-option-card--disabled { - .openui-option-card__title, - .openui-option-card__subtitle { - color: cssUtils.$text-neutral-tertiary; +@media (prefers-reduced-motion: reduce) { + .openui-option-card, + .openui-selection-check, + .openui-selection-check path { + transition: none; + animation: none; + transform: none !important; } } diff --git a/packages/react-ui/src/components/OverviewCardBlock/OverviewCardBlock.tsx b/packages/react-ui/src/components/OverviewCardBlock/OverviewCardBlock.tsx index 56f4ea3e2e..6623f39333 100644 --- a/packages/react-ui/src/components/OverviewCardBlock/OverviewCardBlock.tsx +++ b/packages/react-ui/src/components/OverviewCardBlock/OverviewCardBlock.tsx @@ -1,5 +1,4 @@ import clsx from "clsx"; -import { ChevronRight } from "lucide-react"; import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; import { CardBlockLayout, cardKeyDownHandler } from "../_shared/cards/CardBlockLayout"; import { MetricIndicatorInline, type MetricIndicatorInlineProps } from "../MetricIndicator"; @@ -12,6 +11,8 @@ export interface OverviewCardBlockItem { top?: ReactNode; /** Bottom metric rendered with MetricIndicatorInline. */ bottom?: Omit; + /** Bottom content to render as-is (e.g. a Metric); takes precedence over `bottom`. */ + bottomContent?: ReactNode; } export interface OverviewCardBlockProps extends Omit, "onClick"> { @@ -69,15 +70,10 @@ const OverviewCardBlock = forwardRef((pr {item.top}
)} - {isClickable && ( - - )}
- {item.bottom && ( + {(item.bottomContent || item.bottom) && (
- + {item.bottomContent ?? (item.bottom && )}
)}
diff --git a/packages/react-ui/src/components/OverviewCardBlock/overviewCardBlock.scss b/packages/react-ui/src/components/OverviewCardBlock/overviewCardBlock.scss index 5f91d1012f..8174d9c064 100644 --- a/packages/react-ui/src/components/OverviewCardBlock/overviewCardBlock.scss +++ b/packages/react-ui/src/components/OverviewCardBlock/overviewCardBlock.scss @@ -1,13 +1,17 @@ @use "../../cssUtils" as cssUtils; +@use "../_shared/cards/clickable"; .openui-overview-card { display: flex; flex-direction: column; width: 100%; min-width: 158px; - padding: cssUtils.$space-s-m; - border: 1px solid cssUtils.$border-default; - border-radius: cssUtils.$radius-2xl; + // Same insets as the horizontal ValueCard (12px, 18px on the right where + // text ends), with a little more below the hero value. + padding: 12px 18px 14px 12px; + // Matches the horizontal ValueCard: no outline, sunk tint, 14px radius. + border: none; + border-radius: 14px; background-color: cssUtils.$sunk-light; transition: background-color 0.2s ease, @@ -15,21 +19,9 @@ box-shadow 0.2s ease, transform 0.2s ease; + // Clickable cards keep the sunk tint and gain the hover wash and lift. &--clickable { - background-color: cssUtils.$foreground; - border-color: cssUtils.$border-interactive; - box-shadow: cssUtils.$shadow-m; - cursor: pointer; - - &:hover { - box-shadow: none; - transform: scale(0.995); - } - - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; - } + @include clickable.clickable-card; } } @@ -37,8 +29,10 @@ display: flex; flex: 1; flex-direction: column; - justify-content: space-between; - gap: cssUtils.$space-l; + // The value follows the label at a fixed gap, so it never floats down when + // a neighbour in the row is taller. + justify-content: flex-start; + gap: 8px; min-width: 0; min-height: 0; } @@ -71,10 +65,11 @@ min-width: 0; width: auto; display: flex; - align-items: flex-start; justify-content: space-between; gap: cssUtils.$space-s; - min-height: cssUtils.$space-xl; + // Labels sit at the top at their own height; values line up across a row + // because the card pushes them to the bottom (space-between above). + align-items: flex-start; } .openui-overview-card__slot--bottom { @@ -82,3 +77,92 @@ flex-direction: column; gap: cssUtils.$space-2xs; } + +// Vertical ValueCard label: the leading and title on one line, the way a +// LeadingText reads, with the subtitle always on its own line below, above +// the hero value. +.openui-value-card-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375em; + min-width: 0; + @include cssUtils.typography(body, small); + // A step below body text so the label reads as a caption to the value. + font-size: 13px; + line-height: 18px; + color: cssUtils.$text-neutral-primary; + + &__leading { + display: inline-flex; + flex: none; + align-items: center; + // Icon at the text's icon size and stroke; an Avatar or Emoji keeps its + // inline size. + > svg { + width: 1em; + height: 1em; + stroke-width: 1.5; + } + // Inline avatars track the smaller text too. + > .openui-avatar { + width: calc(1em + 4px); + height: calc(1em + 4px); + } + } + + &__text { + display: contents; + } + + // In the vertical card the subtitle never shares the title's line, so every + // card in a row reads the same whatever the title length. It starts at the + // card's left edge, like the leading and the value, so only the title sits + // beside the leading. + .openui-overview-card & { + row-gap: 0; + + .openui-value-card-label__subtitle { + flex-basis: 100%; + } + } + + &__subtitle { + display: inline-flex; + align-items: center; + gap: 0.375em; + color: cssUtils.$text-neutral-secondary; + } + + // Horizontal card with a subtitle: title over subtitle, and a 36px leading + // (two 18px lines) instead of the inline one. + &--stacked { + flex-wrap: nowrap; + gap: 10px; + + .openui-value-card-label__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + .openui-value-card-label__leading { + width: 36px; + height: 36px; + justify-content: center; + + > .openui-avatar { + width: 100%; + height: 100%; + vertical-align: 0; + } + > .openui-avatar .openui-avatar__icon { + width: 50%; + height: 50%; + } + > .openui-emoji { + font-size: 24px; + } + } + } +} diff --git a/packages/react-ui/src/components/RadioItem/radioItem.scss b/packages/react-ui/src/components/RadioItem/radioItem.scss index 3a29db1771..6ea5750478 100644 --- a/packages/react-ui/src/components/RadioItem/radioItem.scss +++ b/packages/react-ui/src/components/RadioItem/radioItem.scss @@ -23,7 +23,7 @@ width: 16px; cursor: pointer; background-color: transparent; - margin-top: cssUtils.$space-2xs; + margin-top: 1px; &[data-state] { // This makes the root element a styling context for children @@ -55,7 +55,7 @@ } .openui-radio-item-root[data-state="checked"] & { - fill: cssUtils.$interactive-accent-default; + fill: cssUtils.$inverted-background; } } @@ -65,8 +65,12 @@ stroke: cssUtils.$border-interactive; } + .openui-radio-item-root[data-state="unchecked"]:hover:not(:disabled) & { + stroke: cssUtils.$border-interactive-emphasis; + } + .openui-radio-item-root[data-state="checked"] & { - stroke: cssUtils.$border-accent; + stroke: cssUtils.$inverted-background; } } @@ -81,7 +85,7 @@ } .openui-radio-item-root[data-state="unchecked"]:hover:not(:disabled) & { - fill: cssUtils.$border-interactive; + fill: cssUtils.$foreground; } } .openui-radio-item-content { @@ -92,18 +96,18 @@ .openui-radio-item-label { flex: 1; - @include cssUtils.typography(body, default); + @include cssUtils.typography(label, small-heavy); color: cssUtils.$text-neutral-primary; cursor: pointer; overflow-wrap: anywhere; &:disabled { - color: cssUtils.$text-neutral-tertiary; + opacity: 0.5; cursor: not-allowed; } } .openui-radio-item-description { - @include cssUtils.typography(body, default); + @include cssUtils.typography(body, small); color: cssUtils.$text-neutral-secondary; overflow-wrap: anywhere; } diff --git a/packages/react-ui/src/components/Section/Section.tsx b/packages/react-ui/src/components/Section/Section.tsx new file mode 100644 index 0000000000..9e4568592b --- /dev/null +++ b/packages/react-ui/src/components/Section/Section.tsx @@ -0,0 +1,12 @@ +import clsx from "clsx"; +import type { ReactNode } from "react"; + +export interface SectionProps { + children?: ReactNode; + className?: string; +} + +/** One part of a response: stacked content with room below it before the next part. */ +export const Section = ({ children, className }: SectionProps) => ( +
{children}
+); diff --git a/packages/react-ui/src/components/Section/index.ts b/packages/react-ui/src/components/Section/index.ts new file mode 100644 index 0000000000..c40b16983b --- /dev/null +++ b/packages/react-ui/src/components/Section/index.ts @@ -0,0 +1,2 @@ +export { Section } from "./Section"; +export type { SectionProps } from "./Section"; diff --git a/packages/react-ui/src/components/Section/section.scss b/packages/react-ui/src/components/Section/section.scss new file mode 100644 index 0000000000..5605924878 --- /dev/null +++ b/packages/react-ui/src/components/Section/section.scss @@ -0,0 +1,19 @@ +@use "../../cssUtils" as cssUtils; + +// Spacing is in em off the 16px body size, so it scales with the theme. +.openui-section { + display: flex; + flex-direction: column; + // 16px between children; a Heading child adds its own 0.25em below it. + gap: 1em; + min-width: 0; + font-size: var(--openui-font-size-md); + color: cssUtils.$text-neutral-primary; + // Bottom-only, so each part is spaced from the next and the first needs + // nothing on top. The last one leaves the bottom to its parent's padding. + padding-bottom: 2.25em; + + &:last-child { + padding-bottom: 0; + } +} diff --git a/packages/react-ui/src/components/Select/select.scss b/packages/react-ui/src/components/Select/select.scss index 1b38d5296f..f176037109 100644 --- a/packages/react-ui/src/components/Select/select.scss +++ b/packages/react-ui/src/components/Select/select.scss @@ -1,5 +1,6 @@ @use "../../cssUtils" as cssUtils; @use "../menu"; +@use "../Input/genuiField" as field; .openui-select-trigger { box-sizing: border-box; @@ -192,3 +193,22 @@ height: 1px; border-top: 1px solid cssUtils.$border-default; } + +// ─── Agent (genui) select ─── +// Same pill surface as the genui input. See ../Input/_genuiField.scss. +.openui-select-trigger.openui-genui-select { + @include field.pill; + width: 100%; + + &[data-placeholder] { + color: cssUtils.$text-neutral-secondary; + } + + .openui-select-trigger-icon { + @include field.icon; + } + + &--inline { + @include field.inline; + } +} diff --git a/packages/react-ui/src/components/Shape/Shape.tsx b/packages/react-ui/src/components/Shape/Shape.tsx new file mode 100644 index 0000000000..659c2fc28f --- /dev/null +++ b/packages/react-ui/src/components/Shape/Shape.tsx @@ -0,0 +1,50 @@ +import clsx from "clsx"; + +export type ShapeKind = + | "circle" + | "square" + | "diamond" + | "triangle-up" + | "triangle-down" + | "triangle-left" + | "triangle-right" + | "hexagon"; + +export type ShapeColor = + "neutral" | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted"; + +export interface ShapeProps { + shape: ShapeKind; + color?: ShapeColor; + /** Accessible label; omit when the shape is decorative. */ + label?: string; + className?: string; +} + +// Drawn in a 24-unit box with ~3 units of padding, like a lucide icon, so a +// shape sits at the same visual size as an Icon in the same slot. +const PATHS: Record = { + circle: "M12 4a8 8 0 1 1 0 16a8 8 0 1 1 0-16Z", + square: "M6.5 4.5h11a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-11a2 2 0 0 1-2-2v-11a2 2 0 0 1 2-2Z", + diamond: "M12 3.5l8.5 8.5l-8.5 8.5l-8.5-8.5Z", + "triangle-up": "M12 4.5l8.5 14.5h-17Z", + "triangle-down": "M12 19.5l8.5-14.5h-17Z", + "triangle-left": "M4.5 12l14.5-8.5v17Z", + "triangle-right": "M19.5 12l-14.5-8.5v17Z", + hexagon: "M12 3.5l7.5 4.25v8.5l-7.5 4.25l-7.5-4.25v-8.5Z", +}; + +/** A small solid shape, a sibling of Icon, for color-coded markers. */ +export const Shape = ({ shape, color = "neutral", label, className }: ShapeProps) => ( + + + +); diff --git a/packages/react-ui/src/components/Shape/index.ts b/packages/react-ui/src/components/Shape/index.ts new file mode 100644 index 0000000000..7213940756 --- /dev/null +++ b/packages/react-ui/src/components/Shape/index.ts @@ -0,0 +1,2 @@ +export { Shape } from "./Shape"; +export type { ShapeColor, ShapeKind, ShapeProps } from "./Shape"; diff --git a/packages/react-ui/src/components/Shape/shape.scss b/packages/react-ui/src/components/Shape/shape.scss new file mode 100644 index 0000000000..5fa181ba92 --- /dev/null +++ b/packages/react-ui/src/components/Shape/shape.scss @@ -0,0 +1,41 @@ +@use "../../cssUtils" as cssUtils; + +// Solid fill in the shared palette's primary (foreground) color. Hosts size +// it like an Icon; standalone it is 1em. +.openui-shape { + display: inline-block; + flex: none; + width: 1em; + height: 1em; + stroke: none !important; + + &--neutral { + color: cssUtils.$text-neutral-secondary; + } + &--info { + color: cssUtils.$text-info-primary; + } + &--success { + color: cssUtils.$text-success-primary; + } + &--warning { + color: cssUtils.$text-alert-primary; + } + &--danger { + color: cssUtils.$text-danger-primary; + } + &--purple { + color: cssUtils.$text-purple-primary; + } + &--pink { + color: cssUtils.$text-pink-primary; + } + &--inverted { + color: cssUtils.$text-neutral-primary; + } +} + +// Inside a tinted Avatar the shape takes the avatar's foreground color. +.openui-avatar__icon .openui-shape { + color: inherit; +} diff --git a/packages/react-ui/src/components/Shell/Shell.tsx b/packages/react-ui/src/components/Shell/Shell.tsx new file mode 100644 index 0000000000..db80be4e8d --- /dev/null +++ b/packages/react-ui/src/components/Shell/Shell.tsx @@ -0,0 +1,150 @@ +"use client"; + +import clsx from "clsx"; +import { Maximize2, X } from "lucide-react"; +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { IconButton } from "../IconButton"; +import { useTheme } from "../ThemeProvider"; + +export interface ShellProps { + title: string; + children?: React.ReactNode; + className?: string; +} + +// Matches the scale+fade in shell.scss; unmount waits for the close animation. +const CLOSE_MS = 200; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * A frame for dashboards: a small header with an expand button, wider than + * the chat column it sits in. Expand opens the same content full screen. + */ +export const Shell = ({ title, children, className }: ShellProps) => { + const [open, setOpen] = useState(false); + const [closing, setClosing] = useState(false); + const expandRef = useRef(null); + + const close = useCallback(() => { + const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + setClosing(true); + window.setTimeout( + () => { + setOpen(false); + setClosing(false); + expandRef.current?.focus(); + }, + reduced ? 0 : CLOSE_MS, + ); + }, []); + + return ( +
+
+ {title} + } + shape="circle" + variant="tertiary" + size="extra-small" + aria-label={`Expand ${title}`} + onClick={() => setOpen(true)} + /> +
+
{children}
+ {open && ( + + {children} + + )} +
+ ); +}; + +const ShellLightbox = ({ + title, + closing, + onClose, + children, +}: { + title: string; + closing: boolean; + onClose: () => void; + children?: React.ReactNode; +}) => { + const { portalThemeClassName } = useTheme(); + const panelRef = useRef(null); + const titleId = useId(); + + // Focus the panel, lock page scroll, and close on Escape. + useEffect(() => { + panelRef.current?.focus(); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + // Keep Tab inside the panel. + if (e.key === "Tab" && panelRef.current) { + const items = Array.from(panelRef.current.querySelectorAll(FOCUSABLE)); + if (items.length === 0) return e.preventDefault(); + const first = items[0]!; + const last = items[items.length - 1]!; + const active = document.activeElement; + if (e.shiftKey && (active === first || active === panelRef.current)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = previousOverflow; + }; + }, [onClose]); + + return createPortal( +
+
+
+
+ + {title} + + } + shape="circle" + variant="tertiary" + size="extra-small" + aria-label="Close" + onClick={onClose} + /> +
+
{children}
+
+
, + document.body, + ); +}; diff --git a/packages/react-ui/src/components/Shell/index.ts b/packages/react-ui/src/components/Shell/index.ts new file mode 100644 index 0000000000..68de0ef43f --- /dev/null +++ b/packages/react-ui/src/components/Shell/index.ts @@ -0,0 +1,2 @@ +export { Shell } from "./Shell"; +export type { ShellProps } from "./Shell"; diff --git a/packages/react-ui/src/components/Shell/shell.scss b/packages/react-ui/src/components/Shell/shell.scss new file mode 100644 index 0000000000..64080a6197 --- /dev/null +++ b/packages/react-ui/src/components/Shell/shell.scss @@ -0,0 +1,143 @@ +@use "../../cssUtils" as cssUtils; +@use "../_shared/bleed"; + +// A framed dashboard: an outlined frame with the shared breakout, so it is +// wider than the chat column. +.openui-shell { + @include bleed.breakout; + display: flex; + flex-direction: column; + gap: 8px; + // Even 16px inset so the title and the 14px cards inside line up; a 20px + // outer radius, softer than the cards without reading pill-like. + padding: 16px; + border: 1px solid cssUtils.$border-default; + border-radius: 20px; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + } + + // 12px secondary label. + &__title { + font-size: 12px; + line-height: 16px; + font-weight: var(--openui-font-weight-medium); + font-variation-settings: var(--openui-font-variation-medium); + color: cssUtils.$text-neutral-secondary; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Ghost button: the tertiary IconButton's own hover wash, nothing more. + &__button { + flex: none; + } + + &__body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } +} + +// ─── Full-screen lightbox ─── + +.openui-shell-lightbox { + position: fixed; + inset: 0; + z-index: 9999; + + &__backdrop { + position: absolute; + inset: 0; + background: cssUtils.$overlay; + animation: openui-shell-fade 200ms ease-out both; + } + + &__panel { + position: absolute; + inset: 24px; + display: flex; + flex-direction: column; + gap: 8px; + // Same inset and radius as the inline frame. + padding: 16px; + box-sizing: border-box; + border-radius: 20px; + background: cssUtils.$foreground; + color: cssUtils.$text-neutral-primary; + box-shadow: 0 16px 48px oklch(0 0 0 / 0.2); + animation: openui-shell-zoom 200ms ease-out both; + + &:focus { + outline: none; + } + } + + // The panel's own scroll; the page behind stays locked. + &__body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + } + + &--closing { + .openui-shell-lightbox__backdrop { + animation: openui-shell-fade-out 200ms ease-in both; + } + .openui-shell-lightbox__panel { + animation: openui-shell-zoom-out 200ms ease-in both; + } + } + + @media (prefers-reduced-motion: reduce) { + .openui-shell-lightbox__backdrop, + .openui-shell-lightbox__panel { + animation: none !important; + } + } +} + +@keyframes openui-shell-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes openui-shell-zoom { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes openui-shell-fade-out { + to { + opacity: 0; + } +} + +@keyframes openui-shell-zoom-out { + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/react-ui/src/components/Slider/FieldSlider.tsx b/packages/react-ui/src/components/Slider/FieldSlider.tsx new file mode 100644 index 0000000000..b914ed78d3 --- /dev/null +++ b/packages/react-ui/src/components/Slider/FieldSlider.tsx @@ -0,0 +1,190 @@ +import * as SliderPrimitive from "@radix-ui/react-slider"; +import clsx from "clsx"; +import { KeyboardEvent, useEffect, useMemo, useState } from "react"; + +// A slider shaped like a form field, the way design tools draw number scrubs: +// the label sits inside on the left, the fill sits behind it and follows the +// pointer anywhere on the field, and the value on the right is editable text. + +export interface FieldSliderProps { + label: string; + name?: string; + variant?: "continuous" | "discrete"; + min: number; + max: number; + step?: number; + defaultValue?: number[]; + disabled?: boolean; + onValueCommit?: (value: number[]) => void; + className?: string; +} + +// Discrete sliders get a tick per step, unless the steps would crowd the field. +const MAX_TICKS = 24; + +const decimalsOf = (n: number) => { + const s = String(n); + const dot = s.indexOf("."); + return dot === -1 ? 0 : s.length - dot - 1; +}; + +const ValueText = ({ + value, + min, + max, + step, + disabled, + label, + onCommit, +}: { + value: number; + min: number; + max: number; + step: number; + disabled?: boolean; + label: string; + onCommit: (v: number) => void; +}) => { + const [draft, setDraft] = useState(String(value)); + const [editing, setEditing] = useState(false); + + useEffect(() => { + if (!editing) setDraft(String(value)); + }, [value, editing]); + + const commit = () => { + setEditing(false); + const parsed = Number(draft); + if (draft.trim() === "" || Number.isNaN(parsed)) { + setDraft(String(value)); + return; + } + const snapped = Math.round((parsed - min) / step) * step + min; + const clamped = Math.min(max, Math.max(min, snapped)); + const rounded = Number(clamped.toFixed(decimalsOf(step))); + setDraft(String(rounded)); + onCommit(rounded); + }; + + return ( + { + setEditing(true); + e.currentTarget.select(); + }} + onChange={(e) => setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") { + setDraft(String(value)); + setEditing(false); + e.currentTarget.blur(); + } + }} + /> + ); +}; + +export const FieldSlider = ({ + label, + name, + variant = "continuous", + min, + max, + step, + defaultValue, + disabled, + onValueCommit, + className, +}: FieldSliderProps) => { + const resolvedStep = step && step > 0 ? step : variant === "discrete" ? 1 : (max - min) / 100; + const [value, setValue] = useState( + defaultValue && defaultValue.length > 0 ? defaultValue : [min], + ); + + useEffect(() => { + if (defaultValue && defaultValue.length > 0) setValue(defaultValue); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(defaultValue)]); + + const isRange = value.length > 1; + + const ticks = useMemo(() => { + if (variant !== "discrete" || max <= min) return []; + const count = Math.floor((max - min) / resolvedStep); + if (count < 2 || count > MAX_TICKS) return []; + return Array.from({ length: count - 1 }, (_, i) => ((i + 1) / count) * 100); + }, [variant, min, max, resolvedStep]); + + const setAt = (index: number, v: number) => { + const next = [...value]; + next[index] = v; + if (isRange) next.sort((a, b) => a - b); + setValue(next); + onValueCommit?.(next); + }; + + return ( +
+ + + + {ticks.map((left) => ( + + ))} + + {value.map((_, i) => ( + + ))} + + {label} + + setAt(0, v)} + /> + {isRange && ( + <> + – + setAt(1, v)} + /> + + )} + +
+ ); +}; diff --git a/packages/react-ui/src/components/Slider/index.ts b/packages/react-ui/src/components/Slider/index.ts index 85e2736077..bcc70c763e 100644 --- a/packages/react-ui/src/components/Slider/index.ts +++ b/packages/react-ui/src/components/Slider/index.ts @@ -1,2 +1,3 @@ +export * from "./FieldSlider"; export * from "./Slider"; export * from "./SliderBlock"; diff --git a/packages/react-ui/src/components/Slider/slider.scss b/packages/react-ui/src/components/Slider/slider.scss index 8bd2539e7d..eba12a9b88 100644 --- a/packages/react-ui/src/components/Slider/slider.scss +++ b/packages/react-ui/src/components/Slider/slider.scss @@ -1,4 +1,5 @@ @use "../../cssUtils" as cssUtils; +@use "../Input/genuiField" as field; // ─── Base Slider ──────────────────────────────────────────────────────────── @@ -58,7 +59,7 @@ position: absolute; height: 100%; border-radius: cssUtils.$radius-s; - background-color: cssUtils.$interactive-accent-default; + background-color: cssUtils.$inverted-background; margin-left: -10px; margin-right: -8px; @@ -97,7 +98,7 @@ width: 6px; height: 6px; border-radius: cssUtils.$radius-3xs; - background-color: cssUtils.$interactive-accent-default; + background-color: cssUtils.$inverted-background; } } } @@ -177,7 +178,7 @@ } &__label { - @include cssUtils.typography(label, default); + @include cssUtils.typography(label, small-heavy); color: cssUtils.$text-neutral-primary; padding-top: cssUtils.$space-xs; } @@ -263,3 +264,149 @@ flex-shrink: 0; } } + +// ─── Field Slider ─────────────────────────────────────────────────────────── +// A slider in the shape of a genui field (44px, 8px corners). The label sits +// inside on the left, the fill sits behind it and follows the pointer, and the +// value on the right is editable. See ./FieldSlider.tsx. + +.openui-field-slider { + @include field.surface; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.571em; + width: 100%; + height: field.$height; + // The value boxes carry 0.286em of their own, so the text lines up with + // the label's 1.143em inset. + padding: 0 0.857em 0 1.143em; + border-radius: field.$radius; + overflow: hidden; + cursor: ew-resize; + user-select: none; + + // Dragging or typing doesn't need a focus outline on the whole field. + &:focus-within { + border-color: cssUtils.$border-interactive; + box-shadow: none; + } + + &__root { + position: absolute; + inset: 0; + display: flex; + align-items: center; + touch-action: none; + } + + &__track { + position: relative; + flex: 1; + height: 100%; + } + + &__range { + position: absolute; + height: 100%; + background-color: cssUtils.$highlight-strong; + transition: background-color 0.16s ease; + } + + &__tick { + position: absolute; + bottom: 0; + width: 1px; + height: 6px; + transform: translateX(-50%); + background-color: cssUtils.$border-interactive; + } + + &__thumb { + display: block; + width: 2px; + height: 20px; + border-radius: 1px; + background-color: cssUtils.$border-interactive-emphasis; + outline: none; + transition: + background-color 0.16s ease, + height 0.16s ease; + + &:focus-visible { + background-color: cssUtils.$inverted-background; + height: 28px; + } + } + + &:hover:not(&--disabled) &__thumb, + &:active:not(&--disabled) &__thumb { + background-color: cssUtils.$inverted-background; + } + + &:active:not(&--disabled) &__thumb { + height: 28px; + } + + &__label, + &__values { + position: relative; + z-index: 1; + } + + &__label { + @include cssUtils.typography(label, small-heavy); + color: cssUtils.$text-neutral-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + } + + &__values { + display: flex; + align-items: center; + flex-shrink: 0; + color: cssUtils.$text-neutral-secondary; + } + + &__value { + box-sizing: content-box; + min-width: 1ch; + height: 24px; + padding: 0 0.286em; + border: none; + border-radius: cssUtils.$radius-xs; + background: transparent; + outline: none; + font: inherit; + letter-spacing: inherit; + font-variation-settings: inherit; + font-variant-numeric: tabular-nums; + color: cssUtils.$text-neutral-primary; + text-align: right; + cursor: text; + + &:hover:not(:disabled) { + background-color: cssUtils.$sunk; + } + + &:focus { + background-color: cssUtils.$sunk; + } + + &:disabled { + cursor: not-allowed; + } + } + + &__separator { + padding: 0 0.143em; + color: cssUtils.$text-neutral-secondary; + } + + &--disabled { + cursor: not-allowed; + } +} diff --git a/packages/react-ui/src/components/SnippetCardBlock/index.ts b/packages/react-ui/src/components/SnippetCardBlock/index.ts deleted file mode 100644 index 16d6ec6ab5..0000000000 --- a/packages/react-ui/src/components/SnippetCardBlock/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./SnippetCardBlock"; diff --git a/packages/react-ui/src/components/Sources/ListedSources.tsx b/packages/react-ui/src/components/Sources/ListedSources.tsx index 712acabc40..53cf1a783a 100644 --- a/packages/react-ui/src/components/Sources/ListedSources.tsx +++ b/packages/react-ui/src/components/Sources/ListedSources.tsx @@ -1,82 +1,64 @@ import clsx from "clsx"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { Key, memo, useCallback, useRef, useState } from "react"; -import { Carousel, CarouselContent, CarouselItem, CarouselRef } from "../Carousel"; -import { IconButton } from "../IconButton"; +import { Key, memo } from "react"; +import { DisclosureSection } from "../_shared/DisclosureSection"; +import { SourceFaviconImage } from "../SourceFaviconImage"; import { SourceWithFavicon } from "./SourceContext"; import { ListedSourceItem } from "./SourcesItem"; export interface ListedSourcesProps { sources: SourceWithFavicon[]; + /** Start expanded. Collapsed by default. */ + defaultOpen?: boolean; } +// Favicons stacked beside the collapsed header, one per distinct site. +const FaviconStack = ({ sources }: { sources: SourceWithFavicon[] }) => { + const seen = new Set(); + const unique = sources.filter((s) => { + const k = s.faviconUrl || s.sourceName; + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + return ( + + {unique.slice(0, 4).map((s, i) => ( + + + + ))} + + ); +}; + export const ListedSources = memo((props: ListedSourcesProps) => { const sources = props.sources ?? []; - - const ref = useRef(null); - const [scroll, setScroll] = useState({ left: false, right: false }); - - const handleScrollLeftEnabled = useCallback((enabled: boolean) => { - setScroll((prev) => ({ ...prev, left: enabled })); - }, []); - - const handleScrollRightEnabled = useCallback((enabled: boolean) => { - setScroll((prev) => ({ ...prev, right: enabled })); - }, []); - if (!sources.length) return null; - // Show buttons only if there's overflow (can scroll left or right) - const hasOverflow = scroll.left || scroll.right; - return (
-
- Sources - {hasOverflow && ( -
- ref.current?.scroll("left")} - disabled={!scroll.left} - icon={} - /> - ref.current?.scroll("right")} - disabled={!scroll.right} - icon={} - /> -
- )} -
- } + defaultOpen={props.defaultOpen} > - +
{sources.map((item: SourceWithFavicon, index: number) => { - const { key: _key, ...rest } = item as SourceWithFavicon & { - key?: Key; - }; + const { key: _key, ...rest } = item as SourceWithFavicon & { key?: Key }; return ( - - +
); })} -
-
+
+
); }); diff --git a/packages/react-ui/src/components/Sources/SourceContext.tsx b/packages/react-ui/src/components/Sources/SourceContext.tsx index 2b26589d0d..1fa718282a 100644 --- a/packages/react-ui/src/components/Sources/SourceContext.tsx +++ b/packages/react-ui/src/components/Sources/SourceContext.tsx @@ -29,17 +29,17 @@ export const CardSourceContext = createContext( * Returns the favicon URL for a given website using Google's favicon service. * * Attempts to parse the provided URL string and extract the hostname (domain). - * If parsing succeeds, returns a URL (with size 128) from Google's s2/favicons API. + * If parsing succeeds, returns a URL (with the given size, 128 by default) from Google's s2/favicons API. * If the URL is invalid or parsing fails, returns an empty string. */ -export const getFaviconUrl = (url?: string) => { +export const getFaviconUrl = (url?: string, size = 128) => { try { if (!url) { return ""; } const parsedUrl = new URL(url); const domain = parsedUrl.hostname; - return `https://www.google.com/s2/favicons?sz=128&domain=${domain}`; + return `https://www.google.com/s2/favicons?sz=${size}&domain=${domain}`; } catch { return ""; } diff --git a/packages/react-ui/src/components/Sources/SourcesItem.tsx b/packages/react-ui/src/components/Sources/SourcesItem.tsx index 2f90ea0de4..47fd41074e 100644 --- a/packages/react-ui/src/components/Sources/SourcesItem.tsx +++ b/packages/react-ui/src/components/Sources/SourcesItem.tsx @@ -33,11 +33,13 @@ export const ListedSourceItem = memo((props: SourcesItemComponentProps) => { >
- +
{sourceName}
-
{title}
+
+ {title} +
); }); diff --git a/packages/react-ui/src/components/Sources/sources.scss b/packages/react-ui/src/components/Sources/sources.scss index 22ce915068..c8333a228d 100644 --- a/packages/react-ui/src/components/Sources/sources.scss +++ b/packages/react-ui/src/components/Sources/sources.scss @@ -1,61 +1,107 @@ @use "../../cssUtils" as cssUtils; .openui-listed-sources-item-container { - width: 180px !important; - padding: cssUtils.$space-000 !important; - border-radius: cssUtils.$radius-l !important; - overflow: hidden; - background-color: transparent !important; - &--has-url { - &:hover { - background-color: cssUtils.$highlight-subtle !important; - border-color: cssUtils.$border-interactive !important; - } + box-sizing: border-box; + min-width: 0; + border: 1px solid cssUtils.$border-default; + border-radius: cssUtils.$radius-m; + background-color: transparent; + transition: + background-color 0.12s ease, + border-color 0.12s ease; + &--has-url:hover { + background-color: cssUtils.$highlight-subtle; + border-color: cssUtils.$border-interactive; + } + &:focus-within { + border-color: cssUtils.$border-interactive-emphasis; + } + @media (prefers-reduced-motion: reduce) { + transition: none; } } .openui-listed-source-item { + box-sizing: border-box; display: flex; flex-direction: column; - align-items: flex-start; - gap: cssUtils.$space-2xs; - padding: cssUtils.$space-s; + gap: cssUtils.$space-3xs; + min-width: 0; height: 100%; + padding: cssUtils.$space-xs cssUtils.$space-s; + border-radius: inherit; &--has-url { cursor: pointer; } + &:focus { + outline: none; + } &__header { display: flex; - align-items: flex-start; - gap: cssUtils.$space-s; + align-items: center; + gap: cssUtils.$space-xs; + min-width: 0; } - &__source-name { - color: cssUtils.$text-neutral-primary; - @include cssUtils.typography(label, small); + @include cssUtils.typography(label, extra-small); + color: cssUtils.$text-neutral-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &__title { - color: cssUtils.$text-neutral-secondary; - @include cssUtils.typography(body, small); + @include cssUtils.typography(label, small); + color: cssUtils.$text-neutral-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } &__logo { flex-shrink: 0; - width: 20px; - height: 20px; + width: 16px; + height: 16px; display: flex; align-items: center; justify-content: center; + border-radius: cssUtils.$radius-xs; + overflow: hidden; + } +} + +// Favicons stacked beside the collapsed "Sources" header. +.openui-listed-sources-favicons { + display: inline-flex; + align-items: center; + margin-inline-start: cssUtils.$space-3xs; + + &__item { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: cssUtils.$foreground; + box-shadow: 0 0 0 1.5px cssUtils.$background; border: 1px solid cssUtils.$border-default; - border-radius: cssUtils.$radius-s; box-sizing: border-box; overflow: hidden; + + & + & { + margin-inline-start: -5px; + } } } .openui-listed-sources { display: flex; flex-direction: column; - gap: cssUtils.$space-m; + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(176px, 1fr)); + gap: cssUtils.$space-xs; + } &-header { display: flex; diff --git a/packages/react-ui/src/components/SwitchItem/switchItem.scss b/packages/react-ui/src/components/SwitchItem/switchItem.scss index d931d3ab29..409f943c86 100644 --- a/packages/react-ui/src/components/SwitchItem/switchItem.scss +++ b/packages/react-ui/src/components/SwitchItem/switchItem.scss @@ -17,23 +17,22 @@ flex-shrink: 0; height: 16px; width: 24px; - margin-top: cssUtils.$space-2xs; + margin-top: 1px; cursor: pointer; border-radius: cssUtils.$radius-full; padding: cssUtils.$space-3xs; - border: 1px solid cssUtils.$border-default; - background-color: cssUtils.$sunk; + border: 1px solid transparent; + background-color: cssUtils.$highlight-strong; transition-property: background-color; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; &:not([data-disabled], [data-state="checked"]):hover { - border: 1px solid cssUtils.$border-interactive; + background-color: cssUtils.$border-interactive-emphasis; } &[data-state="checked"] { - background-color: cssUtils.$interactive-accent-default; - border: 1px solid cssUtils.$border-accent; + background-color: cssUtils.$inverted-background; } &[data-disabled] { @@ -62,16 +61,22 @@ } } +.openui-switch-item-content { + display: flex; + flex-direction: column; + min-width: 0; +} + .openui-switch-item-label { flex: 1; - @include cssUtils.typography(body, default); + @include cssUtils.typography(label, small-heavy); color: cssUtils.$text-neutral-primary; &:disabled { - color: cssUtils.$text-neutral-tertiary; + opacity: 0.5; } } .openui-switch-item-description { - @include cssUtils.typography(body, default); + @include cssUtils.typography(body, small); color: cssUtils.$text-neutral-secondary; } diff --git a/packages/react-ui/src/components/Table/Table.tsx b/packages/react-ui/src/components/Table/Table.tsx index 1235cdcac1..7815836b2c 100644 --- a/packages/react-ui/src/components/Table/Table.tsx +++ b/packages/react-ui/src/components/Table/Table.tsx @@ -1,7 +1,5 @@ import clsx from "clsx"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { IconButton } from "../IconButton"; +import React from "react"; type TableAlignment = "left" | "center" | "right"; @@ -101,130 +99,15 @@ export interface ScrollableTableProps extends React.HTMLAttributes( - ({ className, containerClassName, containerStyle, children, ...tableProps }, ref) => { - const wrapperRef = useRef(null); - const scrollContainerRef = useRef(null); - - const [isScrollable, setIsScrollable] = useState(false); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(false); - const columnLeftsRef = useRef([]); - - useEffect(() => { - const container = scrollContainerRef.current; - const wrapper = wrapperRef.current; - if (!container || !wrapper) return; - - const compute = () => { - const scrollable = container.scrollWidth > container.clientWidth; - setIsScrollable(scrollable); - setCanScrollLeft(container.scrollLeft > 0); - setCanScrollRight(container.scrollLeft < container.scrollWidth - container.clientWidth - 1); - - const ths = wrapper.querySelectorAll("thead th"); - if (!ths || ths.length === 0) { - columnLeftsRef.current = []; - return; - } - const containerRect = container.getBoundingClientRect(); - const lefts: number[] = []; - ths.forEach((th) => { - const rect = th.getBoundingClientRect(); - const left = rect.left - containerRect.left + container.scrollLeft; - lefts.push(Math.max(0, Math.round(left))); - }); - columnLeftsRef.current = Array.from(new Set(lefts)).sort((a, b) => a - b); - }; - - compute(); - - const ro = new ResizeObserver(() => compute()); - ro.observe(container); - - const onScroll = () => { - setCanScrollLeft(container.scrollLeft > 0); - setCanScrollRight(container.scrollLeft < container.scrollWidth - container.clientWidth - 1); - }; - container.addEventListener("scroll", onScroll); - - return () => { - ro.disconnect(); - container.removeEventListener("scroll", onScroll); - }; - }, [children]); - - const scrollToNextColumn = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || columnLeftsRef.current.length === 0) return; - const current = container.scrollLeft; - const target = columnLeftsRef.current.find((l) => l > current + 1); - const maxScroll = container.scrollWidth - container.clientWidth; - const next = typeof target === "number" ? target : maxScroll; - container.scrollTo({ left: Math.min(next, maxScroll), behavior: "smooth" }); - }, []); - - const scrollToPrevColumn = useCallback(() => { - const container = scrollContainerRef.current; - if (!container || columnLeftsRef.current.length === 0) return; - const current = container.scrollLeft; - const prevs = columnLeftsRef.current.filter((l) => l < current - 1); - const prev = prevs.at(-1) ?? 0; - container.scrollTo({ left: Math.max(0, prev), behavior: "smooth" }); - }, []); - - return ( -
- {isScrollable && ( - <> -
- } - /> -
-
- } - /> -
- - )} -
- {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} -
+ {headerIconForColumn(header.column)} -
+ + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
} - containerClassName={clsx("openui-scrollable-table-scroll-container", containerClassName)} - containerStyle={containerStyle} - className={className} - style={{ width: isScrollable ? "max-content" : "100%" }} - {...tableProps} - > - {children} -
-
- ); - }, + ({ containerClassName, ...tableProps }, ref) => ( + + ), ); ScrollableTable.displayName = "ScrollableTable"; diff --git a/packages/react-ui/src/components/Table/table.scss b/packages/react-ui/src/components/Table/table.scss index bd76fe9754..abc7a70037 100644 --- a/packages/react-ui/src/components/Table/table.scss +++ b/packages/react-ui/src/components/Table/table.scss @@ -10,9 +10,12 @@ border-radius: cssUtils.$radius-2xl; } +// Size to content (at least full width) so headers stay on one line; +// wider tables scroll natively in the container. .openui-table { @include cssUtils.typography(body, small); - width: 100%; + width: max-content; + min-width: 100%; caption-side: bottom; border-collapse: collapse; } @@ -34,11 +37,17 @@ color: cssUtils.$text-neutral-primary; } +// Header row is quieter than the body: 13px Medium labels (between the 12px +// and 14px tokens) and 10px vertical padding, so it reads as a label row. .openui-table-head { border-bottom: 1px solid cssUtils.$border-default; + padding-top: 10px; + padding-bottom: 10px; & .openui-table-head-label { - @include cssUtils.typography(body, small); + @include cssUtils.typography(label, extra-small-heavy); + font-size: 13px; + line-height: 16px; color: cssUtils.$text-neutral-secondary; display: inline-flex; flex-grow: 1; @@ -60,9 +69,6 @@ } .openui-table-row { - &:nth-child(even) { - background-color: cssUtils.$highlight-subtle; - } border-bottom: 1px solid cssUtils.$border-default; &:last-child { border-bottom: 0; @@ -97,43 +103,3 @@ justify-content: center; } } - -// ─── ScrollableTable ─── - -.openui-scrollable-table-wrapper { - position: relative; - width: 100%; - outline: none; -} - -.openui-scrollable-table-scroll-container { - overflow: auto; - scrollbar-width: none; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } -} - -.openui-scrollable-table-control { - position: absolute; - top: cssUtils.$space-s; - z-index: 2; - width: fit-content; - height: fit-content; - border-radius: cssUtils.$radius-s; - background-color: cssUtils.$foreground; - - &-left { - left: cssUtils.$space-s; - } - - &-right { - right: cssUtils.$space-s; - } - - &-disabled { - display: none; - } -} diff --git a/packages/react-ui/src/components/Tabs/tabs.scss b/packages/react-ui/src/components/Tabs/tabs.scss index 50dfec53e7..ef6c7824fb 100644 --- a/packages/react-ui/src/components/Tabs/tabs.scss +++ b/packages/react-ui/src/components/Tabs/tabs.scss @@ -308,3 +308,78 @@ gap: cssUtils.$space-l; } } + +// Agent (genui) Tabs: pill triggers. The selected tab is a grey pill; the +// others are ghost (no fill, secondary text) with a faint wash on hover. No +// underline indicator or bottom rule. App chrome tabs are unaffected. +.openui-tabs.openui-genui-tabs { + gap: cssUtils.$space-m; + + .openui-tabs-list-container::after, + .openui-tabs-indicator { + display: none; + } + + .openui-tabs-list { + gap: 4px; + } + + .openui-tabs-trigger { + height: 32px; + padding: 0 14px; + gap: 6px; + border: none; + border-radius: 999px; + box-shadow: none; + background-color: transparent; + color: cssUtils.$text-neutral-secondary; + @include cssUtils.typography(body, small-heavy); + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:has(.openui-tabs-trigger-icon) { + padding-left: 10px; + } + + &[data-state="active"] { + border: none; + box-shadow: none; + background-color: oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.07); + color: cssUtils.$text-neutral-primary; + @include cssUtils.typography(body, small-heavy); + } + + &:not([data-state="active"]):hover { + border: none; + box-shadow: none; + background-color: oklch(from #{cssUtils.$text-neutral-primary} l c h / 0.04); + color: cssUtils.$text-neutral-primary; + } + + // No browser focus ring. Radix focuses a tab on click in a way that + // counts as :focus-visible, so the selected tab shows focus through its + // grey fill only; keyboard focus on another tab gets a quiet neutral ring. + &:focus { + outline: none; + } + &:not([data-state="active"]):focus-visible { + outline: 2px solid cssUtils.$border-interactive-emphasis; + outline-offset: 2px; + } + } + + // Leading: an inline Icon, Avatar or Emoji, without the old tile. + .openui-tabs-trigger-icon { + width: auto; + min-width: 1.1em; + height: auto; + background: none; + + > svg { + width: 1.1em; + height: 1.1em; + stroke-width: 1.75; + } + } +} diff --git a/packages/react-ui/src/components/Tag/Tag.tsx b/packages/react-ui/src/components/Tag/Tag.tsx index c5a9e6e2f5..1091e85626 100644 --- a/packages/react-ui/src/components/Tag/Tag.tsx +++ b/packages/react-ui/src/components/Tag/Tag.tsx @@ -1,14 +1,10 @@ import clsx from "clsx"; import { CSSProperties, forwardRef, ReactNode } from "react"; +/** @deprecated Tag has a single 24px size; kept so existing callers compile. */ export type TagSize = "sm" | "md" | "lg"; -export type TagVariant = "neutral" | "info" | "success" | "warning" | "danger"; - -const sizeMap: Record = { - sm: "openui-tag-sm", - md: "openui-tag-md", - lg: "openui-tag-lg", -}; +export type TagVariant = + "neutral" | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted"; const variantMap: Record = { neutral: "openui-tag-neutral", @@ -16,6 +12,9 @@ const variantMap: Record = { success: "openui-tag-success", warning: "openui-tag-warning", danger: "openui-tag-danger", + purple: "openui-tag-purple", + pink: "openui-tag-pink", + inverted: "openui-tag-inverted", }; export interface TagProps { @@ -23,16 +22,18 @@ export interface TagProps { styles?: CSSProperties; icon?: ReactNode; text: ReactNode; + /** @deprecated Ignored: every Tag is a 24px pill. */ size?: TagSize; variant?: TagVariant; } export const Tag = forwardRef((props, ref) => { - const { className, styles, icon, text, size = "md", variant = "neutral", ...rest } = props; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { className, styles, icon, text, size: _size, variant = "neutral", ...rest } = props; return (
diff --git a/packages/react-ui/src/components/Tag/tag.scss b/packages/react-ui/src/components/Tag/tag.scss index c176e3ca77..8e9cf73032 100644 --- a/packages/react-ui/src/components/Tag/tag.scss +++ b/packages/react-ui/src/components/Tag/tag.scss @@ -1,48 +1,56 @@ @use "../../cssUtils" as cssUtils; +// One size: a 24px pill with real side padding around the label. .openui-tag { box-sizing: border-box; - display: flex; + display: inline-flex; align-items: center; overflow: hidden; max-width: 100%; - gap: cssUtils.$space-2xs; + height: 24px; + padding: 0 10px; + gap: 4px; + border-radius: 999px; @include cssUtils.typography(label, small); + // A leading icon or avatar sits a little closer to the edge. + &:has(.openui-tag-icon) { + padding-left: 6px; + } + // A 20px avatar sits 2px in from the pill's edge, concentric with its + // rounded end. + &:has(.openui-tag-icon .openui-avatar) { + padding-left: 2px; + } + &-icon { - display: flex; + display: inline-flex; align-items: center; + flex: none; & svg { - stroke-width: 2.5; + stroke-width: 2; width: 14px; height: 14px; } + // Avatars and emoji fill the pill's inner height. + & .openui-avatar { + width: 20px; + height: 20px; + } + & .openui-emoji { + font-size: 14px; + // Emoji glyphs have no side bearing, so give the label a little room. + margin-right: 2px; + } } + // overflow:hidden (for the ellipsis) clips to the line box, so the line + // must cover the font's full descent: 18px for 14px text in a 24px pill. &-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - } - - &-sm { - padding: cssUtils.$space-2xs cssUtils.$space-xs; - border-radius: cssUtils.$radius-s; - height: 24px; - } - - &-md { - padding: cssUtils.$space-xs cssUtils.$space-s; - border-radius: cssUtils.$radius-s; - gap: cssUtils.$space-xs; - height: 32px; - } - - &-lg { - padding: cssUtils.$space-s cssUtils.$space-m; - border-radius: cssUtils.$radius-m; - gap: cssUtils.$space-xs; - height: 36px; + line-height: 18px; } &-neutral { @@ -69,4 +77,20 @@ background-color: cssUtils.$danger-background; color: cssUtils.$text-danger-primary; } + + // Same palette as Avatar. + &-purple { + background-color: cssUtils.$purple-background; + color: cssUtils.$text-purple-primary; + } + + &-pink { + background-color: cssUtils.$pink-background; + color: cssUtils.$text-pink-primary; + } + + &-inverted { + background-color: cssUtils.$inverted-background; + color: cssUtils.$background; + } } diff --git a/packages/react-ui/src/components/TagBlock/tagBlock.scss b/packages/react-ui/src/components/TagBlock/tagBlock.scss index d332ed3bb7..3cbf0a56cf 100644 --- a/packages/react-ui/src/components/TagBlock/tagBlock.scss +++ b/packages/react-ui/src/components/TagBlock/tagBlock.scss @@ -1,9 +1,17 @@ @use "../../cssUtils" as cssUtils; +// Tags keep their natural width and wrap onto new lines, 6px apart both ways. +// A tag only shrinks (and ellipsizes) when it alone is wider than the block. .openui-tag-block { box-sizing: border-box; display: flex; flex-wrap: wrap; - gap: cssUtils.$space-s; + gap: 6px; max-width: 100%; + min-width: 0; + + > * { + flex: 0 0 auto; + max-width: 100%; + } } diff --git a/packages/react-ui/src/components/TextArea/textArea.scss b/packages/react-ui/src/components/TextArea/textArea.scss index 855bdb4866..ed48fcef4f 100644 --- a/packages/react-ui/src/components/TextArea/textArea.scss +++ b/packages/react-ui/src/components/TextArea/textArea.scss @@ -1,4 +1,5 @@ @use "../../cssUtils" as cssUtils; +@use "../Input/genuiField" as field; .openui-textarea { @include cssUtils.typography(body, default); @@ -44,3 +45,27 @@ border-color: cssUtils.$border-danger-emphasis; } } + +// ─── Agent (genui) text area ─── +// Same surface as the genui input, with the 14px card radius. +.openui-genui-textarea { + @include field.block; + display: block; + width: 100%; + resize: none; + + &::placeholder { + color: cssUtils.$text-neutral-secondary; + } + + &--inline { + @include field.inline; + height: auto; + padding: 0.286em 0.571em; + } + + &--error, + &--error:focus-within { + @include field.error; + } +} diff --git a/packages/react-ui/src/components/TextContentWrapper/TextContentWrapper.tsx b/packages/react-ui/src/components/TextContentWrapper/TextContentWrapper.tsx index 4b599142ac..73649f6cae 100644 --- a/packages/react-ui/src/components/TextContentWrapper/TextContentWrapper.tsx +++ b/packages/react-ui/src/components/TextContentWrapper/TextContentWrapper.tsx @@ -4,6 +4,7 @@ import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import { remarkCitations } from "../_shared/remark/remarkCitations"; +import { remarkHighlight } from "../_shared/remark/remarkHighlight"; import { TextContentCitation } from "../Citation"; import { InlineHeader } from "../InlineHeader"; import { MarkDownRenderer } from "../MarkDownRenderer"; @@ -66,6 +67,7 @@ export const TextContentWrapper = memo((props: TextContentWrapperProps) => { [remarkMath, { singleDollarTextMath: false }], [remarkBreaks, { breaks: true }], remarkCitations, + remarkHighlight, ], rehypePlugins: rehypeKatex ? [rehypeKatex] : [], components: { diff --git a/packages/react-ui/src/components/TextContentWrapper/textContentWrapper.scss b/packages/react-ui/src/components/TextContentWrapper/textContentWrapper.scss index 3666a18408..da08366b66 100644 --- a/packages/react-ui/src/components/TextContentWrapper/textContentWrapper.scss +++ b/packages/react-ui/src/components/TextContentWrapper/textContentWrapper.scss @@ -4,161 +4,6 @@ color: cssUtils.$text-neutral-primary; @include cssUtils.typography(body, default); max-width: none; - - /* Heading Styles */ - & h1, - & h2, - & h3, - & h4, - & h5, - & h6 { - @include cssUtils.typography(heading, extra-small); - color: cssUtils.$text-neutral-primary; - margin-bottom: 18px; - &:has(+ p) { - margin-bottom: 8px; - } - + strong { - @include cssUtils.typography(heading, extra-small); - } - } - /* Paragraph Styles */ - & p { - color: cssUtils.$text-neutral-primary; - @include cssUtils.typography(body, default); - margin-bottom: 0; - &:has(+ p) { - margin-bottom: 18px; - } - &:has(+ ol) { - margin-bottom: 8px; - } - &:has(+ ul) { - margin-bottom: 8px; - } - } - - & strong { - color: cssUtils.$text-neutral-primary; - @include cssUtils.typography(body, heavy); - } - - & li { - margin-bottom: 8px; - @include cssUtils.typography(body, default); - :last-child { - margin-bottom: 0; - } - &::marker { - @include cssUtils.typography(body, default); - color: cssUtils.$text-neutral-secondary; - } - } - & li ul { - margin-top: 8px; - } - - & li li { - margin-top: 8px; - } - & li ol { - margin-top: 8px; - } - & ol { - list-style-type: decimal; - padding-left: 20px; - margin-bottom: 20px; - @include cssUtils.typography(body, default); - color: cssUtils.$text-neutral-primary; - :last-child { - margin-bottom: 0; - } - } - - & ul { - list-style-type: disc; - padding-left: 20px; - margin-bottom: 24px; - @include cssUtils.typography(body, default); - color: cssUtils.$text-neutral-primary; - :last-child { - margin-bottom: 0; - } - } - - & .openui-code-block-syntax-highlighter { - padding: 25px !important; - margin-top: 8px !important; - margin-bottom: 8px !important; - - &:first-child { - margin-top: 0 !important; - } - - &:last-child { - margin-bottom: 0 !important; - } - } - - & hr { - margin-top: 48px; - margin-bottom: 48px; - + * { - margin-top: 0; - } - } - - /* Blockquote Styles */ - & blockquote { - color: cssUtils.$text-neutral-primary; - border-left: 4px solid color-mix(in oklab, cssUtils.$text-neutral-primary 70%, transparent); - padding: 0px 0 0px 8px; - font-style: italic; - margin-left: 0; - margin-top: 25px; - margin-bottom: 25px; - padding-left: 16px; - quotes: "\201C" "\201D" "\2018" "\2019"; - line-height: 1.625; - } - - & blockquote strong { - @include cssUtils.typography(body, heavy); - } - - & blockquote > *:first-child::before { - content: open-quote; - font-size: 1em; - line-height: 1; - vertical-align: top; - margin-right: 0.2em; - } - - & blockquote > *:last-child::after { - content: close-quote; - font-size: 1em; - line-height: 1.5; - vertical-align: top; - margin-left: 0.2em; - } - - & blockquote * { - font-style: inherit; - color: inherit; - line-height: inherit; - } - - & .openui-table-container { - margin-bottom: 20px; - } - - > *:first-child { - margin-top: 0; - } - - > *:last-child { - margin-bottom: 0; - } } .openui-text-content-markdown-dark-mode { diff --git a/packages/react-ui/src/components/TextWithIcon/TextWithIcon.tsx b/packages/react-ui/src/components/TextWithIcon/TextWithIcon.tsx new file mode 100644 index 0000000000..45415f6fb3 --- /dev/null +++ b/packages/react-ui/src/components/TextWithIcon/TextWithIcon.tsx @@ -0,0 +1,49 @@ +import clsx from "clsx"; +import type { CSSProperties, ReactNode } from "react"; + +export interface TextWithIconProps { + icon: ReactNode; + children: ReactNode; + /** CSS font shorthand of the text, so the icon frame takes its line height. */ + font?: string; + tone?: "primary" | "secondary"; + /** Text variant, so icon size and stroke track the text's size and weight. */ + variant?: string; + emphasis?: boolean; + className?: string; +} + +/** + * An icon in front of a line of text. The icon sits in a frame one text line + * tall (1lh of the text's own font), so it centers on the first line at any + * size. Icon size and stroke are balanced per variant so the icon reads at + * the same weight as the text beside it. + */ +export const TextWithIcon = ({ + icon, + children, + font, + tone, + variant = "default", + emphasis, + className, +}: TextWithIconProps) => ( +
+ + {icon} + +
{children}
+
+); diff --git a/packages/react-ui/src/components/TextWithIcon/index.ts b/packages/react-ui/src/components/TextWithIcon/index.ts new file mode 100644 index 0000000000..1d84a32560 --- /dev/null +++ b/packages/react-ui/src/components/TextWithIcon/index.ts @@ -0,0 +1,2 @@ +export { TextWithIcon } from "./TextWithIcon"; +export type { TextWithIconProps } from "./TextWithIcon"; diff --git a/packages/react-ui/src/components/TextWithIcon/textWithIcon.scss b/packages/react-ui/src/components/TextWithIcon/textWithIcon.scss new file mode 100644 index 0000000000..b9598fbe49 --- /dev/null +++ b/packages/react-ui/src/components/TextWithIcon/textWithIcon.scss @@ -0,0 +1,54 @@ +@use "../../cssUtils" as cssUtils; + +.openui-text-with-icon { + display: flex; + align-items: flex-start; + min-width: 0; + color: cssUtils.$text-neutral-primary; + + &__frame { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + // One line of the text's own font: centers the icon on the first line. + height: 1lh; + // Spacing in the text's own em, so small text gets a small gap. + margin-inline-end: 0.4em; + color: inherit; + + // The icon scales with the text: 1.15em for small/default, 1.1em for body + // and h3, up to 1.2em for h1 so heading icons don't look undersized. One 1.5 stroke at every size. + --icon-scale: 1.15; + svg { + width: calc(var(--icon-scale) * 1em); + height: calc(var(--icon-scale) * 1em); + stroke-width: 1.5; + } + } + + &--body &__frame { + --icon-scale: 1.1; + } + &--h3 &__frame { + --icon-scale: 1.1; + } + &--h2 &__frame { + --icon-scale: 1.15; + } + &--h1 &__frame { + --icon-scale: 1.2; + } + + &__text { + flex: 1; + min-width: 0; + } + + &--secondary { + color: cssUtils.$text-neutral-secondary; + .openui-text-with-icon__text .openui-markdown-renderer :is(p, li) { + color: cssUtils.$text-neutral-secondary; + } + } +} diff --git a/packages/react-ui/src/components/ThemeProvider/defaultTheme.ts b/packages/react-ui/src/components/ThemeProvider/defaultTheme.ts index c1bd6a4975..a83af9233d 100644 --- a/packages/react-ui/src/components/ThemeProvider/defaultTheme.ts +++ b/packages/react-ui/src/components/ThemeProvider/defaultTheme.ts @@ -335,7 +335,8 @@ const FONT_NUMBERS = '"Inter", sans-serif'; // Inter is a variable font. font-weight stays at 400 / 560 as the fallback, // and the "wght" axis is set through font-variation-settings: Regular 440 in // light mode, eased to 420 in dark mode where light-on-dark text reads -// heavier, and Medium 560. Every non-code style tracks -1%. +// heavier, Medium 560 for links and actions, and Bold 640 for headings +// (font-weight 600 as its fallback). Every non-code style tracks -1%. const TRACKING = "-0.01em"; const createTypographyTheme = ({ @@ -371,6 +372,7 @@ const createTypographyTheme = ({ fontVariationRegular: `"wght" ${regularAxis}`, fontVariationMedium: `"wght" ${medium}`, + fontVariationBold: `"wght" 640`, lineHeightBody: "1.5", lineHeightHeading: "1.25", diff --git a/packages/react-ui/src/components/ThemeProvider/types.ts b/packages/react-ui/src/components/ThemeProvider/types.ts index 993e659c4f..a27db9d80a 100644 --- a/packages/react-ui/src/components/ThemeProvider/types.ts +++ b/packages/react-ui/src/components/ThemeProvider/types.ts @@ -211,9 +211,10 @@ export interface TypographyTheme { fontWeightBold?: string; fontWeightHeavy?: string; - // Font variation settings ("wght" axis) for Regular and Medium + // Font variation settings ("wght" axis) for Regular, Medium and Bold fontVariationRegular?: string; fontVariationMedium?: string; + fontVariationBold?: string; // Line heights lineHeightBody?: string; diff --git a/packages/react-ui/src/components/Timeline/Timeline.tsx b/packages/react-ui/src/components/Timeline/Timeline.tsx new file mode 100644 index 0000000000..6e9b6930c9 --- /dev/null +++ b/packages/react-ui/src/components/Timeline/Timeline.tsx @@ -0,0 +1,154 @@ +import clsx from "clsx"; +import { CSSProperties, ReactNode, useId } from "react"; +import { SelectionCheck } from "../OptionCards/SelectionCheck"; + +/** + * done is a filled dot, current a filled dot with a halo, upcoming a hollow + * dot with a quieter title. Without a status the dot is hollow. + */ +export type TimelineItemStatus = "done" | "current" | "upcoming"; + +export interface TimelineItemData { + /** A string, or composed content such as Text, a Link or a Tag. */ + title?: ReactNode; + /** A string, or composed content such as a Metric, Tag or LeadingText. */ + subtitle?: ReactNode; + /** When the event happens, shown in the column on the left. */ + time?: ReactNode; + /** A visual in place of the dot, usually an Avatar or icon badge. */ + leading?: ReactNode; + status?: TimelineItemStatus; + /** With the check marker: a filled box and a struck-through title. */ + checked?: boolean; +} + +/** + * dot marks each item with a dot; number with ListBlock's numbered chip + * (Steps); check with a checkbox and no connector (Checklist). + */ +export type TimelineMarker = "dot" | "number" | "check"; + +export interface TimelineProps { + items: TimelineItemData[]; + marker?: TimelineMarker; + /** + * With the check marker: makes each row a checkbox. Clicking the row, or + * Space/Enter on its check, calls this with the row's index. + */ + onToggle?: (index: number) => void; + /** Blocks toggling, e.g. while the response is still streaming. */ + disabled?: boolean; + className?: string; + style?: CSSProperties; +} + +const composed = (value: ReactNode) => value != null && typeof value !== "string"; + +export const Timeline = ({ + items, + marker = "dot", + onToggle, + disabled, + className, + style, +}: TimelineProps) => { + const idBase = useId(); + const toggleable = marker === "check" && !!onToggle; + const hasTime = items.some((item) => item.time != null); + const hasLeading = items.some((item) => item.leading != null); + + return ( +
+ {items.map((item, index) => ( +
onToggle(index) : undefined} + > + {hasTime && ( +
+ {item.time} +
+ )} +
+
+ {item.leading ?? + (marker === "number" ? ( + {index + 1} + ) : marker === "check" ? ( + toggleable ? ( + // A real button so Space/Enter work; its click bubbles to + // the row, which does the toggle. + + ) : ( + + + + ) + ) : ( + + ))} +
+
+
+
+ {item.title != null && ( +
+ {item.title} +
+ )} + {item.subtitle != null && ( +
+ {item.subtitle} +
+ )} +
+
+ ))} +
+ ); +}; diff --git a/packages/react-ui/src/components/Timeline/dependencies.ts b/packages/react-ui/src/components/Timeline/dependencies.ts new file mode 100644 index 0000000000..d23798bfcc --- /dev/null +++ b/packages/react-ui/src/components/Timeline/dependencies.ts @@ -0,0 +1,4 @@ +// The number marker reuses ListItem's chip; the check marker OptionCards' +// SelectionCheck. +const dependencies = ["Timeline", "ListItem", "OptionCards"]; +export default dependencies; diff --git a/packages/react-ui/src/components/Timeline/index.ts b/packages/react-ui/src/components/Timeline/index.ts new file mode 100644 index 0000000000..23bd871b64 --- /dev/null +++ b/packages/react-ui/src/components/Timeline/index.ts @@ -0,0 +1 @@ +export * from "./Timeline"; diff --git a/packages/react-ui/src/components/Timeline/timeline.scss b/packages/react-ui/src/components/Timeline/timeline.scss new file mode 100644 index 0000000000..9aa9839d1b --- /dev/null +++ b/packages/react-ui/src/components/Timeline/timeline.scss @@ -0,0 +1,221 @@ +@use "../../cssUtils.scss" as cssUtils; + +// One grid for the whole list so every time label shares a column width. +// Items are display: contents; each row's rail cell stretches to the row's +// height so the connector runs down to the next marker. +.openui-timeline { + --openui-timeline-line: 1.5em; + --openui-timeline-rail: 1em; + box-sizing: border-box; + display: grid; + grid-template-columns: [rail] var(--openui-timeline-rail) [content] minmax(0, 1fr); + column-gap: 0.75em; + width: 100%; + + &--with-time { + grid-template-columns: + [time] max-content [rail] var(--openui-timeline-rail) + [content] minmax(0, 1fr); + } + + // Avatars, icon badges, emoji and ListBlock's number chip take a 24px slot + // instead of the dot. + &--number, + &--with-leading { + --openui-timeline-rail: 1.5em; + } + + &__item { + display: contents; + } + + &__time { + grid-column: time; + // Sized to its content rather than stretched to the row, so a Tag or + // other composed time centers on the title's first 24px line. + align-self: start; + max-width: 9em; + padding-bottom: 1.25em; + color: cssUtils.$text-neutral-secondary; + // 12px text on the title's 24px line box, so time, dot and title share + // one center line. + @include cssUtils.typography(label, extra-small); + line-height: 2em; + font-variant-numeric: tabular-nums; + text-align: right; + overflow-wrap: break-word; + + &--composed { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + align-content: flex-start; + gap: 0.375em; + min-height: 2em; + } + } + + &__rail { + grid-column: rail; + display: flex; + flex-direction: column; + align-items: center; + } + + // Centers the dot or leading visual on the title's first line. + &__marker { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 100%; + height: var(--openui-timeline-line); + + > .openui-avatar { + width: 1.5em; + height: 1.5em; + vertical-align: 0; + } + > .openui-avatar .openui-avatar__icon { + width: 50%; + height: 50%; + svg { + stroke-width: 1.75px; + } + } + > .openui-emoji { + font-size: 1.05em; + } + + // ListBlock's chip, centered in the marker rather than nudged down. + > .openui-list-item-number { + margin-top: 0; + } + } + + &__dot { + width: 0.625em; + height: 0.625em; + box-sizing: border-box; + border-radius: 999px; + border: 1.5px solid cssUtils.$text-neutral-secondary; + background-color: transparent; + } + + // Dashed, the same as EntityList's row rules and the tool timeline. + &__connector { + flex: 1; + width: 0; + min-height: 0.75em; + margin-block: 0.25em; + border-inline-start: 1px dashed cssUtils.$border-interactive; + } + + &__item:last-child &__connector { + visibility: hidden; + } + + &__content { + grid-column: content; + display: flex; + flex-direction: column; + min-width: 0; + padding-bottom: 1.25em; + } + + &__item:last-child &__content, + &__item:last-child &__time { + padding-bottom: 0; + } + + &__title { + min-width: 0; + color: cssUtils.$text-neutral-primary; + @include cssUtils.typography(body, default); + } + + &__subtitle { + color: cssUtils.$text-neutral-secondary; + @include cssUtils.typography(body, default); + } + + &__title--composed, + &__subtitle--composed { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375em; + min-height: var(--openui-timeline-line); + } + + // Checklist: a checkbox instead of the dot, no connector, tighter rows. + // A checked item fills the box and strikes its title through. + &--check { + --openui-timeline-rail: 1.5em; + } + + &--check &__connector { + display: none; + } + + &--check &__content { + padding-bottom: 0.75em; + } + + // The check button is only a focus target around the shared 18px + // SelectionCheck (OptionCards, Chips); the whole row is the hit area. + &__check-button { + @include cssUtils.button-reset; + display: inline-flex; + border-radius: 999px; + cursor: inherit; + + &:focus-visible { + outline: 1.5px solid cssUtils.$text-neutral-primary; + outline-offset: 2px; + } + } + + &__item--toggleable &__rail, + &__item--toggleable &__content { + cursor: pointer; + } + + &__item--toggleable:hover .openui-selection-check:not(.openui-selection-check--checked) { + border-color: cssUtils.$text-neutral-primary; + } + + &__item--disabled &__rail, + &__item--disabled &__content { + cursor: default; + } + + &--check &__title { + transition: color 0.16s ease-out; + + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + &__item--checked &__title { + color: cssUtils.$text-neutral-secondary; + text-decoration: line-through; + } + + // Status: done fills the dot, current adds a halo, upcoming quiets the title. + &__item--done &__dot, + &__item--current &__dot { + border-color: cssUtils.$text-neutral-primary; + background-color: cssUtils.$text-neutral-primary; + } + + &__item--current &__dot { + box-shadow: 0 0 0 0.1875em cssUtils.$sunk; + } + + &__item--upcoming &__title { + color: cssUtils.$text-neutral-secondary; + } +} diff --git a/packages/react-ui/src/components/SnippetCardBlock/SnippetCardBlock.tsx b/packages/react-ui/src/components/ValueCardBlock/ValueCardBlock.tsx similarity index 77% rename from packages/react-ui/src/components/SnippetCardBlock/SnippetCardBlock.tsx rename to packages/react-ui/src/components/ValueCardBlock/ValueCardBlock.tsx index 0f3a9fc85c..207405a504 100644 --- a/packages/react-ui/src/components/SnippetCardBlock/SnippetCardBlock.tsx +++ b/packages/react-ui/src/components/ValueCardBlock/ValueCardBlock.tsx @@ -1,42 +1,41 @@ import clsx from "clsx"; -import { ChevronRight } from "lucide-react"; import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; import { CardBlockLayout, cardKeyDownHandler } from "../_shared/cards/CardBlockLayout"; import { TooltipWrapper } from "../TooltipWrapper"; -export interface SnippetCardTooltip { +export interface ValueCardTooltip { heading?: string; content?: string; } -export interface SnippetCardBlockItem { +export interface ValueCardBlockItem { id?: string; /** Left-hand content (typically an IconText / ImageText). */ lhs: ReactNode; /** Right-hand value content (typically a Text / BoldText). Chevron is shown instead when clickable and absent. */ rhs?: ReactNode; /** Tooltip shown when the lhs text is truncated. */ - lhsTooltip?: SnippetCardTooltip; + lhsTooltip?: ValueCardTooltip; /** Tooltip shown when the rhs text is truncated. */ - rhsTooltip?: SnippetCardTooltip; + rhsTooltip?: ValueCardTooltip; } -export interface SnippetCardBlockProps extends Omit, "onClick"> { - items: SnippetCardBlockItem[]; +export interface ValueCardBlockProps extends Omit, "onClick"> { + items: ValueCardBlockItem[]; /** Collapses the grid to 2 / 1 columns on narrow containers. */ responsive?: boolean; /** Overrides the gap between cards (px number or any CSS length). */ gap?: number | string; /** Renders the cards as buttons and fires `onItemClick`. */ clickable?: boolean; - onItemClick?: (item: SnippetCardBlockItem, index: number) => void; + onItemClick?: (item: ValueCardBlockItem, index: number) => void; className?: string; } const TEXT_BLOCK_HEADING_SELECTOR = ".openui-text-block__primary"; const TEXT_BLOCK_CONTENT_SELECTOR = ".openui-text-block__secondary"; -const SnippetCardBlock = forwardRef((props, ref) => { +const ValueCardBlock = forwardRef((props, ref) => { const { items, responsive = true, @@ -54,14 +53,14 @@ const SnippetCardBlock = forwardRef((prop ref={ref} size="small" cardType="value-card" - data-card-type="SnippetCard" + data-card-type="ValueCard" items={items ?? []} layout="grid" responsive={responsive} maxPerRow={2} gap={gap} className={className} - itemKey={(item, index) => item.id ?? `snippet-card-${index}`} + itemKey={(item, index) => item.id ?? `value-card-${index}`} renderItem={(item, index) => (
((prop >
{item.rhs}
- ) : ( - isClickable && ( - - ) - )} + ) : null}
)} @@ -111,6 +104,6 @@ const SnippetCardBlock = forwardRef((prop ); }); -SnippetCardBlock.displayName = "SnippetCardBlock"; +ValueCardBlock.displayName = "ValueCardBlock"; -export { SnippetCardBlock }; +export { ValueCardBlock }; diff --git a/packages/react-ui/src/components/ValueCardBlock/index.ts b/packages/react-ui/src/components/ValueCardBlock/index.ts new file mode 100644 index 0000000000..9a84cde23c --- /dev/null +++ b/packages/react-ui/src/components/ValueCardBlock/index.ts @@ -0,0 +1 @@ +export * from "./ValueCardBlock"; diff --git a/packages/react-ui/src/components/SnippetCardBlock/snippetCardBlock.scss b/packages/react-ui/src/components/ValueCardBlock/valueCardBlock.scss similarity index 68% rename from packages/react-ui/src/components/SnippetCardBlock/snippetCardBlock.scss rename to packages/react-ui/src/components/ValueCardBlock/valueCardBlock.scss index 410269ff9f..8687393045 100644 --- a/packages/react-ui/src/components/SnippetCardBlock/snippetCardBlock.scss +++ b/packages/react-ui/src/components/ValueCardBlock/valueCardBlock.scss @@ -1,17 +1,26 @@ @use "../../cssUtils" as cssUtils; +@use "../_shared/cards/clickable"; .openui-value-card { display: flex; + // Horizontal is always one row: the value never drops below the label, + // so every card in a block keeps the same direction. A long value wraps + // inside its own column instead. + flex-wrap: nowrap; align-items: center; justify-content: space-between; - gap: cssUtils.$space-m; + gap: 6px cssUtils.$space-m; width: 100%; min-width: 0; - padding: cssUtils.$space-s-m; + padding: 12px; + // Text on the right needs more inset than the round leading on the left + // to look equally spaced. + padding-right: 18px; box-sizing: border-box; - border: 1px solid cssUtils.$border-default; - border-radius: cssUtils.$radius-2xl; - background-color: cssUtils.$sunk-light; + // No outline: the sunk tint on our 14px radius sets the card apart. + border: none; + border-radius: 14px; + background-color: cssUtils.$sunk; position: relative; overflow: hidden; transition: @@ -21,34 +30,21 @@ transform 0.2s ease; &--static { - background-color: cssUtils.$sunk-light; - border-color: cssUtils.$border-default; box-shadow: none; } + // Clickable cards keep the sunk tint and gain the hover wash and lift. &--clickable { - background-color: cssUtils.$foreground; - border-color: cssUtils.$border-interactive; - box-shadow: cssUtils.$shadow-s; - cursor: pointer; - - &:hover { - box-shadow: none; - transform: scale(0.995); - } - - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; - } + @include clickable.clickable-card; } } .openui-value-card__lhs { display: flex; align-items: center; - min-width: 0; - flex: 1; + // Never narrower than a short label's words. + min-width: min(8em, 100%); + flex: 1 1 8em; } .openui-value-card__lhs > * { @@ -56,12 +52,14 @@ } .openui-value-card__rhs { - flex-shrink: 1; + flex: 0 1 auto; display: flex; align-items: center; justify-content: flex-end; + text-align: right; min-width: 0; - margin-left: cssUtils.$space-m; + max-width: 100%; + margin-left: auto; color: cssUtils.$text-neutral-primary; @include cssUtils.typography(number, large-heavy); } @@ -121,3 +119,8 @@ align-items: center; justify-content: center; } + +// A value made of several pieces wraps within its own column, right-aligned. +.openui-value-card__rhs .openui-inline-group { + justify-content: flex-end; +} diff --git a/packages/react-ui/src/components/ValueLayout/valueLayout.scss b/packages/react-ui/src/components/ValueLayout/valueLayout.scss new file mode 100644 index 0000000000..16736da2dd --- /dev/null +++ b/packages/react-ui/src/components/ValueLayout/valueLayout.scss @@ -0,0 +1,62 @@ +@use "../../cssUtils" as cssUtils; + +// ValueLayout: ValueCard items without the tinted card, split by the same +// dashed rules as EntityList and the tool timeline. Cells have no side +// padding, so the outer items sit flush with surrounding content and nothing +// extends past the container. Columns are spaced by a gap, and each cell +// draws its left rule in the middle of that gap and a top rule across its +// own width, so rules never meet at the corners. The grid clips the rules on +// its outer edge (left of the first column, above the first row), which +// works at any column count, including the responsive reflow. +$rule: 1px dashed cssUtils.$border-interactive; +$rule-inset: 12px; +$column-gap: 24px; + +.openui-value-layout { + .openui-small-card-block__grid, + .openui-small-card-block__row, + .openui-small-card-block__carousel-track { + gap: 0 $column-gap; + } + + .openui-small-card-block__grid, + .openui-small-card-block__carousel-track { + // Clip only the hidden outer rules; a couple of pixels of side room keep + // avatar outlines on the outer edge intact. + clip-path: inset(0 -2px); + } + + .openui-small-card-block__item, + .openui-small-card-block__carousel-item { + position: relative; + + &::after { + content: ""; + position: absolute; + left: calc($column-gap / -2); + top: $rule-inset; + bottom: $rule-inset; + border-left: $rule; + pointer-events: none; + } + } + + .openui-small-card-block__item::before { + content: ""; + position: absolute; + top: -1px; + left: 0; + right: 0; + border-top: $rule; + pointer-events: none; + } + + // No fill and no side padding. ValueLayout has no actions, so its cells + // never hover. + .openui-value-card, + .openui-overview-card { + background-color: transparent; + padding-left: 0; + padding-right: 0; + } +} diff --git a/packages/react-ui/src/components/VisualCardBlock/VisualCardBlock.tsx b/packages/react-ui/src/components/VisualCardBlock/VisualCardBlock.tsx index 74195755c7..e5de890d7d 100644 --- a/packages/react-ui/src/components/VisualCardBlock/VisualCardBlock.tsx +++ b/packages/react-ui/src/components/VisualCardBlock/VisualCardBlock.tsx @@ -1,5 +1,4 @@ import clsx from "clsx"; -import { ChevronRight } from "lucide-react"; import { forwardRef, type CSSProperties, type ReactNode } from "react"; import { CardBlockLayout, cardKeyDownHandler } from "../_shared/cards/CardBlockLayout"; import { toCssUrl } from "../_shared/utils"; @@ -48,11 +47,6 @@ export const VisualCard = forwardRef((props, re >
{tag ?? null}
- {clickable && ( - - )}
{body ?
{body}
: null}
diff --git a/packages/react-ui/src/components/VisualCardBlock/visualCardBlock.scss b/packages/react-ui/src/components/VisualCardBlock/visualCardBlock.scss index ad8eecab4e..d441a5ad26 100644 --- a/packages/react-ui/src/components/VisualCardBlock/visualCardBlock.scss +++ b/packages/react-ui/src/components/VisualCardBlock/visualCardBlock.scss @@ -1,5 +1,6 @@ @use "../../cssUtils" as cssUtils; @use "../_shared/chatUtils" as chatUtils; +@use "../_shared/cards/clickable"; // Photo + gradient sit behind content via pseudo-elements; no extra DOM for the image. .openui-visual-first-card { @@ -43,19 +44,8 @@ } &--clickable { - cursor: pointer; - box-shadow: cssUtils.$shadow-s; border-color: cssUtils.$border-interactive; - - &:hover { - box-shadow: none; - transform: scale(0.995); - } - - &:focus-visible { - outline: 2px solid cssUtils.$border-accent; - outline-offset: 2px; - } + @include clickable.clickable-card; } } diff --git a/packages/react-ui/src/components/_shared/DisclosureSection.tsx b/packages/react-ui/src/components/_shared/DisclosureSection.tsx new file mode 100644 index 0000000000..3ab7a3e4cb --- /dev/null +++ b/packages/react-ui/src/components/_shared/DisclosureSection.tsx @@ -0,0 +1,72 @@ +import clsx from "clsx"; +import { ChevronRight } from "lucide-react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; + +/** + * Collapsed-by-default section for the blocks that close a reply (Related + * queries, Sources): a small Medium label with a count and a chevron, over a + * panel that opens to its content's height. + */ +export function DisclosureSection({ + label, + count, + preview, + defaultOpen = false, + className, + children, +}: { + label: string; + count?: number; + /** Shown next to the count while collapsed, e.g. stacked favicons. */ + preview?: ReactNode; + defaultOpen?: boolean; + className?: string; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const panelId = useId(); + const panelRef = useRef(null); + + // A closed panel stays in the DOM for the height animation, so take it out + // of the tab order and the accessibility tree. + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + if (open) panel.removeAttribute("inert"); + else panel.setAttribute("inert", ""); + }, [open]); + + return ( +
+ +
+
+
{children}
+
+
+
+ ); +} diff --git a/packages/react-ui/src/components/_shared/_bleed.scss b/packages/react-ui/src/components/_shared/_bleed.scss new file mode 100644 index 0000000000..59c3b04554 --- /dev/null +++ b/packages/react-ui/src/components/_shared/_bleed.scss @@ -0,0 +1,13 @@ +// Breakout shared by Shell and Bleed: wider than the chat column, growing +// into the gutters on both sides up to --openui-bleed-max-width, keeping 24px +// from the edge of the nearest size container (the viewport when the host +// declares none). Never narrower than its column. +@mixin breakout { + --openui-bleed-width: max(100%, min(var(--openui-bleed-max-width, 1120px), calc(100cqw - 48px))); + width: var(--openui-bleed-width); + margin-inline: calc((100% - var(--openui-bleed-width)) / 2); + // Always leaves room before whatever follows, even as the last block. + margin-bottom: 24px; + min-width: 0; + box-sizing: border-box; +} diff --git a/packages/react-ui/src/components/_shared/_disclosureSection.scss b/packages/react-ui/src/components/_shared/_disclosureSection.scss new file mode 100644 index 0000000000..6a5a06add6 --- /dev/null +++ b/packages/react-ui/src/components/_shared/_disclosureSection.scss @@ -0,0 +1,90 @@ +@use "../../cssUtils" as cssUtils; + +// Collapsed section that closes a reply (Related queries, Sources). +.openui-disclosure-section { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + + &__trigger { + @include cssUtils.button-reset; + @include cssUtils.typography(label, small-heavy); + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: cssUtils.$space-xs; + min-height: 28px; + padding: cssUtils.$space-3xs cssUtils.$space-xs cssUtils.$space-3xs cssUtils.$space-2xs; + margin-inline-start: calc(-1 * #{cssUtils.$space-2xs}); + border-radius: cssUtils.$radius-m; + color: cssUtils.$text-neutral-secondary; + cursor: pointer; + transition: + background-color 0.12s ease, + color 0.12s ease; + + &:hover { + background-color: cssUtils.$highlight-subtle; + color: cssUtils.$text-neutral-primary; + } + &:focus { + outline: none; + } + &:focus-visible { + outline: 2px solid cssUtils.$border-interactive-emphasis; + outline-offset: 1px; + } + } + + &__chevron { + flex-shrink: 0; + color: cssUtils.$text-neutral-tertiary; + transition: transform 0.16s ease; + } + &--open &__chevron { + transform: rotate(90deg); + } + + &__count { + @include cssUtils.typography(label, small); + color: cssUtils.$text-neutral-tertiary; + font-variant-numeric: tabular-nums; + } + + &__preview { + display: inline-flex; + align-items: center; + } + + // Opens to the content's height (grid rows 0fr → 1fr). + &__panel { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 0.18s ease, + opacity 0.14s ease; + } + &--open &__panel { + grid-template-rows: 1fr; + opacity: 1; + } + + &__panel-inner { + min-height: 0; + overflow: hidden; + } + // Inside the clipped wrapper, so the gap folds away with the content. + &__content { + padding-top: cssUtils.$space-xs; + } + + @media (prefers-reduced-motion: reduce) { + &__trigger, + &__chevron, + &__panel { + transition: none; + } + } +} diff --git a/packages/react-ui/src/components/_shared/cards/CardBlockLayout.tsx b/packages/react-ui/src/components/_shared/cards/CardBlockLayout.tsx index 117b215743..e118591ebd 100644 --- a/packages/react-ui/src/components/_shared/cards/CardBlockLayout.tsx +++ b/packages/react-ui/src/components/_shared/cards/CardBlockLayout.tsx @@ -1,12 +1,16 @@ import clsx from "clsx"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import { forwardRef, + useCallback, + useRef, type CSSProperties, type ForwardedRef, type HTMLAttributes, type KeyboardEvent, type ReactNode, } from "react"; +import { IconButton } from "../../IconButton"; import { getRowConfiguration, useCarouselMask } from "./smallCardBlockUtils"; export type CardBlockLayoutVariant = "grid" | "carousel"; @@ -48,6 +52,20 @@ function CardBlockLayoutInner( ...rest } = props; const { scrollRef, maskLeft, maskRight } = useCarouselMask(); + // The arrows scroll by most of a view; they show only on the side that + // still has cards out of view, like the Carousel's. + const trackRef = useRef(null); + const setScrollEl = useCallback( + (el: HTMLDivElement | null) => { + trackRef.current = el; + scrollRef(el); + }, + [scrollRef], + ); + const scrollByView = (direction: 1 | -1) => { + const el = trackRef.current; + if (el) el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: "smooth" }); + }; const safeItems = items ?? []; const base = `openui-${size}-card-block`; const count = safeItems.length; @@ -81,7 +99,7 @@ function CardBlockLayoutInner( > {layout === "carousel" ? (
( {safeItems.map((item, index) => renderCell(item, index))}
- ) : ( + ) : null} + {layout === "carousel" && maskLeft && ( +
+ } + shape="circle" + variant="secondary" + size="small" + aria-label="Scroll left" + onClick={() => scrollByView(-1)} + /> +
+ )} + {layout === "carousel" && maskRight && ( +
+ } + shape="circle" + variant="secondary" + size="small" + aria-label="Scroll right" + onClick={() => scrollByView(1)} + /> +
+ )} + {layout === "carousel" ? null : (
(); +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 && ( + + {keys.map((key, i) => { + const color = colors[i % colors.length] ?? "currentColor"; + const px = (_: Row, index: number) => x(index) ?? 0; + const py = (row: Row) => y(Number(row[key]) || 0); + const lineD = line().x(px).y(py).curve(curve)(data) ?? ""; + const fillId = `${gradientId}-${i}`; + return ( + + {kind === "area" && ( + <> + + + + + + + ().x(px).y0(height).y1(py).curve(curve)(data) ?? ""} + fill={`url(#${fillId})`} + /> + + )} + + + ); + })} + + )} +
+ ); +} 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 ( +
+ ({ + title: cell(item?.props?.title), + subtitle: cell(item?.props?.subtitle), + checked: isChecked(item, i), + }))} + /> +
+ ); + }, +}); diff --git a/packages/react-ui/src/genui-lib/Checklist/schema.ts b/packages/react-ui/src/genui-lib/Checklist/schema.ts new file mode 100644 index 0000000000..256a0269ac --- /dev/null +++ b/packages/react-ui/src/genui-lib/Checklist/schema.ts @@ -0,0 +1,32 @@ +import { reactive } from "@openuidev/react-lang"; +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 ChecklistItemSchema = z.object({ + 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(), + checked: z.boolean().optional(), + value: z.string().optional(), +}); + +export function createChecklistSchema(ChecklistItem: { ref: any }) { + return z.object({ + items: z.array(ChecklistItem.ref), + name: z.string().optional(), + value: reactive(z.record(z.string(), z.boolean()).optional()), + }); +} diff --git a/packages/react-ui/src/genui-lib/Chips/index.tsx b/packages/react-ui/src/genui-lib/Chips/index.tsx index d51ce43ed2..2c40265bfb 100644 --- a/packages/react-ui/src/genui-lib/Chips/index.tsx +++ b/packages/react-ui/src/genui-lib/Chips/index.tsx @@ -101,7 +101,7 @@ function ChipsRenderer({ props, renderNode }: ComponentRenderProps) items={items.map((item) => ({ value: item.props.value, label: item.props.label, - icon: item.props.icon ? renderNode(item.props.icon) : undefined, + leading: item.props.leading ? renderNode(item.props.leading) : undefined, disabled: item.props.disabled, }))} /> @@ -112,6 +112,6 @@ export const Chips = defineComponent({ name: "Chips", props: ChipsSchema, description: - "A form field of compact selectable chips for choosing one or many short options; the selection is stored under `name`.", + 'A form field of compact selectable pills, inside a FormControl, for choosing one or many short options: Chips(name, "single" | "multiple", [ChipItem(...), ...], rules?, defaultValue?). A selected chip shows a checkmark at the end. Use it for short labels (1–3 words) such as tags, amenities or tools; for choices that need a description use OptionCards. The selection is stored under `name`.', component: ChipsRenderer, }); diff --git a/packages/react-ui/src/genui-lib/Chips/schema.ts b/packages/react-ui/src/genui-lib/Chips/schema.ts index d8297b747c..c5df567910 100644 --- a/packages/react-ui/src/genui-lib/Chips/schema.ts +++ b/packages/react-ui/src/genui-lib/Chips/schema.ts @@ -1,12 +1,14 @@ import { defineComponent } from "@openuidev/react-lang"; import { z } from "zod/v4"; +import { Avatar } from "../Avatar"; +import { Emoji } from "../Emoji"; import { Icon } from "../Icon"; import { rulesSchema } from "../rules"; export const ChipItemSchema = z.object({ value: z.string(), label: z.string(), - icon: z.optional(Icon.ref), + leading: z.optional(z.union([Icon.ref, Avatar.ref, Emoji.ref])), disabled: z.boolean().optional(), }); @@ -16,7 +18,7 @@ export const ChipItem = defineComponent({ name: "ChipItem", props: ChipItemSchema, description: - "A single selectable chip inside a Chips group, with a value, label and optional icon.", + 'One selectable pill: ChipItem(value, label, leading?, disabled?), e.g. ChipItem("hotel", "Hotel", Icon("building", "buildings")). label is 1–3 words; leading is an optional Icon, an Avatar for a person or company, or a rare Emoji. Give every chip a leading or none of them.', component: () => null, }); diff --git a/packages/react-ui/src/genui-lib/CompositeCardBlock/index.tsx b/packages/react-ui/src/genui-lib/CompositeCardBlock/index.tsx index 7b3b90d865..46159a6045 100644 --- a/packages/react-ui/src/genui-lib/CompositeCardBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/CompositeCardBlock/index.tsx @@ -10,14 +10,12 @@ import { } from "@openuidev/react-lang"; import { Fragment, type ReactNode } from "react"; import { CompositeCardBlock as OpenUICompositeCardBlock } from "../../components/CompositeCardBlock"; -import { BoldText } from "../BoldText"; -import { Button } from "../Button"; import { withItemContext } from "../cardActionUtils"; import { AreaChartCondensed, BarChartCondensed, LineChartCondensed } from "../Charts"; import { EntityList } from "../EntityList"; +import { IconButton } from "../IconButton"; import { ListBlock } from "../ListBlock"; -import { MetricIndicatorInline, MetricIndicatorWithStrikethrough } from "../MetricIndicator"; -import { TagBlock } from "../TagBlock"; +import { Metric } from "../Metric"; import { Text } from "../Text"; import { CompositeCardBlockSchema, @@ -31,6 +29,8 @@ type CompositeCardItemElement = SubComponentOf; type ElementNode = SubComponentOf>; const COMPOSITE_CARD_CHART_HEIGHT = 180; +const COMPOSITE_CARD_MEDIA_CHART_HEIGHT = 140; +const CHART_NAMES = [AreaChartCondensed.name, BarChartCondensed.name, LineChartCondensed.name]; function isElementOf(node: unknown, typeNames: string[]): node is ElementNode { return ( @@ -50,29 +50,40 @@ function renderCompositeCardNode( node: unknown, renderNode: (value: unknown) => ReactNode, key: string, - location: "body" | "footer", + location: "body" | "footer" | "price", ) { let value = node; - if (location === "body" && isElementOf(node, [Text.name, BoldText.name])) { - value = withProps(node, { size: "xs" }); + if (location === "body" && isElementOf(node, [Text.name])) { + // Headings keep their size; other Text reads as 14px body copy, + // secondary unless emphasized (see the scss). + const variant = node.props["variant"] as string | undefined; + if (variant === "h1" || variant === "h2" || variant === "h3") { + return {renderNode(node)}; + } + return ( +
+ {renderNode(withProps(node, { variant: "default" }))} +
+ ); } 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 ( +
+
+ {renderNode(fields)} +
+
+ ); + }, +}); diff --git a/packages/react-ui/src/genui-lib/Form/index.tsx b/packages/react-ui/src/genui-lib/Form/index.tsx index 36e3d04b54..75c4ab5089 100644 --- a/packages/react-ui/src/genui-lib/Form/index.tsx +++ b/packages/react-ui/src/genui-lib/Form/index.tsx @@ -9,6 +9,7 @@ import { } from "@openuidev/react-lang"; import { FormSchema } from "./schema"; +export { FormRow, FormRowSchema } from "./FormRow"; export { FormSchema } from "./schema"; type FormRenderProps = ComponentRenderProps<{ name: string; buttons: unknown; fields: unknown }>; @@ -21,7 +22,11 @@ export const FormRenderer = ({ props, renderNode }: FormRenderProps) => { return ( -
+
{renderNode(props.fields)} {renderNode(props.buttons)}
@@ -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} - /> +
+ {Icon && } + 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} + /> +
); }, }); diff --git a/packages/react-ui/src/genui-lib/Input/schema.ts b/packages/react-ui/src/genui-lib/Input/schema.ts index a722e3c872..c3f86feddf 100644 --- a/packages/react-ui/src/genui-lib/Input/schema.ts +++ b/packages/react-ui/src/genui-lib/Input/schema.ts @@ -5,7 +5,8 @@ import { rulesSchema } from "../rules"; export const InputSchema = z.object({ name: z.string(), placeholder: z.string().optional(), - type: z.enum(["text", "email", "password", "number", "url"]).optional(), + type: z.enum(["text", "email", "password", "number", "url", "search", "tel"]).optional(), rules: rulesSchema, value: reactive(z.string().optional()), + variant: z.enum(["default", "inline"]).optional(), }); diff --git a/packages/react-ui/src/genui-lib/InsightCard/index.tsx b/packages/react-ui/src/genui-lib/InsightCard/index.tsx new file mode 100644 index 0000000000..622b8dbc0d --- /dev/null +++ b/packages/react-ui/src/genui-lib/InsightCard/index.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { SubComponentOf } from "@openuidev/react-lang"; +import { + ComponentRenderProps, + defineComponent, + useFormName, + useIsStreaming, + useTriggerAction, +} from "@openuidev/react-lang"; +import type { ReactNode } from "react"; +import { ContextCardBlock as OpenUIContextCardBlock } from "../../components/ContextCardBlock"; +import { withItemContext } from "../cardActionUtils"; +import { renderIconSlot } from "../iconSlot"; +import { renderOneOrMany } from "../oneOrMany"; +import { InsightCardSchema, type InsightCardItemProps, type InsightCardProps } from "./schema"; + +export * from "./schema"; + +type InsightCardItemElement = SubComponentOf; + +// Top: the overline with its optional inline leading. +export function insightOverline(item: { + props: { overline?: string | null; leading?: unknown }; +}): ReactNode { + const { overline, leading } = item.props; + if (!overline && !leading) return undefined; + return ( +
+ {leading ? ( + {renderIconSlot(leading)} + ) : null} + {overline ? {overline} : null} +
+ ); +} + +// 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 ( +
+ +
+ ); + }, }); diff --git a/packages/react-ui/src/genui-lib/Text/schema.ts b/packages/react-ui/src/genui-lib/Text/schema.ts index 40cfc0e4e0..0747ad273e 100644 --- a/packages/react-ui/src/genui-lib/Text/schema.ts +++ b/packages/react-ui/src/genui-lib/Text/schema.ts @@ -1,10 +1,7 @@ import { z } from "zod/v4"; export const TextSchema = 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"), + text: z.string(), + variant: z.enum(["small", "default", "body", "h1", "h2", "h3"]).optional(), + emphasis: z.boolean().optional(), }); -export type TextProps = z.infer; diff --git a/packages/react-ui/src/genui-lib/TextArea/index.tsx b/packages/react-ui/src/genui-lib/TextArea/index.tsx index 758b430050..3a72351166 100644 --- a/packages/react-ui/src/genui-lib/TextArea/index.tsx +++ b/packages/react-ui/src/genui-lib/TextArea/index.tsx @@ -7,8 +7,8 @@ import { useIsStreaming, useStateField, } from "@openuidev/react-lang"; +import clsx from "clsx"; import React from "react"; -import { TextArea as OpenUITextArea } from "../../components/TextArea"; import { TextAreaSchema } from "./schema"; export { TextAreaSchema } from "./schema"; @@ -16,7 +16,8 @@ export { TextAreaSchema } from "./schema"; export const TextArea = defineComponent({ name: "TextArea", props: TextAreaSchema, - description: "", + description: + 'A multi-line text field inside a FormControl, for free-form notes or messages: TextArea(name, placeholder?, rows?, rules?, value?, variant?), e.g. TextArea("notes", "Dietary needs, accessibility, special requests…", 4). rows sets the visible height; 3–5 is typical. Never require it when leaving it blank is a valid answer. variant "inline" is borderless until hover or focus, for table cells and dense rows; use "default" everywhere else. For a one-line answer use Input.', component: ({ props }) => { const isStreaming = useIsStreaming(); const formValidation = useFormValidation(); @@ -34,10 +35,14 @@ export const TextArea = defineComponent({ }, [field.name, field.value, formValidation, hasRules, isStreaming, rules]); return ( - formValidation?.clearFieldError(field.name)} onChange={(e: React.ChangeEvent) => { diff --git a/packages/react-ui/src/genui-lib/TextArea/schema.ts b/packages/react-ui/src/genui-lib/TextArea/schema.ts index 5815a79b63..48316de19d 100644 --- a/packages/react-ui/src/genui-lib/TextArea/schema.ts +++ b/packages/react-ui/src/genui-lib/TextArea/schema.ts @@ -8,4 +8,5 @@ export const TextAreaSchema = z.object({ rows: z.number().optional(), rules: rulesSchema, value: reactive(z.string().optional()), + variant: z.enum(["default", "inline"]).optional(), }); diff --git a/packages/react-ui/src/genui-lib/TextContent/index.tsx b/packages/react-ui/src/genui-lib/TextContent/index.tsx deleted file mode 100644 index 1c3385dde1..0000000000 --- a/packages/react-ui/src/genui-lib/TextContent/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { defineComponent } from "@openuidev/react-lang"; -import React from "react"; -import { TextContentWrapper } from "../../components/TextContentWrapper"; -import { TextContentSchema } from "./schema"; -const BODY_SIZE_VARS: Record = { - small: "--openui-text-body-sm", - default: "--openui-text-body-default", - large: "--openui-text-body-lg", - "small-heavy": "--openui-text-body-sm-heavy", - "large-heavy": "--openui-text-body-lg-heavy", -}; - -export { TextContentSchema } from "./schema"; - -export const TextContent = defineComponent({ - name: "TextContent", - props: TextContentSchema, - description: - 'Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy".', - component: ({ props }) => { - const size = (props.size as string) ?? "default"; - const varName = BODY_SIZE_VARS[size] ?? BODY_SIZE_VARS["default"]; - const style = - size === "default" - ? undefined - : ({ - "--openui-text-body-default": `var(${varName})`, - "--openui-text-body-default-letter-spacing": `var(${varName}-letter-spacing)`, - } as React.CSSProperties); - const text = props.text == null ? "" : String(props.text); - return ( -
- -
- ); - }, -}); diff --git a/packages/react-ui/src/genui-lib/TextContent/schema.ts b/packages/react-ui/src/genui-lib/TextContent/schema.ts deleted file mode 100644 index ad36067cf1..0000000000 --- a/packages/react-ui/src/genui-lib/TextContent/schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod/v4"; - -export const TextContentSchema = z.object({ - text: z.string(), - size: z.enum(["small", "default", "large", "small-heavy", "large-heavy"]).optional(), -}); diff --git a/packages/react-ui/src/genui-lib/Timeline/index.tsx b/packages/react-ui/src/genui-lib/Timeline/index.tsx new file mode 100644 index 0000000000..80058cc8e6 --- /dev/null +++ b/packages/react-ui/src/genui-lib/Timeline/index.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { defineComponent } from "@openuidev/react-lang"; +import { z } from "zod/v4"; +import { Timeline as OpenUITimeline, type TimelineItemStatus } from "../../components/Timeline"; +import { renderIconSlot } from "../iconSlot"; +import { renderOneOrMany } from "../oneOrMany"; +import { TimelineItemSchema } from "./schema"; + +export { TimelineItemSchema } from "./schema"; + +type Node = { typeName?: string; props?: { name?: string } } | undefined; + +// A bare Icon leads as a neutral Avatar badge, like ListItem. +const leadingNode = (node: Node) => + node?.typeName === "Icon" && node.props?.name + ? renderIconSlot({ typeName: "Avatar", props: { name: node.props.name, icon: node } }) + : renderIconSlot(node); + +export const TimelineItem = defineComponent({ + name: "TimelineItem", + props: TimelineItemSchema, + description: + 'One event in a Timeline: TimelineItem(title, subtitle?, time?, leading?, status?). title is a string, or a Text, Link or Tag; emphasize a named event with Text("Doors open", "body", true). subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. time is when it happens, shown in a column on the left: a short string such as "9:00 AM", "Day 2" or "Mar 2024", or a Text or Tag. leading is an optional Avatar, Icon or Emoji that replaces the dot, e.g. Avatar("Priya Shah", null, null, null, "inline", Icon("user", "people")) for who acted. status "done" fills the dot, "current" highlights it, "upcoming" quiets the title; leave it out for a plain history. When title or subtitle needs two or more pieces side by side, pass an array.', + component: () => null, +}); + +export const Timeline = defineComponent({ + name: "Timeline", + props: z.object({ + items: z.array(TimelineItem.ref), + }), + description: + 'Events in time order along a dashed rail: an itinerary, a schedule, order status, a project history or an activity log. Timeline([TimelineItem("Check in", "Hotel Lutetia", "3:00 PM"), TimelineItem(Text("Dinner", "body", true), "Septime · table for 2", "8:30 PM")]). Give every item a time when the moments are known; use status to show progress through a plan. Use Steps for how-to instructions and ListBlock for an unordered set.', + component: ({ props, renderNode }) => { + const items = (props.items ?? []) as { + props?: { + title?: unknown; + subtitle?: unknown; + time?: unknown; + leading?: unknown; + status?: TimelineItemStatus; + }; + }[]; + const cell = (value: unknown) => renderOneOrMany(value, renderNode); + + return ( + { + const p = item?.props ?? {}; + return { + title: cell(p.title), + subtitle: cell(p.subtitle), + time: cell(p.time), + leading: p.leading ? leadingNode(p.leading as Node) : undefined, + status: p.status, + }; + })} + /> + ); + }, +}); diff --git a/packages/react-ui/src/genui-lib/Timeline/schema.ts b/packages/react-ui/src/genui-lib/Timeline/schema.ts new file mode 100644 index 0000000000..ba96bf0acc --- /dev/null +++ b/packages/react-ui/src/genui-lib/Timeline/schema.ts @@ -0,0 +1,27 @@ +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 TimelineItemSchema = z.object({ + 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(), + time: oneOrMany(z.string(), Text.ref, Tag.ref).optional(), + leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]).optional(), + status: z.enum(["done", "current", "upcoming"]).optional(), +}); diff --git a/packages/react-ui/src/genui-lib/ValueCard/index.tsx b/packages/react-ui/src/genui-lib/ValueCard/index.tsx new file mode 100644 index 0000000000..95c39d4284 --- /dev/null +++ b/packages/react-ui/src/genui-lib/ValueCard/index.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + ComponentRenderProps, + defineComponent, + useFormName, + useIsStreaming, + useTriggerAction, +} from "@openuidev/react-lang"; +import clsx from "clsx"; +import type { ReactNode } from "react"; +import { OverviewCardBlock as OpenUIOverviewCardBlock } from "../../components/OverviewCardBlock"; +import { ValueCardBlock as OpenUIValueCardBlock } from "../../components/ValueCardBlock"; +import { withItemContext } from "../cardActionUtils"; +import { renderIconSlot } from "../iconSlot"; +import { renderOneOrMany } from "../oneOrMany"; +import { ValueCardSchema, type ValueCardItemNode, type ValueCardProps } from "./schema"; + +export * from "./schema"; + +type Node = { typeName?: string; props?: Record }; + +function getStringProp(node: unknown, key: string): string | undefined { + const value = (node as Node | undefined)?.props?.[key]; + return typeof value === "string" ? value : undefined; +} + +// A Metric keeps the size it was given; otherwise horizontal cards use sm and +// the vertical card's KPI is the hero at the default size. Text values keep +// the compact override. +function sizedValue(value: unknown, metricSize: "sm" | "default"): unknown { + if (Array.isArray(value)) return value.map((v) => sizedValue(v, metricSize)); + const node = value as Node | undefined; + if (!node || typeof node !== "object") return node; + if (node.typeName === "Metric") { + return { ...node, props: { ...node.props, size: node.props?.["size"] ?? metricSize } }; + } + return metricSize === "sm" ? { ...node, props: { ...node.props, size: "xs" } } : node; +} + +type RenderNode = (node: unknown) => ReactNode; + +// The leading, title and subtitle on one line, the way a LeadingText reads. +// A horizontal card with a subtitle stacks the title over the subtitle +// instead, with a larger leading sized to the two lines (a bare Icon becomes +// a neutral badge there). +function cardLabel(item: ValueCardItemNode, renderNode: RenderNode, stacked: boolean): ReactNode { + const { title, subtitle, leading } = item.props; + const lead = leading as Node | undefined; + const leadNode = + stacked && lead?.typeName === "Icon" && lead.props?.["name"] + ? { typeName: "Avatar", props: { name: lead.props["name"], icon: lead } } + : lead; + return ( + + {leadNode ? ( + {renderIconSlot(leadNode)} + ) : null} + + {title} + {subtitle ? ( + + {renderOneOrMany(subtitle, renderNode)} + + ) : null} + + + ); +} + +export function ValueCardRenderer({ props, renderNode }: ComponentRenderProps) { + const triggerAction = useTriggerAction(); + const formName = useFormName(); + const isStreaming = useIsStreaming(); + + const items = (props.items ?? []) as ValueCardItemNode[]; + const vertical = props.layout === "vertical"; + const isClickable = Boolean(props.action) && !isStreaming; + + const handleItemClick = (index: number) => { + const item = items[index]; + if (!item || !props.action || isStreaming) return; + const { id, title, subtitle, value } = item.props; + triggerAction( + title ?? id ?? `Value card ${index + 1}`, + formName, + withItemContext(props.action, { + itemIndex: index, + itemId: id, + itemTitle: title, + itemSubtitle: typeof subtitle === "string" ? subtitle : undefined, + itemValue: getStringProp(value, "value") ?? getStringProp(value, "text"), + itemMetricDelta: getStringProp(value, "delta"), + }), + ); + }; + const onItemClick = isClickable + ? (_item: unknown, index: number) => handleItemClick(index) + : undefined; + + if (vertical) { + return ( + ({ + id: item.props.id, + top: cardLabel(item, renderNode, false), + bottomContent: renderOneOrMany(sizedValue(item.props.value, "default"), renderNode), + }))} + /> + ); + } + + return ( + { + const { id, title, subtitle, value } = item.props; + return { + id, + lhs: cardLabel(item, renderNode, Boolean(item.props.subtitle)), + rhs: renderOneOrMany(sizedValue(value, "sm"), renderNode), + lhsTooltip: { + heading: title, + content: typeof subtitle === "string" ? subtitle : undefined, + }, + rhsTooltip: { + heading: getStringProp(value, "value"), + content: getStringProp(value, "delta"), + }, + }; + })} + /> + ); +} + +export const ValueCard = defineComponent({ + name: "ValueCard", + props: ValueCardSchema, + description: + 'A responsive grid of 2 or more compact cards, each one short fact or KPI: ValueCard([item1, item2, item3], layout?, responsive?, action?, gap?, carousel?). Each card is a ValueCardItem(id?, title, subtitle?, leading?, value?), e.g. ValueCardItem("revenue", "Revenue", "Month to date", Icon("circle-dollar-sign", "finance"), Metric("$48,200", "+8.1%", null, "sm")). Set layout once on the ValueCard for the whole group, never per item. Use "horizontal" only when every value is a single short piece (one Metric or a short Text); if any value has more, such as a Metric plus a Tag or a Metric plus a Text note, use "vertical" for the whole block. "horizontal" (default) is for key/value facts: the value goes on the right, with a Metric at "sm"; with a subtitle the title stacks over it beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge), and without one the leading sits inline before the title like a LeadingText. "vertical" is for KPI tiles where the number is the hero: the leading and title sit on one line, the subtitle on its own line under the title, and the value goes below at the default size, e.g. ValueCard([v1, v2, v3], "vertical", true). leading is an Icon for a concept, or an Avatar for a person or company with a real photo or logo. carousel (vertical only) scrolls the cards sideways instead of wrapping, for secondary browsing. Every item uses the same parts. action is one shared action for all cards, with the clicked card\'s details attached, so keep its @ToAssistant message generic and never use @OpenUrl. Use ValueCard for dashboard-like output, such as an analytics or monitoring view; for an inline section of figures inside a normal response use ValueLayout instead. For a long list of key/value rows use EntityList; for one headline figure use a Metric on its own.', + component: ValueCardRenderer, +}); diff --git a/packages/react-ui/src/genui-lib/ValueCard/schema.ts b/packages/react-ui/src/genui-lib/ValueCard/schema.ts new file mode 100644 index 0000000000..a4042b361f --- /dev/null +++ b/packages/react-ui/src/genui-lib/ValueCard/schema.ts @@ -0,0 +1,49 @@ +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 { TagBlock } from "../TagBlock"; +import { Text } from "../Text"; + +export const ValueCardItemSchema = z.object({ + id: z.string().optional(), + title: z.string(), + subtitle: oneOrMany( + z.string(), + Text.ref, + Metric.ref, + Tag.ref, + LeadingText.ref, + Link.ref, + ).optional(), + leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]).optional(), + value: oneOrMany(Metric.ref, Text.ref, Tag.ref, TagBlock.ref, LeadingText.ref).optional(), +}); + +export const ValueCardItem = defineComponent({ + name: "ValueCardItem", + props: ValueCardItemSchema, + description: + "One card in a ValueCard: ValueCardItem(id?, title, subtitle?, leading?, value?). title says what the value is; subtitle adds a short context line (a string, or a Text, Metric, Tag, LeadingText or Link, or an array of them on one line); leading is an optional Avatar (a photo, logo or tinted icon badge), Icon or Emoji. In a horizontal card with a subtitle, the title stacks over the subtitle beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge) and the value sits on the right; without a subtitle the leading sits inline before the title like a LeadingText. In a vertical card the leading and title share one line, the subtitle sits under the title, and the hero value follows below. value is a Metric, Text, Tag, TagBlock or LeadingText, or an array of them on one line.", + component: () => null, +}); + +export const ValueCardSchema = z.object({ + items: z.array(ValueCardItem.ref).min(2), + layout: z.enum(["horizontal", "vertical"]).optional(), + responsive: z.boolean().optional(), + action: actionPropSchema.optional(), + gap: z.union([z.number(), z.string()]).optional(), + carousel: z.boolean().optional(), +}); + +export type ValueCardItemProps = z.infer; +export type ValueCardProps = z.infer; +export type ValueCardItemNode = ValueCardProps["items"][number]; diff --git a/packages/react-ui/src/genui-lib/ValueLayout/index.tsx b/packages/react-ui/src/genui-lib/ValueLayout/index.tsx new file mode 100644 index 0000000000..ae0856e030 --- /dev/null +++ b/packages/react-ui/src/genui-lib/ValueLayout/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang"; +import { z } from "zod/v4"; +import { ValueCardRenderer, ValueCardSchema, type ValueCardProps } from "../ValueCard"; + +// Its own schema object (same shape as ValueCard's) so the two register as +// separate components rather than sharing one schema definition. +// ValueLayout is informational only, so it has no action. +const { action: _action, ...valueLayoutShape } = ValueCardSchema.shape; +void _action; +const ValueLayoutSchema = z.object(valueLayoutShape); + +function ValueLayoutRenderer(props: ComponentRenderProps>) { + return ( +
+ )} /> +
+ ); +} + +export const ValueLayout = defineComponent({ + name: "ValueLayout", + props: ValueLayoutSchema, + description: + 'A lighter ValueCard: the same ValueCardItems, laid out in a grid or carousel with dashed rules between them instead of tinted cards. ValueLayout([item1, item2, item3], layout?, responsive?, gap?, carousel?), e.g. ValueLayout([ValueCardItem("revenue", "Revenue", "Month to date", Icon("circle-dollar-sign", "finance"), Metric("$48,200", "+8.1%", null, "sm")), ValueCardItem("orders", "Orders", "Month to date", Icon("shopping-cart", "shopping"), Metric("1,204", "+3.4%", null, "sm"))]). It follows every ValueCard rule: 2 or more items of the same shape, layout set once on the group, "horizontal" only when every value is one short piece and "vertical" otherwise, and carousel for vertical only. It has no action: it is informational only, so use ValueCard if the figures must be clickable. Use ValueLayout as an inline section of figures inside a normal UI response, such as key numbers in an answer about a company, city or product, where tinted cards would feel heavy. Use ValueCard for dashboard-like output, such as an analytics or monitoring view.', + component: ValueLayoutRenderer, +}); diff --git a/packages/react-ui/src/genui-lib/VisualCardBlock/index.tsx b/packages/react-ui/src/genui-lib/VisualCardBlock/index.tsx index 558730927e..1b6fab3663 100644 --- a/packages/react-ui/src/genui-lib/VisualCardBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/VisualCardBlock/index.tsx @@ -37,7 +37,7 @@ function VisualCardBlockRenderer({ if (!item || !props.action || isStreaming) return; const { id, body, tag, bgImageSrc, bgImageAlt } = item.props; - const bodyValue = body?.props?.value; + const bodyValue = body?.props?.title; const tagText = tag?.props?.text; triggerAction( bodyValue || tagText || id || `Visual card ${index + 1}`, @@ -47,7 +47,7 @@ function VisualCardBlockRenderer({ itemId: id, itemTag: tagText, itemBody: bodyValue, - itemBodySubtext: body?.props?.subtext, + itemBodySubtext: body?.props?.description, itemBgImageSrc: bgImageSrc, itemBgImageAlt: bgImageAlt, }), diff --git a/packages/react-ui/src/genui-lib/VisualCardBlock/schema.ts b/packages/react-ui/src/genui-lib/VisualCardBlock/schema.ts index 2cf0a314a0..c35ff4c17d 100644 --- a/packages/react-ui/src/genui-lib/VisualCardBlock/schema.ts +++ b/packages/react-ui/src/genui-lib/VisualCardBlock/schema.ts @@ -1,11 +1,11 @@ import { defineComponent } from "@openuidev/react-lang"; import { z } from "zod/v4"; import { actionPropSchema } from "../Action/schema"; -import { BoldText } from "../BoldText"; +import { Heading } from "../Heading"; import { Tag } from "../Tag"; export const VisualCardItemSchema = z.object({ - body: BoldText.ref, + body: Heading.ref, id: z.string().optional(), bgImageSrc: z.string().optional(), tag: z.optional(Tag.ref), @@ -18,7 +18,7 @@ export const VisualCardItem = defineComponent({ name: "VisualCardItem", props: VisualCardItemSchema, description: - "A single photo-first card inside a VisualCardBlock: a BoldText body panel, an optional Tag, and a background image (bgImageSrc must be a real URL; bgImageAlt is its alt text).", + "A single photo-first card inside a VisualCardBlock: a Heading body panel (the card title and an optional subtitle), an optional Tag, and a background image (bgImageSrc must be a real URL; bgImageAlt is its alt text).", component: () => null, }); diff --git a/packages/react-ui/src/genui-lib/archived.ts b/packages/react-ui/src/genui-lib/archived.ts new file mode 100644 index 0000000000..7d2f6d4cdc --- /dev/null +++ b/packages/react-ui/src/genui-lib/archived.ts @@ -0,0 +1,38 @@ +import { BoldText } from "./BoldText"; +import { CardHeader } from "./CardHeader"; +import { IconTitle } from "./IconTitle"; +import { ImageBlock } from "./ImageBlock"; +import { ImageText } from "./ImageText"; +import { ImageTextLarge } from "./ImageTextLarge"; +import { InlineHeader } from "./InlineHeader"; +import { InlineText } from "./InlineText"; +import { Label } from "./Label"; +import { MetricIndicatorInline, MetricIndicatorWithStrikethrough } from "./MetricIndicator"; +import { OverviewCardBlock, OverviewCardItem } from "./OverviewCardBlock"; +import { TextCallout } from "./TextCallout"; +import { VisualCardBlock, VisualCardItem } from "./VisualCardBlock"; + +/** + * Components taken out of the model-facing libraries while their future is + * decided. They are not registered in openuiLibrary / openuiChatLibrary, so + * the model never sees or emits them; previews can still render them by + * adding these to a library of their own. + */ +export const openuiArchivedComponents = [ + CardHeader, + InlineHeader, + Label, + InlineText, + BoldText, + TextCallout, + MetricIndicatorInline, + MetricIndicatorWithStrikethrough, + ImageBlock, + IconTitle, + ImageText, + ImageTextLarge, + OverviewCardBlock, + OverviewCardItem, + VisualCardBlock, + VisualCardItem, +]; diff --git a/packages/react-ui/src/genui-lib/cardTagUtils.tsx b/packages/react-ui/src/genui-lib/cardTagUtils.tsx index 26a1154526..bf344c9242 100644 --- a/packages/react-ui/src/genui-lib/cardTagUtils.tsx +++ b/packages/react-ui/src/genui-lib/cardTagUtils.tsx @@ -1,27 +1,22 @@ import type { SubComponentOf } from "@openuidev/react-lang"; import { z } from "zod/v4"; import { Tag as OpenUITag } from "../components/Tag"; -import { IconWrapper } from "../components/_shared/icons"; +import { renderIconSlot } from "./iconSlot"; import type { TagSchema } from "./Tag"; type TagProps = z.infer; -/** Renders a `Tag` element ref (as used inside card blocks) at the small size. */ +/** Renders a `Tag` element ref, as used inside card blocks. */ export function renderCardTag(tag: SubComponentOf | undefined, key?: string) { if (!tag) return null; - const { text, variant, icon } = tag.props; + const { text, variant, leading } = tag.props; return ( - ) : undefined - } + icon={renderIconSlot(leading)} /> ); } diff --git a/packages/react-ui/src/genui-lib/iconSlot.tsx b/packages/react-ui/src/genui-lib/iconSlot.tsx new file mode 100644 index 0000000000..90597ceeae --- /dev/null +++ b/packages/react-ui/src/genui-lib/iconSlot.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { IconWrapper } from "../components/_shared/icons"; +import { Avatar as OpenUIAvatar, type AvatarColor } from "../components/Avatar"; +import { Emoji as OpenUIEmoji } from "../components/Emoji"; +import { Shape as OpenUIShape, type ShapeColor, type ShapeKind } from "../components/Shape"; + +type IconSlotNode = + | { + typeName?: string; + props?: { + name?: string; + category?: string; + emoji?: string; + label?: string | null; + src?: string | null; + domain?: string | null; + shape?: string | null; + color?: string | null; + icon?: unknown; + }; + } + | undefined; + +/** Renders an Icon, Avatar, Emoji or Shape ref for slots that take them. */ +export function renderIconSlot(node: unknown): ReactNode { + const n = node as IconSlotNode; + if (n?.typeName === "Shape") { + const shape = n.props?.shape as unknown as ShapeKind | undefined; + return shape ? ( + + ) : undefined; + } + if (n?.typeName === "Emoji") { + return n.props?.emoji ? ( + + ) : undefined; + } + if (!n?.props?.name) return undefined; + if (n.typeName === "Avatar") { + return ( + + ); + } + return ; +} diff --git a/packages/react-ui/src/genui-lib/index.ts b/packages/react-ui/src/genui-lib/index.ts index f1ba4ffd39..9bb1afe990 100644 --- a/packages/react-ui/src/genui-lib/index.ts +++ b/packages/react-ui/src/genui-lib/index.ts @@ -13,3 +13,5 @@ export { openuiChatLibrary, openuiChatPromptOptions, } from "./openuiChatLibrary"; + +export { openuiArchivedComponents } from "./archived"; diff --git a/packages/react-ui/src/genui-lib/nestedCardContext.ts b/packages/react-ui/src/genui-lib/nestedCardContext.ts new file mode 100644 index 0000000000..dd30459f65 --- /dev/null +++ b/packages/react-ui/src/genui-lib/nestedCardContext.ts @@ -0,0 +1,7 @@ +import React from "react"; + +/** + * True inside a Carousel slide or a Grid cell, so the chat Card (frameless as + * the response root) renders as a framed card there. + */ +export const NestedCardContext = React.createContext(false); diff --git a/packages/react-ui/src/genui-lib/oneOrMany.tsx b/packages/react-ui/src/genui-lib/oneOrMany.tsx new file mode 100644 index 0000000000..58402fd5bd --- /dev/null +++ b/packages/react-ui/src/genui-lib/oneOrMany.tsx @@ -0,0 +1,41 @@ +import { Fragment, type ReactNode } from "react"; +import { z } from "zod/v4"; +import { InlineGroup } from "../components/InlineGroup"; + +/** + * A slot that takes one of `options`, or an array of them. The agent passes + * an array when a slot needs several pieces side by side (a price and a stock + * Tag); renderOneOrMany lays it out as an inline row. + */ +export function oneOrMany( + ...options: T +) { + const one = z.union(options as unknown as [z.core.$ZodType, z.core.$ZodType]); + return z.union([...options, z.array(one)] as unknown as [z.core.$ZodType, z.core.$ZodType]); +} + +/** Renders a oneOrMany value: strings as text, nodes as themselves, arrays inline. */ +export function renderOneOrMany( + value: unknown, + renderNode: (node: unknown) => ReactNode, +): ReactNode | string | undefined { + if (value == null || value === "") return undefined; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const items = value.filter((v) => v != null && v !== ""); + if (!items.length) return undefined; + if (items.length === 1) return renderOneOrMany(items[0], renderNode); + return ( + + {items.map((item, i) => + typeof item === "string" ? ( + {item} + ) : ( + {renderNode(item)} + ), + )} + + ); + } + return renderNode(value); +} diff --git a/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx b/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx index 57706918aa..fdcc63bca4 100644 --- a/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx +++ b/packages/react-ui/src/genui-lib/openuiChatLibrary.tsx @@ -1,22 +1,30 @@ "use client"; -import type { ComponentGroup } from "@openuidev/react-lang"; +import type { ComponentGroup, ComponentRenderProps } from "@openuidev/react-lang"; import { createLibrary, defineComponent } from "@openuidev/react-lang"; +import React from "react"; import { z } from "zod/v4"; -import { Card as OpenUICard } from "../components/Card"; +import { CardHeading, Card as OpenUICard } from "../components/Card"; import { CardSourceProvider, CardSourceSchema, Sources } from "../components/Sources"; // Content +import { Avatar } from "./Avatar"; +import { AvatarStack } from "./AvatarStack"; import { Callout } from "./Callout"; -import { CardHeader } from "./CardHeader"; import { CodeBlock } from "./CodeBlock"; +import { Emoji } from "./Emoji"; +import { EntityTitle } from "./EntityTitle"; +import { Heading } from "./Heading"; import { Image } from "./Image"; -import { ImageBlock } from "./ImageBlock"; import { ImageGallery } from "./ImageGallery"; -import { MarkDownRenderer } from "./MarkDownRenderer"; +import { ImageTitle } from "./ImageTitle"; +import { LeadingText } from "./LeadingText"; +import { Link } from "./Link"; +import { Markdown } from "./Markdown"; +import { Metric } from "./Metric"; import { Separator } from "./Separator"; -import { TextCallout } from "./TextCallout"; -import { TextContent } from "./TextContent"; +import { Shape } from "./Shape"; +import { Text } from "./Text"; // Charts import { @@ -38,10 +46,9 @@ import { // Forms import { CheckBoxGroup, CheckBoxItem } from "./CheckBoxGroup"; import { DatePicker } from "./DatePicker"; -import { Form } from "./Form"; +import { Form, FormRow } from "./Form"; import { FormControl } from "./FormControl"; import { Input } from "./Input"; -import { Label } from "./Label"; import { RadioGroup, RadioItem } from "./RadioGroup"; import { Select, SelectItem } from "./Select"; import { Slider } from "./Slider"; @@ -54,9 +61,15 @@ import { Buttons } from "./Buttons"; // Layout (no Stack) import { AccordionRenderer } from "./Accordion"; -import { CarouselRenderer } from "./Carousel"; +import { BLEED_DESCRIPTION, BleedRenderer, BleedSchema } from "./Bleed"; +import { CAROUSEL_DESCRIPTION, CarouselRenderer, carouselSlideUnion } from "./Carousel"; +import { Checklist, ChecklistItem } from "./Checklist"; +import { GRID_DESCRIPTION, GridRenderer, GridSchema } from "./Grid"; +import { SHELL_DESCRIPTION, ShellRenderer, ShellSchema } from "./Shell"; import { Steps, StepsItem } from "./Steps"; import { TabsRenderer } from "./Tabs"; +import { Timeline, TimelineItem } from "./Timeline"; +import { NestedCardContext } from "./nestedCardContext"; // Data Display import { Col, Table } from "./Table"; @@ -68,22 +81,17 @@ import { FollowUpBlock } from "./FollowUpBlock"; import { FollowUpItem } from "./FollowUpItem"; import { ListBlock } from "./ListBlock"; import { ListItem } from "./ListItem"; +import { SECTION_DESCRIPTION, SectionRenderer, SectionSchema } from "./Section"; import { SectionBlock, SectionBlockRenderer } from "./SectionBlock"; // Content & inline composites -import { BoldText } from "./BoldText"; import { EntityList } from "./EntityList"; +import { EntityListItem } from "./EntityListItem"; import { Icon } from "./Icon"; import { IconButton } from "./IconButton"; -import { IconText } from "./IconText"; -import { ImageText } from "./ImageText"; -import { ImageTextLarge } from "./ImageTextLarge"; -import { InlineHeader } from "./InlineHeader"; -import { MetricIndicatorInline, MetricIndicatorWithStrikethrough } from "./MetricIndicator"; -import { Text } from "./Text"; // Editable table -import { EditableTable } from "./EditableTable"; +import { EditableCol, EditableTable } from "./EditableTable"; // Selection inputs import { ChipItem, Chips } from "./Chips"; @@ -91,16 +99,16 @@ import { OptionCard, OptionCards } from "./OptionCards"; // Card blocks import { CompositeCardBlock, CompositeCardItem } from "./CompositeCardBlock"; -import { ContextCardBlock, ContextCardItem } from "./ContextCardBlock"; -import { OverviewCardBlock, OverviewCardItem } from "./OverviewCardBlock"; -import { SnippetCardBlock, SnippetCardItem } from "./SnippetCardBlock"; -import { VisualCardBlock, VisualCardItem } from "./VisualCardBlock"; +import { ImageCard, ImageCardItem } from "./ImageCard"; +import { InsightCard, InsightCardItem } from "./InsightCard"; +import { ValueCard, ValueCardItem } from "./ValueCard"; +import { ValueLayout } from "./ValueLayout"; import { ChatContentChildUnion } from "./unions"; // ── Chat containers — same renderers as the base Tabs / Accordion / Carousel / SectionItem, // but their content unions accept every chat block (card blocks, EntityList, EditableTable, -// InlineHeader, ...). Defined here, not in unions.ts, to avoid circular imports; registered +// Metric, ...). Defined here, not in unions.ts, to avoid circular imports; registered // under the base names so SectionBlock's `SectionItem.ref` resolves to the chat variant. // Everything a nested container may hold — no SectionBlock nesting. @@ -118,15 +126,18 @@ const ChatAccordionItem = defineComponent({ value: z.string(), trigger: z.string(), content: z.array(ChatNestedContentUnion), + leading: z.union([Icon.ref, Avatar.ref, Emoji.ref]).optional(), }), - 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, }); const ChatAccordion = defineComponent({ name: "Accordion", props: z.object({ items: z.array(ChatAccordionItem.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, }); @@ -136,25 +147,30 @@ const ChatTabItem = defineComponent({ value: z.string(), trigger: z.string(), content: z.array(z.union([...ChatNestedContentUnion.options, ChatAccordion.ref])), + leading: z.union([Icon.ref, Avatar.ref, Emoji.ref]).optional(), }), - 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, }); const ChatTabs = defineComponent({ name: "Tabs", props: z.object({ items: z.array(ChatTabItem.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, }); const ChatCarousel = defineComponent({ name: "Carousel", props: z.object({ - children: z.array(z.array(ChatNestedContentUnion)), - variant: z.enum(["card", "sunk"]).optional(), + // Each slide is a chat Card; lazy because the Card can hold this Carousel. + get children(): z.ZodArray { + return z.array(carouselSlideUnion(ChatCard.ref)); + }, }), - description: "Horizontal scrollable carousel", + description: CAROUSEL_DESCRIPTION, component: CarouselRenderer, }); @@ -176,30 +192,115 @@ const ChatSectionBlock = 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 topics 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, }); +// Same props and renderer as the base Section, with children typed to chat content. +// A Section holds no Section, so it only appears directly in the Card. +// Same props and renderer as the base Grid; each cell is usually a chat Card, +// which frames itself inside a Grid. Lazy because the Card can hold this Grid. +const ChatGrid = defineComponent({ + name: "Grid", + props: GridSchema.extend({ + get children(): z.ZodArray { + return z.array( + z.union([...ChatNestedContentUnion.options, ChatCard.ref, ChatTabs.ref, ChatCarousel.ref]), + ); + }, + }), + description: GRID_DESCRIPTION, + component: GridRenderer, +}); + +// Shell and Bleed hold the same chat blocks as a Grid, plus the Grid itself. +// Lazy because the chat Card can hold them. +const chatWideChildren = (): z.ZodArray => + z.array( + z.union([ + ...ChatNestedContentUnion.options, + ChatCard.ref, + ChatGrid.ref, + ChatTabs.ref, + ChatCarousel.ref, + ]), + ); + +const ChatShell = defineComponent({ + name: "Shell", + props: ShellSchema.extend({ + get children(): z.ZodArray { + return chatWideChildren(); + }, + }), + description: SHELL_DESCRIPTION, + component: ShellRenderer, +}); + +const ChatBleed = defineComponent({ + name: "Bleed", + props: BleedSchema.extend({ + get children(): z.ZodArray { + return chatWideChildren(); + }, + }), + description: BLEED_DESCRIPTION, + component: BleedRenderer, +}); + +const ChatSection = defineComponent({ + name: "Section", + props: SectionSchema.extend({ + children: z.array( + z.union([ + ...ChatNestedContentUnion.options, + ChatSectionBlock.ref, + ChatTabs.ref, + ChatAccordion.ref, + ChatCarousel.ref, + ChatGrid.ref, + ChatShell.ref, + ChatBleed.ref, + ]), + ), + }), + description: SECTION_DESCRIPTION, + component: SectionRenderer, +}); + const ChatCardChildUnion = z.union([ ...ChatNestedContentUnion.options, + ChatSection.ref, + ChatGrid.ref, + ChatShell.ref, + ChatBleed.ref, ChatSectionBlock.ref, ChatTabs.ref, ChatCarousel.ref, ]); -// ── Locked Chat Card — no design params, always vertical ── +// ── Chat Card — always vertical. As the response root it has no frame; as a +// Carousel slide it is a framed Card whose variant sets the look. -const ChatCard = defineComponent({ - name: "Card", - props: z.object({ - children: z.array(ChatCardChildUnion), - sources: z.array(CardSourceSchema).optional(), - }), - description: - "Vertical container for all content in a chat response. Children stack top to bottom automatically. Optional sources ([{ title, sourceName, url }]) render as a Sources strip at the bottom and back inline [n] citations in TextContent.", - component: ({ props, renderNode }) => ( - +type ChatCardVariant = "outlined" | "lifted" | "sunk"; + +const ChatCardRenderer = ({ + props, + renderNode, +}: ComponentRenderProps<{ + children: unknown[]; + sources?: unknown; + variant?: ChatCardVariant; + title?: string; + subtitle?: string; +}>) => { + const inSlide = React.useContext(NestedCardContext); + return ( + ["sources"]} + > + {renderNode(props.children)} - ), + ); +}; + +const ChatCard = defineComponent({ + name: "Card", + props: z.object({ + children: z.array(ChatCardChildUnion), + sources: z.array(CardSourceSchema).optional(), + variant: z.enum(["outlined", "lifted", "sunk"]).optional(), + title: z.string().optional(), + subtitle: z.string().optional(), + }), + description: `Vertical container for all content in a chat response. Children stack top to bottom automatically. Optional sources ([{ title, sourceName, url }]) render as a Sources strip at the bottom and back inline [n] citations in Markdown. The response root takes no variant. A Card used as a Carousel slide or a Grid cell is a framed card: variant "outlined" (default, a thin border on a transparent background) | "lifted" (white, with the same border plus a soft shadow) | "sunk" (a light grey fill, no border or shadow). A framed Card can take a small title and subtitle (4th and 5th, after variant), 14px over 13px secondary like our other cards; use them instead of a Heading child: Card([chart], null, null, "Revenue by quarter", "USD thousands").`, + component: ChatCardRenderer, }); // ── Component Groups ── @@ -220,29 +335,49 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ { name: "Content", components: [ - "CardHeader", - "TextContent", - "MarkDownRenderer", + "Text", + "Markdown", + "Metric", + "Avatar", + "Emoji", + "Shape", + "AvatarStack", + "Heading", + "LeadingText", + "EntityTitle", + "ImageTitle", + "Link", "Callout", - "TextCallout", "Image", - "ImageBlock", "ImageGallery", "CodeBlock", "Separator", - "InlineHeader", ], notes: [ - "- InlineHeader is a compact heading + description pair for labelling a block inside the response (lighter than CardHeader).", - "- Pass sources on Card ([{ title, sourceName, url }]) when the answer relies on references, and cite them inline in TextContent as [1], [2] (1-based index into sources). A Sources strip renders at the bottom of the card.", + '- Metric(value, delta?, variant?, size?, previous?) is a value with an optional change (+/- shows an up/down arrow); label it with the Card title inside a Card, or compose one with Text or Heading elsewhere. It uses the UI font, so use size "sm" whenever it is not the hero number: Table cells, EntityListItem right, ListItem/EntityTitle subtitles, ImageTitle content, ValueCardItem value, CompositeCardItem body (a CompositeCardItem footer price always renders at 18px), e.g. Col("Change", @Each(data.rows, "r", Metric(r.value, r.change, "horizontal", "sm"))). Only a standalone headline figure gets "vertical", "large", e.g. Card([Metric("$48,200", "+8.1%", "vertical", "large")], null, null, "Revenue"). previous (5th) strikes through an earlier value for a real before/after, like a price drop: Metric("$89", "-26%", null, "sm", "$120").', + '- Avatar is a photo, logo or tinted icon badge that can stand in for an Icon (LeadingText, Tag, Chips): Avatar("Ada Lovelace", "https://…/ada.jpg", null, null, null, Icon("user", "people")) for a person, Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")) for a company logo, Avatar("Billing", null, "rounded", null, "md", Icon("credit-card", "finance"), "info") for a standalone concept badge. ALWAYS pass an icon, even with src or domain: it renders when the image is missing or broken. src must be a real URL; a person with no real photo URL gets Icon("user", "people") inside and no src. The icon slot also takes a Shape for a solid colored mark. A concept on a text line stays a plain Icon.', + '- Heading("Title", "Supporting line") titles a UI section when the description adds a fact the title lacks (scope, unit, time range, what to do). Never a description that greets, introduces you, restates the title or says what the section contains; a bare title is Text h2/h3.', + "- Greetings, small talk and short conversational replies are one plain Markdown block with no heading, Heading or Text title.", + '- EntityTitle(leading, title, subtitle) names a person, company, product, place or topic behind an Avatar-first leading visual (a photo, a domain logo or a tinted icon badge): EntityTitle(Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")), "Stripe", "Payments infrastructure"). subtitle is required: a string, or a Tag, Metric, Link, TagBlock or LeadingText for a status, price, source or labels. A title with no subtitle is a LeadingText. Heading titles a section.', + '- ImageTitle(Image(alt, src, ratio), [content]) is one item in a grid or Carousel of 2+ products, articles or places: image on top, then a short content stack, usually a title Text("…", "body", true) (an EntityTitle only for a real person, brand or seller with their own photo or logo, never an icon Avatar), then one secondary line (an article author as a LeadingText with an inline Avatar and tone "secondary", a price Metric, Tags). Keep every item the same shape and ratio; a single illustrative picture is Image plus Markdown. Real image URLs only.', + '- LeadingText(leading, Text(...)) puts an Icon, Avatar or AvatarStack before any Text, centered on its first line: Icon for a concept or fact (LeadingText(Icon("calendar", "time"), Text("Opens daily 9am–6pm"))), Shape for a status dot, legend marker or trend triangle (LeadingText(Shape("triangle-up", "success"), Text("+12% this week"))), Avatar for one real person or company, AvatarStack for a group of them, and rarely an Emoji for expressive generated content (never in UI chrome). Avatars here use size "inline".', + "- AvatarStack([a1, a2, a3, a4, a5], 3) overlaps 2+ Avatars with a count chip for the rest: a team, attendees, who replied. Keep max at 3–5, set size on the stack, and name the group in Text beside it.", + "- Markdown is for explanations and the top-level prose that sets context around UI. Text is for short UI text: headings, labels, captions, single values.", + "- Answering a question with a title and prose: one Markdown block with a # heading, not Text h1 + Markdown. Text headings title UI (dashboards, forms, card groups); a part of a longer multi-part response is a Section that starts with a Heading.", + '- Callout("warning", "**Visa required.** Apply 3 weeks ahead.") is an icon (or a rare Emoji) plus short Markdown content for one standout aside (tip, caveat, warning, result), with no title. Pick variant by meaning; one or two per response at most; never for notes about what you did.', + "- Highlight in Markdown with ==key phrase==: a soft green marker behind the text.", + "- Use it for the one phrase that directly answers the question, at most 1-2 per response. Never for whole sentences, headings, links or code.", + "- Links: in Markdown write [descriptive text](https://…); the site's favicon renders before the link text automatically. Never use a bare URL as link text.", + '- For a standalone link outside prose (a source, docs or product page), use Link("Page name", "https://…"). When a Link is the title of a ListItem, StepsItem or TimelineItem, pass size "lg" so it matches the title size: Link("Septime", "https://…", "lg").', + "- Pass sources on Card ([{ title, sourceName, url }]) when the answer relies on references, and cite them inline in Markdown as [1], [2] (1-based index into sources). A Sources strip renders at the bottom of the card.", ], }, { name: "Tables", - components: ["Table", "Col", "EditableTable"], + components: ["Table", "Col", "EditableTable", "EditableCol"], notes: [ - "- EditableTable lets the user edit cells inline. Give it a unique name, columns of { type, key, header } with type one of text | number | date-single | select | url (select also needs options: [{ value, label }]).", - "- data is an array of { id, values } rows where values are ordered positionally to match columns. Edited data is submitted when the user clicks Save Changes.", + '- EditableTable(name, [EditableCol(key, label, type?, options?, icon?)], rows) lets the user edit cells inline. type is the cell editor: "text" (default), "number", "date", "select" (options as SelectItems) or "url"; each column shows a leading header icon for its type unless you pass an Icon.', + "- rows is an array of value arrays in column order (dates as ISO strings, selects as the SelectItem value). Edited data is submitted when the user clicks Save Changes.", ], }, { @@ -268,8 +403,8 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ name: "Forms", components: [ "Form", + "FormRow", "FormControl", - "Label", "Input", "TextArea", "Select", @@ -289,7 +424,9 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ ], notes: [ "- Chips: compact single/multiple selection pills. Use ChipItem references for each option.", - "- OptionCards: larger selectable cards with title, subtitle and an optional Icon or Image on top. Use OptionCard references for each option.", + "- OptionCards: larger selectable cards with a title, a composable subtitle and an optional leading (Icon, Avatar, Image or rare Emoji); the selected card shows a checkmark. Use OptionCard references for each option; use it for 2–6 choices that need a short explanation, Chips for short labels.", + "- FormRow([fieldA, fieldB]) puts 2–3 short related FormControls side by side (first and last name; city, state and zip) and stacks them on narrow widths. Mix FormRows with full-width FormControls in Form fields; keep TextArea and other long fields full width.", + '- Pick Input type for what is entered ("email", "password", "number", "url", "search", "tel", or "text") since it sets the leading icon automatically. Use variant "inline" on Input, TextArea, Select and DatePicker only inside table cells or dense rows, where a borderless field fits; "default" everywhere else.', "- Define EACH FormControl as its own reference — do NOT inline all controls in one array.", "- NEVER nest Form inside Form.", "- Form requires explicit buttons. Always pass a Buttons(...) reference as the second Form argument: Form(name, buttons, fields).", @@ -301,7 +438,7 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ name: "Buttons", components: ["Button", "Buttons", "Icon", "IconButton"], notes: [ - "- Icon renders a lucide icon by kebab-case name; it is also used as the icon of IconButton, IconText and OptionCard.", + "- Icon is a lucide line icon by exact kebab-case name that fills icon slots (LeadingText, Tag, ChipItem, Avatar, EntityTitle, IconButton, OptionCard); the host sets its size, stroke and color. An unknown name shows a plain circle.", ], }, { @@ -317,8 +454,10 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ }, { name: "Sections", - components: ["SectionBlock", "SectionItem"], + components: ["Section", "SectionBlock", "SectionItem"], notes: [ + "- Build every response from Sections placed directly in the Card: Section([children]) holds one part (an overview, then options, then next steps) with more space after it and tight spacing inside; a short answer is one Section. Compose each part yourself: a Heading title first when it needs one, then content; put a card block (ValueCard, ImageCard, CompositeCardBlock) inside for a grid. Never nested. Sections are high-level divisions and many responses have only one; never give a lone Text, Markdown, Buttons row or title (Heading or Text, with or without a subtitle) its own Section, so an intro or a dashboard header goes first inside the Section holding the content it introduces.", + "- SectionBlock is different: collapsible accordion sections. Use Section for always-visible parts.", "- SectionBlock renders collapsible accordion sections that auto-open as they stream.", "- Each section needs a unique `value` id, a `trigger` label, and a `content` array.", '- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1])', @@ -327,51 +466,68 @@ export const openuiChatComponentGroups: ComponentGroup[] = [ }, { name: "Layout", - components: ["Tabs", "TabItem", "Accordion", "AccordionItem", "Steps", "StepsItem", "Carousel"], + components: [ + "Tabs", + "TabItem", + "Accordion", + "AccordionItem", + "Steps", + "StepsItem", + "Carousel", + "Grid", + "Shell", + "Bleed", + ], notes: [ + "- Shell(title, [children]) frames a dashboard or large Grid layout: a small title, an outline, wider than the chat column, with an expand button for full screen. At most one per response. Bleed([children]) is the same width with no frame, for a wide hero Image, comparison Grid or Carousel; wrap a Grid comparing an odd number of cards (3 or 5 plans) in a Bleed, Bleed([Grid([p1, p2, p3], 3)]), so they sit in one row instead of 2 + 1. Never nest either.", + '- Grid(children, columns?) lays out equal columns for dashboards: columns 2 (default) | 3 | 4, collapsing to fewer on narrow screens. Each cell is usually a Card, which is outlined by default inside a Grid: a KPI row Grid([Card([Metric("$48,200", "+8.1%")], null, null, "Revenue"), ...], 4) above Grid([Card([chart1], null, null, "Revenue"), Card([chart2], null, null, "Orders")]). Title cards with the Card\'s own title, not a Heading child.', "- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array.", - "- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])", - "- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order.", - "- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern.", + '- Carousel takes 2+ Cards, one per slide: carousel = Carousel([Card([t1, img1]), Card([t2, img2])]). A slide Card is outlined by default; its variant (3rd argument, after sources) sets the look: Card([t1, img1], null, "sunk").', + "- IMPORTANT: Every slide in a Carousel must have the same structure — same variant, same component types in the same order.", + '- Which scroller: a set of one card type with one shared click action uses that block\'s own carousel layout (ImageCard, InsightCard, CompositeCardBlock layout "carousel"; vertical ValueCard carousel true), which keeps the shared action. Carousel is for custom Card or Image slides, or card items that need no shared action.', + "- For image carousels each slide is Card([title, image, description, tags]) — every slide must follow this exact pattern.", "- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs.", ], }, { name: "Data Display", - components: ["TagBlock", "Tag", "EntityList"], + components: [ + "TagBlock", + "Tag", + "EntityList", + "EntityListItem", + "Timeline", + "TimelineItem", + "Checklist", + "ChecklistItem", + ], notes: [ - "- EntityList is a compact two-column list of { left, right } rows (e.g. name / value). size='default' also supports a header and footer row; size='small' does not.", + "- EntityList is a compact list of EntityListItem(left, right?, rightVariant?) rows. left is a plain string for key/value facts, an EntityTitle for people, companies or products, or a LeadingText for facts with an icon; right is an optional string, Metric, Tag or Text. size='default' also supports a header and footer row; size='small' does not.", + "- Timeline lists events in time order along a dashed rail, with an optional time column on the left: itineraries, schedules, project histories, order tracking. TimelineItem(title, subtitle?, time?, leading?, status?); status done/current/upcoming shows progress through a plan. Use Steps for numbered how-to instructions and ListBlock for unordered or ranked sets.", + '- Steps([StepsItem(title, subtitle?)]) is a numbered how-to; the number chips are automatic. title is a string or a Text (pass Text("Install the CLI", "body", true) to emphasize it), and subtitle is one or two plain sentences on how to do it.', + "- Checklist(items, name?, value?) with ChecklistItem(title, subtitle?, checked?, value?) is a checkable list of to-dos, packing items, launch or onboarding steps: the user ticks items, checked sets the starting state, and value is a stable key per item. Give it a name when the ticks should persist or you need to read them later. Inside a CompositeCardItem body it is read-only, for what a plan includes. Steps is for numbered how-tos, Timeline for time-ordered events.", ], }, { name: "Cards", components: [ - "SnippetCardBlock", - "SnippetCardItem", - "OverviewCardBlock", - "OverviewCardItem", - "ContextCardBlock", - "ContextCardItem", + "ValueCard", + "ValueCardItem", + "ValueLayout", + "InsightCard", + "InsightCardItem", "CompositeCardBlock", "CompositeCardItem", - "VisualCardBlock", - "VisualCardItem", - "Text", - "BoldText", - "IconText", - "ImageText", - "ImageTextLarge", - "MetricIndicatorInline", - "MetricIndicatorWithStrikethrough", + "ImageCard", + "ImageCardItem", ], notes: [ "- Card blocks lay out 2+ items in a responsive grid (or carousel where supported). Every item in a block must have the same structure.", - "- SnippetCardItem: small card with lhs (IconText | ImageText) and optional rhs (Text | BoldText) — good for key/value facts.", - "- OverviewCardItem: small card with top (IconText | ImageText | Text) and optional bottom MetricIndicatorInline — good for KPIs.", - "- ContextCardItem: medium card with a title (string or Tag), body text and optional background image — good for summaries.", - "- CompositeCardItem: rich card with header, body array (Text, BoldText, MetricIndicatorInline, IconText, Image, charts, ListBlock, TagBlock, EntityList) and footer (price + Button) — good for products/offers.", - "- VisualCardItem: image-first card with a BoldText body and optional Tag.", - "- Text / BoldText / IconText / ImageText / ImageTextLarge / MetricIndicator* are the inline building blocks used INSIDE card items; do not place them directly in the root Card.", + '- ValueCard: a grid of 2+ small ValueCardItem(id?, title, subtitle?, leading?, value?) cards, e.g. ValueCardItem("revenue", "Revenue", "Month to date", Icon("circle-dollar-sign", "finance"), Metric("$48,200", "+8.1%", null, "sm")). leading is an Icon for a concept or an Avatar for a real person or company (36px beside a stacked title and subtitle in horizontal cards, inline before the title otherwise); subtitle and value each take one piece or an array on one line, e.g. value [Metric("99.98%"), Tag("Healthy", null, "success")]. layout "horizontal" (default) for key/value facts (value on the right, Metric at "sm"); "vertical" for KPI tiles (a hero Metric at the default size), optionally with carousel. Set layout on the group, never per item: "horizontal" only when every value is one short piece (one Metric or short Text), otherwise "vertical" for the whole block. Keep every item the same shape, and use one generic shared action at most.', + "- ValueLayout takes the same ValueCardItems and layout rules as ValueCard but draws dashed rules instead of tinted cards and has no action. Use ValueCard for dashboard-like output (analytics, monitoring) and ValueLayout as an inline section of figures inside a normal UI response.", + "- InsightCard: 2+ tall InsightCardItem(id?, title, subtitle?, overline?, leading?, color?) tiles, each one takeaway: a short overline (with an optional leading) at the top, and the title (the insight, 22px) with a composable subtitle at the bottom, on a palette color (picked by meaning, never invented); no photo, which is ImageCard. Keep the overline to a few words and the title to one short insight, never a Tag. Use it for research takeaways, trends or news with a status Tag in the subtitle, topics to explore, plan options and suggested next topics.", + '- CompositeCardItem: rich white card with a header (EntityTitle, ImageTitle, Image, or a Text("…", "h3") title), an optional edge-to-edge media at the top (5th argument: a real photo Image, or an AreaChart, BarChart or LineChart for a metric or trend card), a body array (Text as 14px body copy, Metric at sm, EntityTitle, Image, charts, ListBlock, TagBlock, EntityList, or a Checklist for what a plan includes) and a footer (price Metric, always 18px, with previous for a sale, plus a Button, Buttons([primary, secondary], "column") for two actions, or IconButtons on the right; real actions only). The fallback block: use it only when no specialised card (ImageCard, InsightCard, ValueCard, EntityList, ListBlock, OptionCards) fits, for products, offers and plans that need mixed content. Keep one strong block per card: never put an EntityTitle header next to another big block (media, a chart, an ImageTitle, a large Metric); with a photo or chart hero, title the card with a plain Text, or a LeadingText(Icon(...), Text(..., "body", true)) when an icon helps.', + "- ImageCard: 2+ tall ImageCardItem(id?, src, title, subtitle?, overline?, leading?, alt?, actions?) photo tiles for places, products, media or inspiration: a real photo behind an overline (top left), optional IconButton actions (top right), and the title with a composable subtitle (bottom). Only with real image URLs for every item, otherwise InsightCard; IconButtons only for real @OpenUrl or @ToAssistant actions, never save or share.", ], }, ]; @@ -391,14 +547,21 @@ export const openuiChatLibrary = createLibrary({ components: [ // Root ChatCard, - CardHeader, // Content - TextContent, - MarkDownRenderer, + Text, + Markdown, + Metric, + Avatar, + Emoji, + Shape, + AvatarStack, + Heading, + LeadingText, + EntityTitle, + ImageTitle, + Link, Callout, - TextCallout, Image, - ImageBlock, ImageGallery, CodeBlock, Separator, @@ -423,8 +586,8 @@ export const openuiChatLibrary = createLibrary({ Point, // Forms Form, + FormRow, FormControl, - Label, Input, TextArea, Select, @@ -446,6 +609,7 @@ export const openuiChatLibrary = createLibrary({ FollowUpBlock, FollowUpItem, // Sections + ChatSection, ChatSectionBlock, ChatSectionItem, // Layout (no Stack) @@ -456,39 +620,38 @@ export const openuiChatLibrary = createLibrary({ Steps, StepsItem, ChatCarousel, + ChatGrid, + ChatShell, + ChatBleed, // Data Display TagBlock, Tag, EntityList, + EntityListItem, + Timeline, + TimelineItem, + Checklist, + ChecklistItem, // Content - InlineHeader, Icon, IconButton, // Tables (editable) EditableTable, + EditableCol, // Selection inputs ChipItem, Chips, OptionCard, OptionCards, - // Card building blocks - Text, - BoldText, - IconText, - ImageText, - ImageTextLarge, - MetricIndicatorInline, - MetricIndicatorWithStrikethrough, // Card blocks - SnippetCardItem, - SnippetCardBlock, - OverviewCardItem, - OverviewCardBlock, - ContextCardItem, - ContextCardBlock, + ValueCardItem, + ValueCard, + ValueLayout, + InsightCardItem, + InsightCard, CompositeCardItem, CompositeCardBlock, - VisualCardItem, - VisualCardBlock, + ImageCardItem, + ImageCard, ], }); diff --git a/packages/react-ui/src/genui-lib/openuiLibrary.tsx b/packages/react-ui/src/genui-lib/openuiLibrary.tsx index c713c9a4bf..d2b24cb9ba 100644 --- a/packages/react-ui/src/genui-lib/openuiLibrary.tsx +++ b/packages/react-ui/src/genui-lib/openuiLibrary.tsx @@ -4,20 +4,14 @@ import type { ComponentGroup } from "@openuidev/react-lang"; import { createLibrary } from "@openuidev/react-lang"; // Card building blocks -import { BoldText } from "./BoldText"; import { EntityList } from "./EntityList"; +import { EntityListItem } from "./EntityListItem"; import { IconButton } from "./IconButton"; -import { IconText } from "./IconText"; -import { ImageText } from "./ImageText"; -import { ImageTextLarge } from "./ImageTextLarge"; -import { InlineHeader } from "./InlineHeader"; import { ListBlock } from "./ListBlock"; import { ListItem } from "./ListItem"; -import { MetricIndicatorInline, MetricIndicatorWithStrikethrough } from "./MetricIndicator"; -import { Text } from "./Text"; // Editable table -import { EditableTable } from "./EditableTable"; +import { EditableCol, EditableTable } from "./EditableTable"; // Selection inputs import { ChipItem, Chips } from "./Chips"; @@ -25,22 +19,29 @@ import { OptionCard, OptionCards } from "./OptionCards"; // Card blocks import { CompositeCardBlock, CompositeCardItem } from "./CompositeCardBlock"; -import { ContextCardBlock, ContextCardItem } from "./ContextCardBlock"; -import { OverviewCardBlock, OverviewCardItem } from "./OverviewCardBlock"; -import { SnippetCardBlock, SnippetCardItem } from "./SnippetCardBlock"; -import { VisualCardBlock, VisualCardItem } from "./VisualCardBlock"; +import { ImageCard, ImageCardItem } from "./ImageCard"; +import { InsightCard, InsightCardItem } from "./InsightCard"; +import { ValueCard, ValueCardItem } from "./ValueCard"; +import { ValueLayout } from "./ValueLayout"; // Content +import { Avatar } from "./Avatar"; +import { AvatarStack } from "./AvatarStack"; import { Callout } from "./Callout"; import { Card } from "./Card"; -import { CardHeader } from "./CardHeader"; import { CodeBlock } from "./CodeBlock"; +import { Emoji } from "./Emoji"; +import { EntityTitle } from "./EntityTitle"; +import { Heading } from "./Heading"; import { Image } from "./Image"; -import { ImageBlock } from "./ImageBlock"; import { ImageGallery } from "./ImageGallery"; -import { MarkDownRenderer } from "./MarkDownRenderer"; -import { TextCallout } from "./TextCallout"; -import { TextContent } from "./TextContent"; +import { ImageTitle } from "./ImageTitle"; +import { LeadingText } from "./LeadingText"; +import { Link } from "./Link"; +import { Markdown } from "./Markdown"; +import { Metric } from "./Metric"; +import { Shape } from "./Shape"; +import { Text } from "./Text"; // Charts import { @@ -62,10 +63,9 @@ import { // Forms import { CheckBoxGroup, CheckBoxItem } from "./CheckBoxGroup"; import { DatePicker } from "./DatePicker"; -import { Form } from "./Form"; +import { Form, FormRow } from "./Form"; import { FormControl } from "./FormControl"; import { Input } from "./Input"; -import { Label } from "./Label"; import { RadioGroup, RadioItem } from "./RadioGroup"; import { Select, SelectItem } from "./Select"; import { Slider } from "./Slider"; @@ -78,11 +78,22 @@ import { Buttons } from "./Buttons"; // Layout import { Accordion, AccordionItem } from "./Accordion"; +import { Bleed } from "./Bleed"; import { Carousel } from "./Carousel"; +import { Checklist, ChecklistItem } from "./Checklist"; +import { Grid } from "./Grid"; +import { Section } from "./Section"; import { Separator } from "./Separator"; +import { Shell } from "./Shell"; import { Stack } from "./Stack"; import { Steps, StepsItem } from "./Steps"; import { TabItem, Tabs } from "./Tabs"; +import { Timeline, TimelineItem } from "./Timeline"; + +// Response endings +import { FollowUpBlock } from "./FollowUpBlock"; +import { FollowUpItem } from "./FollowUpItem"; +import { Sources } from "./Sources"; // Data Display import { Icon } from "./Icon"; @@ -100,6 +111,10 @@ export const openuiComponentGroups: ComponentGroup[] = [ name: "Layout", components: [ "Stack", + "Section", + "Grid", + "Shell", + "Bleed", "Tabs", "TabItem", "Accordion", @@ -111,9 +126,10 @@ export const openuiComponentGroups: ComponentGroup[] = [ "Modal", ], notes: [ - '- For grid-like layouts, use Stack with direction "row" and wrap set to true.', - '- Prefer justify "start" (or omit justify) with wrap=true for stable columns instead of uneven gutters.', + "- Shell(title, [children]) frames a dashboard or large Grid layout: a small title, an outline, wider than the chat column, with an expand button for full screen. At most one per response. Bleed([children]) is the same width with no frame, for a wide hero Image, comparison Grid or Carousel; wrap a Grid comparing an odd number of cards (3 or 5 plans) in a Bleed, Bleed([Grid([p1, p2, p3], 3)]), so they sit in one row instead of 2 + 1. Never nest either.", + "- For dashboards and equal columns use Grid(children, columns?): columns 2 (default) | 3 | 4, collapsing to fewer on narrow screens, e.g. a KPI row Grid([c1, c2, c3, c4], 4) above Grid([chartCard1, chartCard2]). Use a Stack row only for a few items of mixed width.", "- Use nested Stacks when you need explicit rows/sections.", + "- Build every response from Sections: Section([children]) holds one part (an overview, then options, then next steps) with more space after it and tight spacing inside. Compose each part yourself: a Heading title first when it needs one, then content; rows or grids inside with Stack or card blocks. Never nested. Sections are high-level divisions and many responses have only one; never give a lone Text, Markdown, Buttons row or title (Heading or Text, with or without a subtitle) its own Section, so an intro or a dashboard header goes first inside the Section holding the content it introduces.", '- Show/hide sections: $editId != "" ? Card([editForm]) : null', '- Modal: Modal("Title", $showModal, [content]) — $showModal is boolean, X/Escape auto-closes. Put Form with its own buttons inside children.', "- Use Tabs for alternative views (chart types, data sections) — no $variable needed", @@ -124,37 +140,57 @@ export const openuiComponentGroups: ComponentGroup[] = [ name: "Content", components: [ "Card", - "CardHeader", - "TextContent", - "MarkDownRenderer", + "Text", + "Markdown", + "Metric", + "Avatar", + "Emoji", + "Shape", + "AvatarStack", + "Heading", + "LeadingText", + "EntityTitle", + "ImageTitle", + "Link", "Callout", - "TextCallout", "Image", - "ImageBlock", "ImageGallery", "CodeBlock", - "InlineHeader", ], notes: [ - "- InlineHeader is a compact heading + description pair for labelling a block (lighter than CardHeader).", - '- Use Cards to group related KPIs or sections. Stack with direction "row" for side-by-side layouts.', - '- Success toast: Callout("success", "Saved", "Done.", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == "error" ? Callout("error", "Failed", result.error) : null', - '- KPI card: Card([TextContent("Label", "small"), TextContent("" + @Count(@Filter(data.rows, "field", "==", "value")), "large-heavy")])', + '- Metric(value, delta?, variant?, size?, previous?) is a value with an optional change (+/- shows an up/down arrow); label it with the Card title inside a Card, or compose one with Text or Heading elsewhere. It uses the UI font, so use size "sm" whenever it is not the hero number: Table cells, EntityListItem right, ListItem/EntityTitle subtitles, ImageTitle content, ValueCardItem value, CompositeCardItem body (a CompositeCardItem footer price always renders at 18px), e.g. Col("Change", @Each(data.rows, "r", Metric(r.value, r.change, "horizontal", "sm"))). Only a standalone headline figure gets "vertical", "large", e.g. Card([Metric("$48,200", "+8.1%", "vertical", "large")], null, "Revenue"). previous (5th) strikes through an earlier value for a real before/after, like a price drop: Metric("$89", "-26%", null, "sm", "$120").', + '- Avatar is a photo, logo or tinted icon badge that can stand in for an Icon (LeadingText, Tag, Chips): Avatar("Ada Lovelace", "https://…/ada.jpg", null, null, null, Icon("user", "people")) for a person, Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")) for a company logo, Avatar("Billing", null, "rounded", null, "md", Icon("credit-card", "finance"), "info") for a standalone concept badge. ALWAYS pass an icon, even with src or domain: it renders when the image is missing or broken. src must be a real URL; a person with no real photo URL gets Icon("user", "people") inside and no src. The icon slot also takes a Shape for a solid colored mark. A concept on a text line stays a plain Icon.', + '- Heading("Title", "Supporting line") titles a UI section when the description adds a fact the title lacks (scope, unit, time range, what to do). Never a description that greets, introduces you, restates the title or says what the section contains; a bare title is Text h2/h3.', + "- Greetings, small talk and short conversational replies are one plain Markdown block with no heading, Heading or Text title.", + '- EntityTitle(leading, title, subtitle) names a person, company, product, place or topic behind an Avatar-first leading visual (a photo, a domain logo or a tinted icon badge): EntityTitle(Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")), "Stripe", "Payments infrastructure"). subtitle is required: a string, or a Tag, Metric, Link, TagBlock or LeadingText for a status, price, source or labels. A title with no subtitle is a LeadingText. Heading titles a section.', + '- ImageTitle(Image(alt, src, ratio), [content]) is one item in a grid or Carousel of 2+ products, articles or places: image on top, then a short content stack, usually a title Text("…", "body", true) (an EntityTitle only for a real person, brand or seller with their own photo or logo, never an icon Avatar), then one secondary line (an article author as a LeadingText with an inline Avatar and tone "secondary", a price Metric, Tags). Keep every item the same shape and ratio; a single illustrative picture is Image plus Markdown. Real image URLs only.', + '- LeadingText(leading, Text(...)) puts an Icon, Avatar or AvatarStack before any Text, centered on its first line: Icon for a concept or fact (LeadingText(Icon("calendar", "time"), Text("Opens daily 9am–6pm"))), Shape for a status dot, legend marker or trend triangle (LeadingText(Shape("triangle-up", "success"), Text("+12% this week"))), Avatar for one real person or company, AvatarStack for a group of them, and rarely an Emoji for expressive generated content (never in UI chrome). Avatars here use size "inline".', + "- AvatarStack([a1, a2, a3, a4, a5], 3) overlaps 2+ Avatars with a count chip for the rest: a team, attendees, who replied. Keep max at 3–5, set size on the stack, and name the group in Text beside it.", + "- Markdown is for explanations and the top-level prose that sets context around UI. Text is for short UI text: headings, labels, captions, single values.", + "- Answering a question with a title and prose: one Markdown block with a # heading, not Text h1 + Markdown. Text headings title UI (dashboards, forms, card groups); a part of a longer multi-part response is a Section that starts with a Heading.", + '- Callout("warning", "**Visa required.** Apply 3 weeks ahead.") is an icon (or a rare Emoji) plus short Markdown content for one standout aside (tip, caveat, warning, result), with no title. Pick variant by meaning; one or two per response at most; never for notes about what you did.', + "- Highlight in Markdown with ==key phrase==: a soft green marker behind the text.", + "- Use it for the one phrase that directly answers the question, at most 1-2 per response. Never for whole sentences, headings, links or code.", + "- Links: in Markdown write [descriptive text](https://…); the site's favicon renders before the link text automatically. Never use a bare URL as link text.", + '- For a standalone link outside prose (a source, docs or product page), use Link("Page name", "https://…"). When a Link is the title of a ListItem, StepsItem or TimelineItem, pass size "lg" so it matches the title size: Link("Septime", "https://…", "lg").', + '- Use Cards to group related KPIs or blocks: variant "outlined" (default) for ordinary groups, "lifted" for the one block that should stand out, "sunk" for secondary or nested content. For grouping without a frame use Stack; Stack with direction "row" for side-by-side layouts. A Card\'s children stack on their own; for a row or custom spacing inside a Card, pass one Stack child, e.g. Card([Stack([chartA, chartB], "row", "m")]).', + '- Success toast: Callout("success", "**Saved.** Your changes are live.", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == "error" ? Callout("error", result.error) : null', + '- KPI card: Card([Metric("" + @Count(@Filter(data.rows, "field", "==", "value")), null, "vertical", "large")], null, "Label")', ], }, { name: "Tables", - components: ["Table", "Col", "EditableTable"], + components: ["Table", "Col", "EditableTable", "EditableCol"], notes: [ - "- EditableTable lets the user edit cells inline. Give it a unique name, columns of { type, key, header } with type one of text | number | date-single | select | url (select also needs options: [{ value, label }]).", - "- data is an array of { id, values } rows where values are ordered positionally to match columns. Edited data is submitted when the user clicks Save Changes.", + '- EditableTable(name, [EditableCol(key, label, type?, options?, icon?)], rows) lets the user edit cells inline. type is the cell editor: "text" (default), "number", "date", "select" (options as SelectItems) or "url"; each column shows a leading header icon for its type unless you pass an Icon.', + "- rows is an array of value arrays in column order (dates as ISO strings, selects as the SelectItem value). Edited data is submitted when the user clicks Save Changes.", '- Table is COLUMN-oriented: Table([Col("Label", dataArray), Col("Count", countArray, "number")]). Use array pluck for data: data.rows.fieldName', - '- Col data can be component arrays for styled cells: Col("Status", @Each(data.rows, "item", Tag(item.status, null, "sm", item.status == "open" ? "success" : "danger")))', + '- Col data can be component arrays for styled cells: Col("Status", @Each(data.rows, "item", Tag(item.status, null, item.status == "open" ? "success" : "danger")))', '- Row actions: Col("Actions", @Each(data.rows, "t", Button("Edit", Action([@Set($showEdit, true), @Set($editId, t.id)]))))', '- Sortable: sorted = @Sort(data.rows, $sortField, "desc"). Bind $sortField to Select. Use sorted.fieldName for Col data', '- Searchable: filtered = @Filter(data.rows, "title", "contains", $search). Bind $search to Input', "- Chain sort + filter: filtered = @Filter(...) then sorted = @Sort(filtered, ...) — use sorted for both Table and Charts", - '- Empty state: @Count(data.rows) > 0 ? Table([...]) : TextContent("No data yet")', + '- Empty state: @Count(data.rows) > 0 ? Table([...]) : Text("No data yet")', ], }, { @@ -169,7 +205,7 @@ export const openuiComponentGroups: ComponentGroup[] = [ ], notes: [ '- Charts accept column arrays: LineChart(labels, [Series("Name", values)]). Use array pluck: LineChart(data.rows.day, [Series("Views", data.rows.views)])', - "- Use Cards to wrap charts with CardHeader for titled sections", + '- Use Cards to wrap charts, titled with the Card title and subtitle args, e.g. Card([chart], null, "Revenue by month", "Jul–Sep"), never a Heading or Text title inside the Card, but never wrap a Table or EditableTable in a Card: tables draw their own frame, so they sit directly in a Section or TabItem.', "- Chart + Table from same source: use @Sort or @Filter result for both LineChart and Table Col data", '- Multiple chart views: use Tabs — Tabs([TabItem("line", "Line", [LineChart(...)]), TabItem("bar", "Bar", [BarChart(...)])])', ], @@ -180,7 +216,7 @@ export const openuiComponentGroups: ComponentGroup[] = [ notes: [ "- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:", '- PieChart from list: `PieChart(["Low", "Med", "High"], [@Count(@Filter(data.rows, "priority", "==", "low")), @Count(@Filter(data.rows, "priority", "==", "medium")), @Count(@Filter(data.rows, "priority", "==", "high"))], "donut")`', - '- KPI from count: `TextContent("" + @Count(@Filter(data.rows, "status", "==", "open")), "large-heavy")`', + '- KPI from count: `Metric("" + @Count(@Filter(data.rows, "status", "==", "open")), null, "vertical", "large")`', ], }, { @@ -191,8 +227,8 @@ export const openuiComponentGroups: ComponentGroup[] = [ name: "Forms", components: [ "Form", + "FormRow", "FormControl", - "Label", "Input", "TextArea", "Select", @@ -212,7 +248,9 @@ export const openuiComponentGroups: ComponentGroup[] = [ ], notes: [ "- Chips: compact single/multiple selection pills. Use ChipItem references for each option.", - "- OptionCards: larger selectable cards with title, subtitle and an optional Icon or Image on top. Use OptionCard references for each option.", + "- OptionCards: larger selectable cards with a title, a composable subtitle and an optional leading (Icon, Avatar, Image or rare Emoji); the selected card shows a checkmark. Use OptionCard references for each option; use it for 2–6 choices that need a short explanation, Chips for short labels.", + "- FormRow([fieldA, fieldB]) puts 2–3 short related FormControls side by side (first and last name; city, state and zip) and stacks them on narrow widths. Mix FormRows with full-width FormControls in Form fields; keep TextArea and other long fields full width.", + '- Pick Input type for what is entered ("email", "password", "number", "url", "search", "tel", or "text") since it sets the leading icon automatically. Use variant "inline" on Input, TextArea, Select and DatePicker only inside table cells or dense rows, where a borderless field fits; "default" everywhere else.', "- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming.", "- NEVER nest Form inside Form — each Form should be a standalone container.", "- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.", @@ -227,48 +265,62 @@ export const openuiComponentGroups: ComponentGroup[] = [ name: "Buttons", components: ["Button", "Buttons", "IconButton"], notes: [ - "- Icon renders a lucide icon by kebab-case name; it is also used as the icon of IconButton, IconText and OptionCard.", '- Toggle in @Each: @Each(rows, "t", Button(t.status == "open" ? "Close" : "Reopen", Action([...])))', ], }, { name: "Data Display", - components: ["TagBlock", "Tag", "Icon", "EntityList", "ListBlock", "ListItem"], + components: [ + "TagBlock", + "Tag", + "Icon", + "EntityList", + "EntityListItem", + "ListBlock", + "ListItem", + "Timeline", + "TimelineItem", + "Checklist", + "ChecklistItem", + ], notes: [ - '- Color-mapped Tag: Tag(value, null, "sm", value == "high" ? "danger" : value == "medium" ? "warning" : "neutral")', - "- EntityList is a compact two-column list of { left, right } rows (e.g. name / value). size='default' also supports a header and footer row; size='small' does not.", - "- ListBlock is a numbered or image list of ListItem references. An action on ListItem is optional.", + "- Icon is a lucide line icon by exact kebab-case name that fills icon slots (LeadingText, Tag, ChipItem, Avatar, EntityTitle, IconButton, OptionCard); the host sets its size, stroke and color. An unknown name shows a plain circle.", + '- Color-mapped Tag: Tag(value, null, value == "high" ? "danger" : value == "medium" ? "warning" : "neutral")', + "- EntityList is a compact list of EntityListItem(left, right?, rightVariant?) rows. left is a plain string for key/value facts, an EntityTitle for people, companies or products, or a LeadingText for facts with an icon; right is an optional string, Metric, Tag or Text. size='default' also supports a header and footer row; size='small' does not.", + "- ListBlock is a numbered (default) or plain list of ListItem references. Numbered suits rankings and ordered picks; plain suits people, companies or places that lead with an Avatar. A ListItem is clickable only with an action.", + "- Timeline lists events in time order along a dashed rail, with an optional time column on the left: itineraries, schedules, project histories, order tracking. TimelineItem(title, subtitle?, time?, leading?, status?); status done/current/upcoming shows progress through a plan. Use Steps for numbered how-to instructions and ListBlock for unordered or ranked sets.", + '- Steps([StepsItem(title, subtitle?)]) is a numbered how-to; the number chips are automatic. title is a string or a Text (pass Text("Install the CLI", "body", true) to emphasize it), and subtitle is one or two plain sentences on how to do it.', + "- Checklist(items, name?, value?) with ChecklistItem(title, subtitle?, checked?, value?) is a checkable list of to-dos, packing items, launch or onboarding steps: the user ticks items, checked sets the starting state, and value is a stable key per item. Give it a name when the ticks should persist or you need to read them later. Inside a CompositeCardItem body it is read-only, for what a plan includes. Steps is for numbered how-tos, Timeline for time-ordered events.", ], }, { name: "Cards", components: [ - "SnippetCardBlock", - "SnippetCardItem", - "OverviewCardBlock", - "OverviewCardItem", - "ContextCardBlock", - "ContextCardItem", + "ValueCard", + "ValueCardItem", + "ValueLayout", + "InsightCard", + "InsightCardItem", "CompositeCardBlock", "CompositeCardItem", - "VisualCardBlock", - "VisualCardItem", - "Text", - "BoldText", - "IconText", - "ImageText", - "ImageTextLarge", - "MetricIndicatorInline", - "MetricIndicatorWithStrikethrough", + "ImageCard", + "ImageCardItem", ], notes: [ "- Card blocks lay out 2+ items in a responsive grid (or carousel where supported). Every item in a block must have the same structure.", - "- SnippetCardItem: small card with lhs (IconText | ImageText) and optional rhs (Text | BoldText) — good for key/value facts.", - "- OverviewCardItem: small card with top (IconText | ImageText | Text) and optional bottom MetricIndicatorInline — good for KPIs.", - "- ContextCardItem: medium card with a title (string or Tag), body text and optional background image — good for summaries.", - "- CompositeCardItem: rich card with header, body array (Text, BoldText, MetricIndicatorInline, IconText, Image, charts, ListBlock, TagBlock, EntityList) and footer (price + Button) — good for products/offers.", - "- VisualCardItem: image-first card with a BoldText body and optional Tag.", - "- Text / BoldText / IconText / ImageText / ImageTextLarge / MetricIndicator* are the inline building blocks used INSIDE card items; do not place them directly in the root Card.", + '- ValueCard: a grid of 2+ small ValueCardItem(id?, title, subtitle?, leading?, value?) cards, e.g. ValueCardItem("revenue", "Revenue", "Month to date", Icon("circle-dollar-sign", "finance"), Metric("$48,200", "+8.1%", null, "sm")). leading is an Icon for a concept or an Avatar for a real person or company (36px beside a stacked title and subtitle in horizontal cards, inline before the title otherwise); subtitle and value each take one piece or an array on one line, e.g. value [Metric("99.98%"), Tag("Healthy", null, "success")]. layout "horizontal" (default) for key/value facts (value on the right, Metric at "sm"); "vertical" for KPI tiles (a hero Metric at the default size), optionally with carousel. Set layout on the group, never per item: "horizontal" only when every value is one short piece (one Metric or short Text), otherwise "vertical" for the whole block. Keep every item the same shape, and use one generic shared action at most.', + "- ValueLayout takes the same ValueCardItems and layout rules as ValueCard but draws dashed rules instead of tinted cards and has no action. Use ValueCard for dashboard-like output (analytics, monitoring) and ValueLayout as an inline section of figures inside a normal UI response.", + "- InsightCard: 2+ tall InsightCardItem(id?, title, subtitle?, overline?, leading?, color?) tiles, each one takeaway: a short overline (with an optional leading) at the top, and the title (the insight, 22px) with a composable subtitle at the bottom, on a palette color (picked by meaning, never invented); no photo, which is ImageCard. Keep the overline to a few words and the title to one short insight, never a Tag. Use it for research takeaways, trends or news with a status Tag in the subtitle, topics to explore, plan options and suggested next topics.", + '- CompositeCardItem: rich white card with a header (EntityTitle, ImageTitle, Image, or a Text("…", "h3") title), an optional edge-to-edge media at the top (5th argument: a real photo Image, or an AreaChart, BarChart or LineChart for a metric or trend card), a body array (Text as 14px body copy, Metric at sm, EntityTitle, Image, charts, ListBlock, TagBlock, EntityList, or a Checklist for what a plan includes) and a footer (price Metric, always 18px, with previous for a sale, plus a Button, Buttons([primary, secondary], "column") for two actions, or IconButtons on the right; real actions only). The fallback block: use it only when no specialised card (ImageCard, InsightCard, ValueCard, EntityList, ListBlock, OptionCards) fits, for products, offers and plans that need mixed content. Keep one strong block per card: never put an EntityTitle header next to another big block (media, a chart, an ImageTitle, a large Metric); with a photo or chart hero, title the card with a plain Text, or a LeadingText(Icon(...), Text(..., "body", true)) when an icon helps.', + "- ImageCard: 2+ tall ImageCardItem(id?, src, title, subtitle?, overline?, leading?, alt?, actions?) photo tiles for places, products, media or inspiration: a real photo behind an overline (top left), optional IconButton actions (top right), and the title with a composable subtitle (bottom). Only with real image URLs for every item, otherwise InsightCard; IconButtons only for real @OpenUrl or @ToAssistant actions, never save or share.", + ], + }, + { + name: "Response endings", + components: ["FollowUpBlock", "FollowUpItem", "Sources"], + notes: [ + '- FollowUpBlock([FollowUpItem("Show 5-year price performance"), FollowUpItem("Add NVDA to this comparison")]) lists 3–4 short next questions under a "Related Queries" header; clicking one sends its text as the user\'s next message. Put it last in the final Section of an answer that invites more exploration (research, comparisons, plans), never after a clarifying question or a form.', + "- Sources([{ title, sourceName, url }]) is a row of source cards with favicons. Add it only when the answer used real references from a search or tool result, never invented ones, as the very last child of the final Section, after any FollowUpBlock.", ], }, ]; @@ -284,20 +336,27 @@ export const openuiLibrary = createLibrary({ components: [ // Content Card, - CardHeader, - TextContent, - MarkDownRenderer, + Text, + Markdown, + Metric, + Avatar, + Emoji, + Shape, + AvatarStack, + Heading, + LeadingText, + EntityTitle, + ImageTitle, + Link, Callout, - TextCallout, Image, - ImageBlock, ImageGallery, CodeBlock, - InlineHeader, // Tables Table, Col, EditableTable, + EditableCol, // Charts (2D) BarChartCondensed, LineChartCondensed, @@ -317,8 +376,8 @@ export const openuiLibrary = createLibrary({ // Forms Form, + FormRow, FormControl, - Label, Input, TextArea, Select, @@ -341,6 +400,10 @@ export const openuiLibrary = createLibrary({ IconButton, // Layout Stack, + Section, + Grid, + Shell, + Bleed, Tabs, TabItem, Accordion, @@ -354,28 +417,28 @@ export const openuiLibrary = createLibrary({ Tag, Icon, EntityList, + EntityListItem, ListBlock, ListItem, - // Card building blocks - Text, - BoldText, - IconText, - ImageText, - ImageTextLarge, - MetricIndicatorInline, - MetricIndicatorWithStrikethrough, + Timeline, + TimelineItem, + Checklist, + ChecklistItem, // Card blocks - SnippetCardItem, - SnippetCardBlock, - OverviewCardItem, - OverviewCardBlock, - ContextCardItem, - ContextCardBlock, + ValueCardItem, + ValueCard, + ValueLayout, + InsightCardItem, + InsightCard, CompositeCardItem, CompositeCardBlock, - VisualCardItem, - VisualCardBlock, + ImageCardItem, + ImageCard, // Modal Modal, + // Response endings + FollowUpBlock, + FollowUpItem, + Sources, ], }); diff --git a/packages/react-ui/src/genui-lib/prompt-options/index.ts b/packages/react-ui/src/genui-lib/prompt-options/index.ts index 96d5db0c1a..25ae405550 100644 --- a/packages/react-ui/src/genui-lib/prompt-options/index.ts +++ b/packages/react-ui/src/genui-lib/prompt-options/index.ts @@ -7,8 +7,9 @@ import type { PromptOptions } from "@openuidev/react-lang"; export const openuiExamples: string[] = [ `Example 1 — Table (column-oriented): -root = Stack([title, tbl]) -title = TextContent("Top Languages", "large-heavy") +root = Stack([main]) +main = Section([title, tbl]) +title = Text("Top Languages", "h2") tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)]) langs = ["Python", "JavaScript", "Java", "TypeScript", "Go"] users = [15.7, 14.2, 12.1, 8.5, 5.2] @@ -16,8 +17,9 @@ years = [1991, 1995, 1995, 2012, 2009]`, `Example 2 — Bar chart: -root = Stack([title, chart]) -title = TextContent("Q4 Revenue", "large-heavy") +root = Stack([main]) +main = Section([title, chart]) +title = Text("Q4 Revenue", "h2") chart = BarChart(labels, [s1, s2], "grouped") labels = ["Oct", "Nov", "Dec"] s1 = Series("Product A", [120, 150, 180]) @@ -25,9 +27,11 @@ s2 = Series("Product B", [90, 110, 140])`, `Example 3 — Form with validation: -root = Stack([title, form]) -title = TextContent("Contact Us", "large-heavy") -form = Form("contact", btns, [nameField, emailField, countryField, msgField]) +root = Stack([main]) +main = Section([title, form]) +title = Text("Contact Us", "h2") +form = Form("contact", btns, [nameRow, countryField, msgField]) +nameRow = FormRow([nameField, emailField]) 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 })) countryField = FormControl("Country", Select("country", countryOpts, "Select...", { required: true })) @@ -37,59 +41,59 @@ btns = Buttons([Button("Submit", @ToAssistant("Submit"), "primary"), Button("Can `Example 4 — Tabs with mixed content: -root = Stack([title, tabs]) -title = TextContent("React vs Vue", "large-heavy") +root = Stack([main]) +main = Section([title, tabs]) +title = Text("React vs Vue", "h2") tabs = Tabs([tabReact, tabVue]) tabReact = TabItem("react", "React", reactContent) tabVue = TabItem("vue", "Vue", vueContent) -reactContent = [TextContent("React is a library by Meta for building UIs."), Callout("info", "Note", "React uses JSX syntax.")] -vueContent = [TextContent("Vue is a progressive framework by Evan You."), Callout("success", "Tip", "Vue has a gentle learning curve.")]`, +reactContent = [Markdown("React is a library by Meta for building UIs."), Callout("info", "React uses JSX syntax.")] +vueContent = [Markdown("Vue is a progressive framework by Evan You."), Callout("success", "Vue has a gentle learning curve.")]`, `Example 5 — KPI and product cards: -root = Stack([kpiHeader, kpiBlock, productsHeader, productCards]) -kpiHeader = InlineHeader("This Month", "Key account metrics") -kpiBlock = SnippetCardBlock([kpi1, kpi2, kpi3]) -kpi1 = SnippetCardItem("revenue", kpi1lhs, kpi1rhs) -kpi1lhs = IconText(kpi1icon, "neutral", "m", "Revenue", "Month to date", false, "horizontal") +root = Stack([secKpis, secProducts]) +secKpis = Section([kpiHeader, kpiBlock]) +secProducts = Section([productsHeader, productCards]) +kpiHeader = Text("This Month", "h3") +kpiBlock = ValueCard([kpi1, kpi2, kpi3]) +kpi1 = ValueCardItem("revenue", "Revenue", "Month to date", kpi1icon, kpi1rhs) kpi1icon = Icon("circle-dollar-sign", "finance") -kpi1rhs = BoldText("number", "$48,200", "+8.1%", "metric") -kpi2 = SnippetCardItem("active-users", kpi2lhs, kpi2rhs) -kpi2lhs = IconText(kpi2icon, "neutral", "m", "Active Users", "This month", false, "horizontal") +kpi1rhs = Metric("$48,200", "+8.1%", null, "sm") +kpi2 = ValueCardItem("active-users", "Active Users", "This month", kpi2icon, kpi2rhs) kpi2icon = Icon("users", "people") -kpi2rhs = BoldText("number", "1,204", "+3.4%", "metric") -kpi3 = SnippetCardItem("churn", kpi3lhs, kpi3rhs) -kpi3lhs = IconText(kpi3icon, "neutral", "m", "Churn", "This month", false, "horizontal") +kpi2rhs = Metric("1,204", "+3.4%", null, "sm") +kpi3 = ValueCardItem("churn", "Churn", "This month", kpi3icon, kpi3rhs) kpi3icon = Icon("user-minus", "people") -kpi3rhs = BoldText("number", "1.8%", "-0.3%", "metric") -productsHeader = InlineHeader("Top Products", "By units sold") +kpi3rhs = Metric("1.8%", "-0.3%", null, "sm") +productsHeader = Heading("Top Products", "By units sold") productCards = CompositeCardBlock([p1, p2]) -p1 = CompositeCardItem("widget-pro", p1header, [p1body], p1footer) -p1header = IconText(p1icon, "neutral", "m", "Widget Pro", "Best seller", true, "horizontal") +p1 = CompositeCardItem("widget-pro", p1header, [p1bodyLabel, p1body], p1footer) +p1header = EntityTitle(p1icon, "Widget Pro", "Best seller") p1icon = Icon("package", "shopping") -p1body = MetricIndicatorInline("2,410 units", "Sold this month", { direction: "up", value: 12 }) -p1footer = { price: BoldText("text", "$29.00"), button: p1btn } +p1bodyLabel = Text("Sold this month", "small") +p1body = Metric("2,410 units", "+12%", null, "sm") +p1footer = { price: Metric("$29.00"), button: p1btn } p1btn = Button("View Details", @ToAssistant("Show details for Widget Pro"), "secondary") -p2 = CompositeCardItem("widget-mini", p2header, [p2body], p2footer) -p2header = IconText(p2icon, "neutral", "m", "Widget Mini", "New arrival", true, "horizontal") +p2 = CompositeCardItem("widget-mini", p2header, [p2bodyLabel, p2body], p2footer) +p2header = EntityTitle(p2icon, "Widget Mini", "New arrival") p2icon = Icon("box", "shopping") -p2body = MetricIndicatorInline("980 units", "Sold this month", { direction: "up", value: 4 }) -p2footer = { price: BoldText("text", "$14.00"), button: p2btn } +p2bodyLabel = Text("Sold this month", "small") +p2body = Metric("980 units", "+4%", null, "sm") +p2footer = { price: Metric("$14.00", null, null, null, "$19.00"), button: p2btn } p2btn = Button("View Details", @ToAssistant("Show details for Widget Mini"), "secondary")`, `Example 6 — Editable table and a selection form: -root = Stack([tableHeader, editTable, formHeader, prefsForm]) -tableHeader = InlineHeader("Team Roster", "Click a cell to edit") -editTable = EditableTable("roster", [colName, colRole, colStart], [row1, row2]) -colName = { type: "text", key: "name", header: "Name" } -colRole = { type: "select", key: "role", header: "Role", options: [roleEng, roleDesign] } -roleEng = { value: "eng", label: "Engineering" } -roleDesign = { value: "design", label: "Design" } -colStart = { type: "date-single", key: "start", header: "Start Date" } -row1 = { id: "1", values: ["Alex Kim", "eng", "2024-01-15"] } -row2 = { id: "2", values: ["Jamie Lee", "design", "2024-03-02"] } -formHeader = InlineHeader("Preferences", "Tell us how you like to work") +root = Stack([secTable, secPrefs]) +secTable = Section([tableHeader, editTable]) +secPrefs = Section([formHeader, prefsForm]) +tableHeader = Heading("Team Roster", "Click a cell to edit") +editTable = EditableTable("roster", [colName, colRole, colStart], [["Alex Kim", "eng", "2024-01-15"], ["Jamie Lee", "design", "2024-03-02"]]) +colName = EditableCol("name", "Name") +colRole = EditableCol("role", "Role", "select", [SelectItem("eng", "Engineering"), SelectItem("design", "Design")]) +colStart = EditableCol("start", "Start Date", "date") +formHeader = Text("Preferences", "h3") prefsForm = Form("prefs", formButtons, [fc1, fc2]) fc1 = FormControl("Working Style", styleCards, "Pick the one that fits best") styleCards = OptionCards("style", "single", [style1, style2]) @@ -106,11 +110,60 @@ tool2icon = Icon("message-square", "communication") tool3 = ChipItem("notion", "Notion", tool3icon) tool3icon = Icon("notebook", "text") formButtons = Buttons([Button("Save", @ToAssistant("Save preferences"), "primary")])`, + `Example 7 — Dashboard (one Section holding a Shell: KPI Grid, chart Grid, then a wide table outside the Grids; Cards are titled with their own title/subtitle args): + +root = Stack([dash]) +dash = Section([shell]) +shell = Shell("Sales Overview · Q3 2026", [kpis, charts, ordersTbl]) +kpis = Grid([k1, k2, k3], 3) +k1 = Card([Metric("$1.24M", "+8.1%", "vertical", "large"), LineChart(weeks, [Series("Revenue", [88, 92, 95, 101, 104, 110])], "natural", null, null, 56)], null, "Revenue", "Last 6 weeks") +k2 = Card([Metric("18,420", "+4.3%", "vertical", "large"), LineChart(weeks, [Series("Orders", [2.8, 2.9, 3.0, 3.1, 3.2, 3.3])], "natural", null, null, 56)], null, "Orders", "Last 6 weeks") +k3 = Card([Metric("1.9%", "-0.4%", "vertical", "large"), LineChart(weeks, [Series("Refunds", [2.4, 2.3, 2.2, 2.1, 2.0, 1.9])], "natural", null, null, 56)], null, "Refund rate", "Last 6 weeks") +weeks = ["W1", "W2", "W3", "W4", "W5", "W6"] +charts = Grid([revCard, regionCard]) +revCard = Card([AreaChart(["Jul", "Aug", "Sep"], [Series("Revenue ($k)", [380, 405, 455])], "natural", "Month", "Revenue ($k)")], null, "Revenue by month", "Jul–Sep") +regionCard = Card([BarChart(["NA", "EU", "APAC"], [Series("Orders", [8200, 6100, 4120])], "grouped", "Region", "Orders")], null, "Orders by region", "Q3 total") +ordersTbl = Table([Col("Region", ["NA", "EU", "APAC"]), Col("Revenue ($k)", [560, 410, 270], "number"), Col("Growth", [Metric("+9.2%", null, null, "sm"), Metric("+6.8%", null, null, "sm"), Metric("+11.4%", null, null, "sm")])])`, + `Example 8 — Image-led answer for a visual topic (the photos came from an image search tool call; with no real URLs, skip the gallery and ImageCards and compose with text and cards): + +root = Stack([look, options]) +look = Section([intro, gallery]) +intro = Markdown("# Rustic Barn Wedding Ideas\\n\\nWarm wood, string lights and wildflowers make a barn wedding feel ==relaxed and personal==. Here is the look, then three venue styles to start from.") +gallery = ImageGallery([{ src: "https://images.unsplash.com/photo-1519741497674-611481863552", alt: "Barn reception with string lights", details: "String lights over long farm tables" }, { src: "https://images.unsplash.com/photo-1465495976277-4387d4b0b4c6", alt: "Wildflower bouquet", details: "Seasonal wildflower bouquets" }, { src: "https://images.unsplash.com/photo-1511285560929-80b456fea0bc", alt: "Wooden ceremony arch", details: "Wooden ceremony arch in a meadow" }]) +options = Section([venuesTitle, wideVenues, nextBtns]) +venuesTitle = Heading("Venue styles", "Tap one to see venues and typical costs") +wideVenues = Bleed([venues]) +venues = ImageCard([v1, v2, v3], "grid", true, @ToAssistant("Show venues and typical costs for this style")) +v1 = ImageCardItem("classic-barn", "https://images.unsplash.com/photo-1464366400600-7168b8af9bc3", "Classic barn", ["80–150 guests", Tag("Most popular", null, "success")], "Rustic", null, "Red barn at sunset") +v2 = ImageCardItem("vineyard-barn", "https://images.unsplash.com/photo-1510076857177-7470076d4098", "Vineyard barn", ["50–120 guests", Tag("Wine pairing", null, "info")], "Rustic", null, "Vineyard rows beside a barn") +v3 = ImageCardItem("mountain-lodge", "https://images.unsplash.com/photo-1470770841072-f978cf4d019e", "Mountain lodge", ["40–100 guests", Tag("Scenic", null, "neutral")], "Rustic", null, "Lodge beside a mountain lake") +nextBtns = Buttons([Button("Plan my budget", @ToAssistant("Help me plan a rustic barn wedding budget"), "primary"), Button("Build a timeline", @ToAssistant("Build a 12-month wedding planning timeline"), "secondary")])`, + + `Example 9 — Clarifying question as visual choices, not a form (one question, tappable options, a stated default): + +root = Stack([main]) +main = Section([question, paceCards, skipBtns]) +question = Markdown("Happy to plan your Japan trip. **What pace do you want?** Tap one, or I will plan a balanced pace.") +paceCards = InsightCard([p1, p2, p3], "grid", true, @ToAssistant("Plan my Japan trip at this pace")) +p1 = InsightCardItem("relaxed", "2–3 sights a day, long meals", null, "Relaxed", Icon("coffee", "food"), "neutral") +p2 = InsightCardItem("balanced", "Highlights with downtime", null, "Balanced", Icon("scale", "tools"), "info") +p3 = InsightCardItem("packed", "See as much as possible", null, "Packed", Icon("zap", "weather"), "neutral") +skipBtns = Buttons([Button("Surprise me", @ToAssistant("Plan my Japan trip at a balanced pace"), "secondary", "normal", "default", Icon("sparkles", "ai"))])`, ]; +const openuiPreamble = + 'You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang.\n\nHow to shape every response — decide before writing, in this order:\n\n1) Visual first: when the topic is visual (travel, weddings, events, food, places, products, fashion, home and design), use an image search tool if one is available and lead with real photos: an ImageGallery or a hero Image (in a Bleed for width) near the top of the first Section, then an ImageCard grid or carousel for the options. Only use image URLs from a tool result or the user, never invented ones; with no real photos, compose the answer from text, icons and cards instead, never with a placeholder.\n\n2) Ask before guessing, with visual choices: when the request is ambiguous or missing a detail that changes the answer (a style, a budget range, which product or place), ask one short question and let the user pick instead of typing. Offer 2–5 concrete options plus your default: ImageCard tiles with real photos for visual options, InsightCard tiles or Buttons with leading icons for the rest, all with @ToAssistant("…") actions. A Markdown line asks the question and names the default you will use. Use a Form only for essential input that cannot be picked (a name, an email, exact dates, free text), with the fewest fields possible, never a long form of options the user could tap instead. Ask one question at a time, and never ask when the intent is clear.\n\n3) Match the UI to the need: a plain or conversational answer is concise Markdown in one Section; a visual answer leads with images as above; a structured or data answer uses the compositions (Card with title, Grid, Shell for a dashboard, Bleed for wide comparisons, Tabs, Accordion, Steps, Timeline, Checklist, Table and charts). Never add UI the answer does not need.'; + export const openuiAdditionalRules: string[] = [ "When asked about data, generate realistic/plausible data", - 'For grid-like layouts, use Stack with direction "row" and wrap=true. Avoid justify="between" unless you specifically want large gutters.', + "Build every response from Sections: root = Stack([section1, section2]), where each distinct part (an overview, then options, then next steps) is one Section; when the part needs a title, its first child is a Heading (or a prose Markdown block that starts with a # heading), followed by that part's content. A short single-part answer is a single Section. Sections are high-level divisions, and many responses need only one: never give a lone Text, Markdown or Buttons row its own Section, and never a Section that holds only a title (a Heading or Text, with or without its subtitle): the title always goes at the top of the Section holding the content it introduces, so a dashboard header sits in the same Section as its grid. An intro goes first inside the Section it introduces, and closing actions go last in the final Section. Never nest Sections and never leave blocks outside them.", + 'Markdown vs Text: use Markdown for explanations, summaries and the top-level prose that sets context around the UI (an intro before cards, a closing note, anything with several sentences, lists, tables or quotes). Use Text only for short UI text: headings (h1/h2/h3), labels, captions and single values. Text "body" is for a short multi-line caption inside a component, not for explanations.', + 'When answering a question with a title and prose, write one Markdown block that starts with a markdown heading (Markdown("# Google\\n\\nGoogle is ...")), not Text("Google", "h1") followed by Markdown. Markdown\'s headings carry the spacing prose needs; Text headings are for titling UI such as dashboards, forms and card groups.', + 'Metric(value, delta, variant, size, previous) is a value with an optional change; a leading +/- on delta shows a green up or red down arrow. It has no label: inside a Card use the Card title, otherwise compose one with Text or Heading (or the card item it sits in). It uses the UI font, so use size "sm" whenever it is not the hero number: in tables, EntityList rows, list and title subtitles, ImageTitle content, ValueCardItem value and CompositeCardItem body (a CompositeCardItem footer price always renders at 18px). Use variant "vertical" with size "large" only for a standalone headline figure such as a KPI tile. Pass previous (5th) only for a real before/after such as a price drop: Metric("$89", "-26%", null, "sm", "$120") strikes through $120.', + 'Card(children, variant?, title?, subtitle?) has a small built-in header: title a Card with its title and subtitle args, e.g. Card([chart], null, "Revenue by month", "Jul–Sep"), never a Heading or small Text label inside it.', + "Shell(title, children) frames a dashboard or a large Grid composition inside a Section: a small title, an outline, wider than the chat column, with an expand button. Use at most one per response. Bleed(children) is the same width with no frame, for a wide hero Image, Grid or Carousel. 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)]). Neither nests.", + "Use Grid(children, columns?) for equal columns: dashboards (custom KPI Cards in a 3–4 column Grid, chart Cards in a 2 column Grid) and sets of Cards that are not a carousel. A wide item such as a Table or a long Markdown goes outside the Grid, directly in the Section. When each KPI is only a label and a value, use ValueCard instead of hand-built KPI Cards. When a Grid compares an odd number of cards (3 or 5 plans or options), wrap it in a Bleed so they stay in one row. Use a Stack row only for a few items of mixed width.", + "Prefer visual choices over Forms: when you need input, offer options the user can tap (ImageCard tiles, InsightCard tiles, Buttons with leading icons, or OptionCards with images) so they do not have to fill anything in. A Form is only for essential input that cannot be picked, such as a name, an email, exact dates or free text, and keeps to the fewest fields.", "For forms, define one FormControl reference per field so controls can stream progressively.", "For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields).", "Never nest Form inside Form.", @@ -118,16 +171,17 @@ export const openuiAdditionalRules: string[] = [ "Multi-query refresh: Action([@Run(mutation), @Run(query1), @Run(query2), @Reset(...)])", "$variables are reactive: changing via Select or @Set re-evaluates all Queries and expressions referencing them", "Use existing components (Tabs, Accordion, Modal) before inventing ternary show/hide patterns", - "Card blocks (SnippetCardBlock, OverviewCardBlock, ContextCardBlock, CompositeCardBlock, VisualCardBlock) need at least 2 items; every item in a block must have the same structure.", - "Text / BoldText / IconText / ImageText / ImageTextLarge / MetricIndicatorInline / MetricIndicatorWithStrikethrough are inline building blocks used INSIDE card items — do not place them directly in a Stack or Card.", + "Card blocks (ValueCard, InsightCard, CompositeCardBlock, ImageCard) need at least 2 items; every item in a block must have the same structure.", + "CompositeCardBlock is the fallback card block: first use the specialised one that fits (ImageCard for photo tiles, InsightCard for takeaways, ValueCard or ValueLayout for figures, EntityList or ListBlock for rows, OptionCards or Chips for choices in a form), and use CompositeCardBlock only when a card needs mixed content none of them offers, such as a header, a body with charts or a Checklist, and a price or button footer.", "EntityList size='small' (no header/footer) is only for use inside a CompositeCardItem body; use size='default' everywhere else.", - "Image URLs must be real (from a tool result or the user) — never invent or template an image URL.", - "Always pass a category to Icon — it enables a topical fallback when the exact icon name is unavailable.", + 'Icon names must be exact lucide kebab-case names (e.g. "calendar", "circle-check"); an unknown name renders a plain circle, so prefer common icons over guesses.', + "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.", "EditableTable requires the consuming app to persist edits — use it only when the user or app explicitly asks for an editable table; default to Table for read-only data.", ]; export const openuiPromptOptions: PromptOptions = { examples: openuiExamples, + preamble: openuiPreamble, additionalRules: openuiAdditionalRules, }; @@ -136,51 +190,46 @@ export const openuiPromptOptions: PromptOptions = { export const openuiChatExamples: string[] = [ `Example 1 — Informational response (the shape for "tell me about X" / explainer asks): -root = Card([header, intro, highlights, statsHeader, statsCards, sectionsBlock, actionBtns], [src1, src2]) -header = CardHeader("Paris", "The City of Light · France's Eternal Capital") -intro = TextContent("Paris is one of the world's most visited cities — a timeless blend of iconic landmarks, world-class cuisine, art, and romance [1]. In 2026 the city is buzzing with new energy: the **Grand Palais** has reopened after a €466M renovation, and you can now **swim in the Seine** for the first time in over a century [2].") -highlights = ContextCardBlock([h1, h2, h3], "grid", true, @ToAssistant("Tell me more about this aspect of Paris")) -h1 = ContextCardItem("art-museums", "Art & Museums", "143 museums including the Louvre & newly reopened Grand Palais.", "gray", "https://cdn.britannica.com/03/121003-050-2544BD4E/Interior-Louvre-Museum-Paris.jpg") -h2 = ContextCardItem("gastronomy", "Gastronomy", "9,000+ restaurants, 130+ Michelin stars, and the world's best baguettes.", "gray") -h3 = ContextCardItem("fashion-capital", "Fashion Capital", "Home to Chanel, Dior, Louis Vuitton & Hermès — the global luxury hub.", "gray", "https://thumbs.dreamstime.com/z/dior-storefront-facade-chanel-store-lvmh-s-french-designer-modehouse-christian-adjacent-to-luxury-fashion-beauty-brand-327161553.jpg") -statsHeader = InlineHeader("Paris by the Numbers", "Key visitor statistics") -statsCards = OverviewCardBlock([ov1, ov2, ov3], "grid", true) -ov1 = OverviewCardItem("annual-visitors", ov1top, ov1metric) -ov1top = IconText(ov1icon, "neutral", "m", "Annual Visitors", "2024 arrivals", false, "vertical") +root = Card([secIntro, secStats, secExplore], [src1, src2]) +secIntro = Section([intro, highlights]) +secStats = Section([statsHeader, statsCards]) +secExplore = Section([sectionsBlock, actionBtns]) +intro = Markdown("# Paris\\n\\nParis is ==one of the world's most visited cities== — a timeless blend of iconic landmarks, world-class cuisine, art, and romance [1]. In 2026 the city is buzzing with new energy: the **Grand Palais** has reopened after a €466M renovation, and you can now **swim in the Seine** for the first time in over a century [2].") +highlights = InsightCard([h1, h2, h3], "grid", true, @ToAssistant("Tell me more about this aspect of Paris")) +h1 = InsightCardItem("art-museums", "143 museums including the Louvre & newly reopened Grand Palais.", null, "Art & Museums", null, "neutral") +h2 = InsightCardItem("gastronomy", "9,000+ restaurants, 130+ Michelin stars, and the world's best baguettes.", null, "Gastronomy", null, "neutral") +h3 = InsightCardItem("fashion-capital", "Home to Chanel, Dior, Louis Vuitton & Hermès — the global luxury hub.", null, "Fashion Capital", null, "neutral") +statsHeader = Text("Paris by the Numbers", "h3") +statsCards = ValueLayout([ov1, ov2, ov3], "vertical", true) +ov1 = ValueCardItem("annual-visitors", "Annual Visitors (2024)", null, ov1icon, ov1metric) ov1icon = Icon("users", "people") -ov1metric = MetricIndicatorInline("48.7M", "tourists", { direction: "up", value: 2.5 }) -ov2 = OverviewCardItem("museums-monuments", ov2top, ov2metric) -ov2top = IconText(ov2icon, "neutral", "m", "Museums & Monuments", "Cultural sites", false, "vertical") +ov1metric = Metric("48.7M", "+2.5%") +ov2 = ValueCardItem("museums-monuments", "Museums & Monuments", null, ov2icon, ov2metric) ov2icon = Icon("landmark", "buildings") -ov2metric = MetricIndicatorInline("2,370+", "across the city") -ov3 = OverviewCardItem("tourism-revenue", ov3top, ov3metric) -ov3top = IconText(ov3icon, "neutral", "m", "Tourism Revenue", "2024 estimate", false, "vertical") +ov2metric = Metric("2,370+") +ov3 = ValueCardItem("tourism-revenue", "Tourism Revenue (2024)", null, ov3icon, ov3metric) ov3icon = Icon("circle-dollar-sign", "finance") -ov3metric = MetricIndicatorInline("€23.4B", "generated") +ov3metric = Metric("€23.4B") sectionsBlock = SectionBlock([secLandmarks, secFood, secWhen], false) secLandmarks = SectionItem("landmarks", "Iconic Landmarks", [landmarksCarousel]) -landmarksCarousel = VisualCardBlock([lm1, lm2, lm3, lm4], "carousel", true, @ToAssistant("Tell me more about this Paris landmark")) -lm1 = VisualCardItem(lm1body, "eiffel-tower", "https://c8.alamy.com/comp/RYEB13/aerial-view-of-the-eiffel-tower-with-the-park-champ-de-mars-and-the-river-seine-paris-france-RYEB13.jpg", lm1tag, "Aerial view of the Eiffel Tower") -lm1body = BoldText("text", "Eiffel Tower", "Open daily 9am–midnight") -lm1tag = Tag("Must-See", lm1tagIcon, "sm", "info") +landmarksCarousel = ImageCard([lm1, lm2, lm3, lm4], "carousel", true, @ToAssistant("Tell me more about this Paris landmark")) +lm1 = ImageCardItem("eiffel-tower", "https://c8.alamy.com/comp/RYEB13/aerial-view-of-the-eiffel-tower-with-the-park-champ-de-mars-and-the-river-seine-paris-france-RYEB13.jpg", "Eiffel Tower", ["Open daily 9am–midnight", lm1tag], null, null, "Aerial view of the Eiffel Tower") +lm1tag = Tag("Must-See", lm1tagIcon, "info") lm1tagIcon = Icon("star", "shapes") -lm2 = VisualCardItem(lm2body, "the-louvre", "https://images.fineartamerica.com/images/artworkimages/mediumlarge/3/1-exterior-of-musee-du-louvre-museum-paris-france-bernard-jaubert.jpg", lm2tag, "Exterior of the Louvre museum") -lm2body = BoldText("text", "The Louvre", "World's largest art museum") -lm2tag = Tag("8.9M visits/yr", lm2tagIcon, "sm", "success") +lm2 = ImageCardItem("the-louvre", "https://images.fineartamerica.com/images/artworkimages/mediumlarge/3/1-exterior-of-musee-du-louvre-museum-paris-france-bernard-jaubert.jpg", "The Louvre", ["World's largest art museum", lm2tag], null, null, "Exterior of the Louvre museum") +lm2tag = Tag("8.9M visits/yr", lm2tagIcon, "success") lm2tagIcon = Icon("trending-up", "charts") -lm3 = VisualCardItem(lm3body, "notre-dame", "https://cdn.britannica.com/29/255529-050-63A22A3C/notre-dame-de-paris-cathedral-paris-france.jpg", lm3tag, "Notre-Dame cathedral facade") -lm3body = BoldText("text", "Notre-Dame", "Fully restored cathedral") -lm3tag = Tag("Reopened 2024", lm3tagIcon, "sm", "warning") +lm3 = ImageCardItem("notre-dame", "https://cdn.britannica.com/29/255529-050-63A22A3C/notre-dame-de-paris-cathedral-paris-france.jpg", "Notre-Dame", ["Fully restored cathedral", lm3tag], null, null, "Notre-Dame cathedral facade") +lm3tag = Tag("Reopened 2024", lm3tagIcon, "warning") lm3tagIcon = Icon("hammer", "tools") -lm4 = VisualCardItem(lm4body, "versailles", "https://cdn.britannica.com/76/130076-050-31128A3D/Latona-Fountain-Balthazard-Marsy-Gaspard-Andre-Le.jpg", lm4tag, "Latona Fountain at Versailles") -lm4body = BoldText("text", "Palace of Versailles", "Royal gardens & grand halls") -lm4tag = Tag("Day Trip", lm4tagIcon, "sm", "neutral") +lm4 = ImageCardItem("versailles", "https://cdn.britannica.com/76/130076-050-31128A3D/Latona-Fountain-Balthazard-Marsy-Gaspard-Andre-Le.jpg", "Palace of Versailles", ["Royal gardens & grand halls", lm4tag], null, null, "Latona Fountain at Versailles") +lm4tag = Tag("Day Trip", lm4tagIcon, "neutral") lm4tagIcon = Icon("train-front", "travel") secFood = SectionItem("food", "Food & Drink", [foodCards]) -foodCards = ContextCardBlock([f1, f2, f3], "grid", true, @ToAssistant("Tell me more about eating and drinking in Paris")) -f1 = ContextCardItem("bistros", "Bistros", "Classic French fare — try Les Arlots for confit beef cheeks.", "gray", "https://everydayparisian.com/wp-content/uploads/2023/01/IMG_0221-768x1024.webp") -f2 = ContextCardItem("fine-dining", "Fine Dining", "Septime leads a new wave of creative Michelin-starred cuisine.", "gray", "https://production-data.worldofmouth.app/images/72b2e6db-97ea-49c4-aa5d-3b7c8121c812.jpg") -f3 = ContextCardItem("bakeries", "Bakeries", "Shinya Pain for sourdough. Grand Prix-winning baguettes citywide.", "gray", "https://www.davidlebovitz.com/wp-content/uploads/2018/09/Le-petit-grain-paris-bakery-boulangerie-patisserie-pastry-shop-8-640x895.jpg") +foodCards = InsightCard([f1, f2, f3], "grid", true, @ToAssistant("Tell me more about eating and drinking in Paris")) +f1 = InsightCardItem("bistros", "Classic French fare — try Les Arlots for confit beef cheeks.", null, "Bistros", null, "neutral") +f2 = InsightCardItem("fine-dining", "Septime leads a new wave of creative Michelin-starred cuisine.", null, "Fine Dining", null, "neutral") +f3 = InsightCardItem("bakeries", "Shinya Pain for sourdough. Grand Prix-winning baguettes citywide.", null, "Bakeries", null, "neutral") secWhen = SectionItem("when", "Best Time to Visit", [whenTabs]) whenTabs = Tabs([tabSpring, tabSummer]) tabSpring = TabItem("spring", "Spring", [springList]) @@ -202,25 +251,23 @@ src2 = { title: "Best Things to Do in Paris 2026", sourceName: "Time Out Paris", `Example 2 — Data and analytics response (quantitative asks lead with KPIs and charts): -root = Card([pageHeader, kpiBlock, sections, deepDiveHeader, compositeCards, actionButtons]) -pageHeader = CardHeader("Analytics Dashboard", "April 2026 · Business Performance Overview") -kpiBlock = SnippetCardBlock([kpi1, kpi2, kpi3, kpi4], "grid", true) -kpi1 = SnippetCardItem("total-revenue", kpi1lhs, kpi1rhs) -kpi1lhs = IconText(kpi1icon, "neutral", "m", "Total Revenue", "Month to date", false, "horizontal") +root = Card([secOverview, secDeepDive]) +secOverview = Section([pageHeader, kpiBlock, sections]) +secDeepDive = Section([deepDiveHeader, compositeCards, actionButtons]) +pageHeader = Text("Analytics Dashboard", "h2") +kpiBlock = ValueCard([kpi1, kpi2, kpi3, kpi4], "horizontal", true) +kpi1 = ValueCardItem("total-revenue", "Total Revenue", "Month to date", kpi1icon, kpi1rhs) kpi1icon = Icon("circle-dollar-sign", "finance") -kpi1rhs = BoldText("number", "$284,500", "+12.4%", "metric") -kpi2 = SnippetCardItem("active-users", kpi2lhs, kpi2rhs) -kpi2lhs = IconText(kpi2icon, "neutral", "m", "Active Users", "Monthly active users", false, "horizontal") +kpi1rhs = Metric("$284,500", "+12.4%", null, "sm") +kpi2 = ValueCardItem("active-users", "Active Users", "Monthly active users", kpi2icon, kpi2rhs) kpi2icon = Icon("users", "people") -kpi2rhs = BoldText("number", "47,320", "+6.8%", "metric") -kpi3 = SnippetCardItem("conversion-rate", kpi3lhs, kpi3rhs) -kpi3lhs = IconText(kpi3icon, "neutral", "m", "Conversion Rate", "Avg. this month", false, "horizontal") +kpi2rhs = Metric("47,320", "+6.8%", null, "sm") +kpi3 = ValueCardItem("conversion-rate", "Conversion Rate", "Avg. this month", kpi3icon, kpi3rhs) kpi3icon = Icon("trending-up", "charts") -kpi3rhs = BoldText("number", "5.7%", "+0.9%", "metric") -kpi4 = SnippetCardItem("churn-rate", kpi4lhs, kpi4rhs) -kpi4lhs = IconText(kpi4icon, "neutral", "m", "Churn Rate", "Monthly customer churn", false, "horizontal") +kpi3rhs = Metric("5.7%", "+0.9%", null, "sm") +kpi4 = ValueCardItem("churn-rate", "Churn Rate", "Monthly customer churn", kpi4icon, kpi4rhs) kpi4icon = Icon("user-minus", "people") -kpi4rhs = BoldText("number", "2.1%", "-0.4%", "metric") +kpi4rhs = Metric("2.1%", "-0.4%", null, "sm") sections = SectionBlock([secUsers, secChannels], false) secUsers = SectionItem("users", "User Activity", [userTabs]) userTabs = Tabs([tabGrowth, tabRetention]) @@ -249,54 +296,52 @@ colConv = Col("Conv. Rate", ["6.4%", "5.1%", "9.4%", "11.8%", "5.8%", "8.1%"], " colCac = Col("CAC ($)", [188, 395, 132, 109, 218, 154], "number") colRevenue = Col("Revenue ($k)", [82.4, 68.1, 54.6, 38.2, 26.8, 14.4], "number") colStatus = Col("Status", ["Scaling", "Optimizing", "Scaling", "Growing", "Stable", "Stable"], "string") -deepDiveHeader = InlineHeader("Support Health & Marketing ROI", "Click a card to explore deeper insights") +deepDiveHeader = Text("Support Health & Marketing ROI", "h3") compositeCards = CompositeCardBlock([cardSupport, cardMarketing], "grid", true, @ToAssistant("Explore this deep-dive area in more detail")) -cardSupport = CompositeCardItem("support-health", supportHeader, [supportMetric, supportChart, supportList]) -supportHeader = IconText(supportIcon, "neutral", "m", "Support Health", "Ticket volume & resolution trends", false, "horizontal") +cardSupport = CompositeCardItem("support-health", supportHeader, [supportMetricLabel, supportMetric, supportChart, supportList]) +supportHeader = EntityTitle(supportIcon, "Support Health", "Ticket volume & resolution trends") supportIcon = Icon("headphones", "communication") -supportMetric = MetricIndicatorInline("94.2% CSAT", "Customer Satisfaction Score", { direction: "up", value: 2.1 }) +supportMetricLabel = Text("Customer Satisfaction Score", "small") +supportMetric = Metric("94.2%", "+2.1%", null, "sm") supportChart = BarChart(["Jan", "Feb", "Mar", "Apr"], [openSeries, resolvedSeries], "grouped", "Month", "Tickets") openSeries = Series("Opened", [341, 327, 308, 289]) resolvedSeries = Series("Resolved", [319, 318, 301, 283]) supportList = EntityList([sup1, sup2, sup3], "small") -sup1 = { left: "Avg. Resolution Time", right: "4h 12m", rightVariant: "text" } -sup2 = { left: "First Contact Resolution", right: "78.4%", rightVariant: "number" } -sup3 = { left: "Escalation Rate", right: "6.2%", rightVariant: "number" } -cardMarketing = CompositeCardItem("marketing-roi", marketingHeader, [marketingMetric, marketingChart, marketingList]) -marketingHeader = IconText(marketingIcon, "neutral", "m", "Marketing ROI", "Spend vs. pipeline generated", false, "horizontal") +sup1 = EntityListItem("Avg. Resolution Time", "4h 12m", "text") +sup2 = EntityListItem("First Contact Resolution", "78.4%", "number") +sup3 = EntityListItem("Escalation Rate", "6.2%", "number") +cardMarketing = CompositeCardItem("marketing-roi", marketingHeader, [marketingMetricLabel, marketingMetric, marketingChart, marketingList]) +marketingHeader = EntityTitle(marketingIcon, "Marketing ROI", "Spend vs. pipeline generated") marketingIcon = Icon("megaphone", "communication") -marketingMetric = MetricIndicatorInline("3.8x Blended ROI", "Return on Ad Spend", { direction: "up", value: 0.4 }) +marketingMetricLabel = Text("Blended return on ad spend", "small") +marketingMetric = Metric("3.8x", "+0.4x", null, "sm") marketingChart = AreaChart(["Jan", "Feb", "Mar", "Apr"], [spendSeries, pipelineSeries], "natural", "Month", "Value ($k)") spendSeries = Series("Ad Spend", [44.0, 46.8, 49.2, 51.6]) pipelineSeries = Series("Pipeline Generated", [158.2, 171.5, 184.9, 196.1]) marketingList = EntityList([mkt1, mkt2, mkt3], "small") -mkt1 = { left: "Total Ad Spend (Apr)", right: "$51,600", rightVariant: "number" } -mkt2 = { left: "Cost per Lead", right: "$48.20", rightVariant: "number" } -mkt3 = { left: "Top Channel", right: "Organic Search", rightVariant: "text" } +mkt1 = EntityListItem("Total Ad Spend (Apr)", "$51,600", "number") +mkt2 = EntityListItem("Cost per Lead", "$48.20", "number") +mkt3 = EntityListItem("Top Channel", "Organic Search", "text") actionButtons = Buttons([btnChannels, btnForecast], "row") btnChannels = Button("Break Down by Channel", @ToAssistant("Break down this month's revenue by acquisition channel"), "primary") btnForecast = Button("Forecast Next Quarter", @ToAssistant("Forecast revenue and active users for next quarter"), "secondary")`, `Example 3 — Form response (help-me-choose/plan asks: inspiration cards FIRST, then a compact form). The inspiration-card images below came from an image tool call — include such cards ONLY when you have real URLs; with no real URLs, start directly with the form: -root = Card([header, intro, inspoCards, form]) -header = CardHeader("Plan Your Trip", "A few ideas to spark inspiration — then tell me your preferences") -intro = TextContent("Tap a style to jump straight in, or fill the quick form below for a tailored plan.") -inspoCards = VisualCardBlock([insp1, insp2], "grid", true, @ToAssistant("Plan me a trip in this style with destinations, itinerary, and costs")) -insp1 = VisualCardItem(insp1body, "inspo-beach", "https://www.travelandleisure.com/thmb/cuAB7XMQ6s_Gji-no956KNopT7M=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/TAL-maldives-TROPVACAY0325-1b5047d5931d4850b47d082966f9f563.jpg", insp1tag, "Maldives overwater villas") -insp1body = BoldText("text", "Tropical Beach Escape", "Overwater villas & snorkeling") -insp1tag = Tag("Relaxation", insp1icon, "sm", "info") +root = Card([main]) +main = Section([intro, inspoCards, form]) +intro = Markdown("# Plan Your Trip\\n\\nTap a style to jump straight in, or fill the quick form below for a tailored plan.") +inspoCards = ImageCard([insp1, insp2], "grid", true, @ToAssistant("Plan me a trip in this style with destinations, itinerary, and costs")) +insp1 = ImageCardItem("inspo-beach", "https://www.travelandleisure.com/thmb/cuAB7XMQ6s_Gji-no956KNopT7M=/1500x0/filters:no_upscale():max_bytes(150000):strip_icc()/TAL-maldives-TROPVACAY0325-1b5047d5931d4850b47d082966f9f563.jpg", "Tropical Beach Escape", ["Overwater villas & snorkeling", insp1tag], null, null, "Maldives overwater villas") +insp1tag = Tag("Relaxation", insp1icon, "info") insp1icon = Icon("umbrella", "travel") -insp2 = VisualCardItem(insp2body, "inspo-mountain", "https://www.salkantaytrekmachu.com/img/adventure-travel-peru-032.jpg", insp2tag, "Trekking in the Peruvian Andes") -insp2body = BoldText("text", "Mountain Adventure", "Trekking & epic views") -insp2tag = Tag("Active", insp2icon, "sm", "warning") +insp2 = ImageCardItem("inspo-mountain", "https://www.salkantaytrekmachu.com/img/adventure-travel-peru-032.jpg", "Mountain Adventure", ["Trekking & epic views", insp2tag], null, null, "Trekking in the Peruvian Andes") +insp2tag = Tag("Active", insp2icon, "warning") insp2icon = Icon("mountain", "nature") -form = Form("trip-planner", formButtons, [fcDestination, fcDates, fcTravellers, fcTripType, fcBudget, fcStay, fcNotes]) +form = Form("trip-planner", formButtons, [fcDates, fcTravellers]) formButtons = Buttons([btnSubmit, btnSkip], "row") btnSubmit = Button("Plan My Trip", @ToAssistant("Help me plan a trip based on my form submission"), "primary") btnSkip = Button("Just Surprise Me", @ToAssistant("Skip the form and surprise me with a destination and itinerary"), "secondary") -fcDestination = FormControl("Destination", destinationInput, "Leave blank if you'd like suggestions") -destinationInput = Input("destination", "e.g. Tokyo, Japan — or leave blank", "text") fcDates = FormControl("Travel Dates", datesPicker, "Select your start and end dates") datesPicker = DatePicker("travel-dates", "range", { required: true }) fcTravellers = FormControl("Number of Travellers", travellersSelect, "How many people are travelling?") @@ -304,58 +349,33 @@ travellersSelect = Select("num-travellers", [tr1, tr2, tr3, tr4], "Select number tr1 = SelectItem("1", "1 — Solo") tr2 = SelectItem("2", "2 — Couple") tr3 = SelectItem("3-4", "3–4 People") -tr4 = SelectItem("5+", "5+ People") -fcTripType = FormControl("Trip Type", tripTypeCards, "What kind of trip are you planning?") -tripTypeCards = OptionCards("trip-type", "single", [oc1, oc2, oc3, oc4], { required: true }) -oc1 = OptionCard("adventure", "Adventure", "Hiking, trekking & outdoor activities", oc1icon) -oc1icon = Icon("mountain", "nature") -oc2 = OptionCard("beach", "Beach & Relaxation", "Sun, sand & leisure", oc2icon) -oc2icon = Icon("waves", "nature") -oc3 = OptionCard("cultural", "Cultural & Historical", "Museums, heritage & local experiences", oc3icon) -oc3icon = Icon("landmark", "buildings") -oc4 = OptionCard("city", "City Break", "Urban exploration & nightlife", oc4icon) -oc4icon = Icon("building-2", "buildings") -fcBudget = FormControl("Estimated Budget (per person)", budgetSlider, "Drag to set your per-person budget in USD") -budgetSlider = Slider("budget", "discrete", 500, 10000, 500, [2500], "Budget (USD)") -fcStay = FormControl("Accommodation Preference", stayChips, "Select all that apply") -stayChips = Chips("accommodation", "multiple", [ch1, ch2, ch3, ch4], {}, ["hotel"]) -ch1 = ChipItem("hotel", "Hotel", ch1icon) -ch1icon = Icon("building", "buildings") -ch2 = ChipItem("resort", "Resort", ch2icon) -ch2icon = Icon("umbrella", "travel") -ch3 = ChipItem("airbnb", "Vacation Rental", ch3icon) -ch3icon = Icon("home", "home") -ch4 = ChipItem("camping", "Camping / Glamping", ch4icon) -ch4icon = Icon("tent", "nature") -fcNotes = FormControl("Special Requests or Notes", notesArea, "Any dietary needs, accessibility requirements, or specific interests?") -notesArea = TextArea("special-requests", "e.g. vegetarian meals, wheelchair access, honeymoon surprise...", 4)`, +tr4 = SelectItem("5+", "5+ People")`, `Example 4 — Recommendations with images and links (same pattern for products, movies, restaurants, articles): -root = Card([header, intro, posters, linksHeader, trackCards], [src1, src2]) -header = CardHeader("Retro-Energetic Indian Tracks", "New Hindi releases with 80s/90s dance DNA") -intro = TextContent("These recent tracks channel retro synths and 90s party percussion [1][2]. Tap a poster to explore a track, or use its button to watch the exact official video.") -posters = VisualCardBlock([v1, v2], "grid", true, @ToAssistant("Tell me more about this track and its retro influences")) -v1 = VisualCardItem(v1body, "tamma", "https://i.ytimg.com/vi/PQHeOb0Q9oo/hq720.jpg", v1tag, "Tamma Tamma video poster") -v1body = BoldText("text", "Tamma Tamma", "Dhurandhar · Ranveer Singh") -v1tag = Tag("80s Electro-Disco", v1icon, "sm", "success") +root = Card([secPosters, secLinks], [src1, src2]) +secPosters = Section([intro, posters]) +secLinks = Section([linksHeader, trackCards]) +intro = Markdown("# Retro-Energetic Indian Tracks\\n\\nThese recent tracks channel retro synths and 90s party percussion [1][2]. Tap a poster to explore a track, or use its button to watch the exact official video.") +posters = ImageCard([v1, v2], "grid", true, @ToAssistant("Tell me more about this track and its retro influences")) +v1 = ImageCardItem("tamma", "https://i.ytimg.com/vi/PQHeOb0Q9oo/hq720.jpg", "Tamma Tamma", ["Dhurandhar · Ranveer Singh", v1tag], null, null, "Tamma Tamma video poster") +v1tag = Tag("80s Electro-Disco", v1icon, "success") v1icon = Icon("disc-3", "multimedia") -v2 = VisualCardItem(v2body, "sajan", "https://i.ytimg.com/vi/mLz-qBiRnl8/hq720.jpg", v2tag, "Sajan Re video poster") -v2body = BoldText("text", "Sajan Re", "Nora Fatehi · Badshah") -v2tag = Tag("Synthwave Electro", v2icon, "sm", "info") +v2 = ImageCardItem("sajan", "https://i.ytimg.com/vi/mLz-qBiRnl8/hq720.jpg", "Sajan Re", ["Nora Fatehi · Badshah", v2tag], null, null, "Sajan Re video poster") +v2tag = Tag("Synthwave Electro", v2icon, "info") v2icon = Icon("music", "multimedia") -linksHeader = InlineHeader("Watch the Exact Videos", "Each button opens that track's official video") +linksHeader = Heading("Watch the Exact Videos", "Each button opens that track's official video") trackCards = CompositeCardBlock([t1, t2], "grid", true) t1 = CompositeCardItem("t-tamma", t1h, [t1body, t1tags], { button: t1btn }) -t1h = IconText(t1icon, "neutral", "m", "Tamma Tamma", "Bappi Lahiri classic, electro-funk rework", true, "horizontal") +t1h = EntityTitle(t1icon, "Tamma Tamma", "Bappi Lahiri classic, electro-funk rework") t1icon = Icon("disc-3", "multimedia") -t1body = Text("text", "Heavy analog synths and brass over a fast disco groove.", "Retro Electro-Disco") +t1body = Text("Heavy analog synths and brass over a fast disco groove.", "small") t1tags = TagBlock(["2026 Film Hit", "Peak Party"]) t1btn = Button("Watch on YouTube", @OpenUrl("https://www.youtube.com/watch?v=PQHeOb0Q9oo"), "primary") t2 = CompositeCardItem("t-sajan", t2h, [t2body, t2tags], { button: t2btn }) -t2h = IconText(t2icon, "neutral", "m", "Sajan Re", "Nora Fatehi · Badshah · Sanjoy", true, "horizontal") +t2h = EntityTitle(t2icon, "Sajan Re", "Nora Fatehi · Badshah · Sanjoy") t2icon = Icon("music", "multimedia") -t2body = Text("text", "Synthwave melody hooks over a modern club beat.", "Retro-Fusion Electro") +t2body = Text("Synthwave melody hooks over a modern club beat.", "small") t2tags = TagBlock(["Club Anthem", "Nora x Badshah"]) t2btn = Button("Watch on YouTube", @OpenUrl("https://www.youtube.com/watch?v=COUSCnFazzc"), "primary") src1 = { title: "Tamma Tamma (Full Video) - Dhurandhar", sourceName: "YouTube (T-Series)", url: "https://www.youtube.com/watch?v=PQHeOb0Q9oo" } @@ -363,8 +383,9 @@ src2 = { title: "Sajan Re (Official Music Video)", sourceName: "YouTube (T-Serie `Example 5 — Table with follow-ups: -root = Card([title, tbl, followUps]) -title = TextContent("Top Languages", "large-heavy") +root = Card([main]) +main = Section([title, tbl, followUps]) +title = Text("Top Languages", "h2") tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)]) langs = ["Python", "JavaScript", "Java"] users = [15.7, 14.2, 12.1] @@ -374,38 +395,46 @@ fu1 = FollowUpItem("Tell me more about Python") fu2 = FollowUpItem("Show me a JavaScript comparison")`, ]; +const openuiChatPreamble = + "You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang.\n\nHow to shape every response — decide before writing, in this order:\n\n1) Visual first: when the topic is visual (travel, weddings, events, food, places, products, fashion, home and design), use an image search tool if one is available and lead with real photos: an ImageGallery or a hero Image (in a Bleed for width) near the top of the first Section, then an ImageCard grid or carousel for the options. Only use image URLs from a tool result or the user, never invented ones; with no real photos, compose the answer from text, icons and cards instead, never with a placeholder.\n\n2) Ask before guessing, with visual choices: when the request is ambiguous or missing a detail that changes the answer (a style, a budget range, which product or place), ask one short question and let the user pick instead of typing. Offer 2–5 concrete options plus your default: ImageCard tiles with real photos for visual options, InsightCard tiles or Buttons with leading icons for the rest, all with @ToAssistant actions. A Markdown line asks the question and names the default you will use. Use a Form only for essential input that cannot be picked (a name, an email, exact dates, free text), with the fewest fields possible, never a long form of options the user could tap instead. Ask one question at a time, and never ask when the intent is clear.\n\n3) Match the UI to the need: a plain or conversational answer is concise Markdown in one Section; a visual answer leads with images as above; a structured or data answer uses the compositions (Card with title, Grid, Shell for a dashboard, Bleed for wide comparisons, Tabs, Accordion, Steps, Timeline, Checklist, Table and charts). Never add UI the answer does not need."; + export const openuiChatAdditionalRules: string[] = [ "When asked about data, generate realistic/plausible data", - "Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card.", - "Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll.", + 'Markdown vs Text: use Markdown for explanations, summaries and the top-level prose that sets context around the UI (an intro before cards, a closing note, anything with several sentences, lists, tables or quotes). Use Text only for short UI text: headings (h1/h2/h3), labels, captions and single values. Text "body" is for a short multi-line caption inside a component, not for explanations.', + 'When answering a question with a title and prose, write one Markdown block that starts with a markdown heading (Markdown("# Google\\n\\nGoogle is ...")), not Text("Google", "h1") followed by Markdown. Markdown\'s headings carry the spacing prose needs; Text headings are for titling UI such as dashboards, forms and card groups.', + 'Metric(value, delta, variant, size, previous) is a value with an optional change; a leading +/- on delta shows a green up or red down arrow. It has no label: inside a Card use the Card title, otherwise compose one with Text or Heading (or the card item it sits in). It uses the UI font, so use size "sm" whenever it is not the hero number: in tables, EntityList rows, list and title subtitles, ImageTitle content, ValueCardItem value and CompositeCardItem body (a CompositeCardItem footer price always renders at 18px). Use variant "vertical" with size "large" only for a standalone headline figure such as a KPI tile. Pass previous (5th) only for a real before/after such as a price drop: Metric("$89", "-26%", null, "sm", "$120") strikes through $120.', + "Every response is a single root Card(children) whose children stack automatically. Do NOT use Stack. Use Grid for equal columns, Tabs to switch between views, and Carousel for horizontal scroll.", + 'Card(children, sources?, variant?, title?, subtitle?) has a small built-in header: inside a Section, title a Card with its title and subtitle args, e.g. Card([chart], null, null, "Revenue by month", "Jul–Sep"), never a Heading or small Text label inside it.', + "Shell(title, children) frames a dashboard or a large Grid composition inside a Section: a small title, an outline, wider than the chat column, with an expand button. Use at most one per response. Bleed(children) is the same width with no frame, for a wide hero Image, Grid or Carousel. 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)]). Neither nests.", + "Use Grid(children, columns?) for dashboards and Card sets that are not a carousel: custom KPI Cards in a 3–4 column Grid, chart Cards in a 2 column Grid. A wide item such as a Table or a long Markdown goes outside the Grid, directly in the Section. When each KPI is only a label and a value, use ValueCard instead of hand-built KPI Cards. When a Grid compares an odd number of cards (3 or 5 plans or options), wrap it in a Bleed so they stay in one row.", "Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next.", "Use ListBlock when presenting a set of options or steps; give each ListItem an action (5th argument) to make it clickable — without one the item is plain text.", - "Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content.", + "Build every response from Sections placed directly in the Card: each distinct part (an overview, then options, then next steps) is one Section; when the part needs a title, its first child is a Heading (or a prose Markdown block that starts with a # heading), followed by that part's content. A short single-part answer is a single Section. Sections are high-level divisions, and many responses need only one: never give a lone Text, Markdown or Buttons row its own Section, and never a Section that holds only a title (a Heading or Text, with or without its subtitle): the title always goes at the top of the Section holding the content it introduces, so a dashboard header sits in the same Section as its grid. An intro goes first inside the Section it introduces, and closing actions go last in the final Section. Never nest Sections and never leave blocks outside them; use SectionBlock inside a Section only when its topics should be collapsible, such as FAQs or long reports.", "Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array.", - "Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]])", - "IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags].", + "Carousel takes 2+ slides of one type: Cards (Carousel([Card([t1, img1]), Card([t2, img2])]), the Card variant sets the look), Images (same aspectRatio), or one kind of card item (CompositeCardItem, ImageCardItem, InsightCardItem or ValueCardItem).", + 'IMPORTANT: Every slide in a Carousel must be the same: same Card variant and the same component types in the same order — e.g. all slides Card([title, image, description, tags]). Which scroller: a set of one card type with one shared click action uses that block\'s own carousel layout (ImageCard, InsightCard or CompositeCardBlock with layout "carousel", or a vertical ValueCard with carousel true), which keeps the shared action; use Carousel for custom Card or Image slides, or card items that need no shared action.', + "Prefer visual choices over Forms: when you need input, offer options the user can tap (ImageCard tiles, InsightCard tiles, Buttons with leading icons, or OptionCards with images) so they do not have to fill anything in. A Form is only for essential input that cannot be picked, such as a name, an email, exact dates or free text, and keeps to the fewest fields.", "For forms, define one FormControl reference per field so controls can stream progressively.", "For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields).", "Never nest Form inside Form.", - "CardHeader is the page-level heading — place it as the first child of Card; InlineHeader is a lighter subsection label for a block inside the response, and is redundant when the following component already has a heading.", - "Never use Callout or TextCallout to justify why you did what you did or for any meta-information.", - "Every image slot (Image, ImageBlock, ImageGallery, VisualCardItem/ContextCardItem bgImageSrc, ImageText, ImageTextLarge, ListItem image) requires a REAL URL from a tool result, the conversation, or the user — NEVER invent, guess, or template a URL.", - "If no image URL is in context, compose without image components — icons, gray ContextCardItems, and charts still make rich layouts.", - "Argument-order trap: Image takes alt FIRST, while ImageBlock, ImageText, and ImageTextLarge take src FIRST — alt text in a src slot ships a broken image.", - 'Card blocks (SnippetCardBlock, OverviewCardBlock, ContextCardBlock, CompositeCardBlock, VisualCardBlock) need at least 2 items, all with the same structure; use layout "grid" for primary side-by-side comparison and "carousel" for secondary browsing.', + "Never use Callout to justify why you did what you did or for any meta-information.", + "Every image slot (Image, ImageGallery, ImageCardItem src, Avatar src) requires a REAL URL from a tool result, the conversation, or the user — NEVER invent, guess, or template a URL.", + "Argument-order trap: Image takes alt FIRST and src second (Image(alt, src)), while Avatar takes name first and src second — a URL in the alt slot ships a broken image.", + "CompositeCardBlock is the fallback card block: first use the specialised one that fits (ImageCard for photo tiles, InsightCard for takeaways, ValueCard or ValueLayout for figures, EntityList or ListBlock for rows, OptionCards or Chips for choices in a form), and use CompositeCardBlock only when a card needs mixed content none of them offers, such as a header, a body with charts or a Checklist, and a price or button footer.", + 'Card blocks (ValueCard, InsightCard, CompositeCardBlock, ImageCard) need at least 2 items, all with the same structure. InsightCard, CompositeCardBlock and ImageCard take layout "grid" for primary side-by-side comparison or "carousel" for secondary browsing; ValueCard and ValueLayout take layout "horizontal" (key/value facts) or "vertical" (KPI tiles), plus carousel true for vertical cards only. ValueLayout has no action.', 'The card-block action is BLOCK-LEVEL: one action shared by every card, with the clicked card\'s details attached automatically — keep the @ToAssistant message generic (e.g. "Tell me more about this destination"), and never use a block-level @OpenUrl since it would send every card to the same URL.', "Card items have NO per-item action: to give each item its own external link, use CompositeCardItem with footer { button: Button(label, @OpenUrl(url)) } — never claim a card opens a link it cannot.", - "Text, BoldText, IconText, ImageText, ImageTextLarge and MetricIndicator* are inline building blocks used INSIDE card items — do not place them directly in the root Card.", 'EntityList size "small" is reserved for CompositeCardItem body content (no header/footer); use size "default" elsewhere.', - "Tag variant must be semantically correct (success=positive, danger=negative, warning=cautionary, info=informational, neutral=label); Tag takes an optional Icon second, so use Tag(text, icon, size, variant) when a variant is needed.", + 'Tag(text, leading, variant): pick variant by meaning (success=positive, danger=negative, warning=cautionary, info=informational, neutral=plain label); purple and pink only to tell categories apart, inverted for a single high-emphasis label. leading is optional: an Icon for a concept, a Shape for a status dot or category marker, an Avatar for a person or company, and an Emoji only in rare expressive content. Pass null for leading to set just a variant: Tag("Open", null, "success").', "TagBlock takes plain strings — keep it to at most 3 tags.", - 'ListBlock variant "number" is for ordered sequences and rankings, "image" for products/profiles with a real image; Steps is for ordered sequential processes only.', - "Use SectionBlock to divide a long response into independent, equally prominent topics; use Tabs when the items are alternative views of the same thing; set isFoldable=false unless a section has 4+ distinct blocks; never make SectionBlock the first Card child.", + 'ListBlock variant "number" (default) is for rankings, ordered picks and short sequences; "plain" for an unordered set such as people, companies or places that each lead with an Avatar; Steps is for numbered how-to instructions only; Timeline is for events in time order (itineraries, order status, activity logs, project history).', + "Use SectionBlock for independent, equally prominent topics the reader opens on demand; use Tabs when the items are alternative views of the same thing; set isFoldable=false unless a section has 4+ distinct blocks; never make SectionBlock the first Card child.", "Accordion is for supplementary or FAQ content only — never primary information, 3-5 items max, and always the last component in its section or card.", 'Charts: LineChart/AreaChart variant "natural" for smooth trends and "linear" for precise point-to-point data; BarChart "grouped" for side-by-side comparison and "stacked" for part-to-whole; prefer HorizontalBarChart when category labels are long or numerous.', 'For large chart values, scale the data and put the unit in the axis label (e.g. yLabel "Revenue ($k)") instead of passing raw thousands or millions.', "Quantitative asks get a chart (plus KPI cards where useful) in the FIRST response — do not answer numbers with prose or a table alone.", - "Table is column-oriented: each Col holds its own data array of plain strings or numbers (never components), and every column's array must have the same length.", + "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.", + 'Table is column-oriented: each Col holds its own data array of plain strings or numbers, or of components built with @Each such as Tag or Metric(…, "sm") for styled cells; every column\'s array must have the same length.', "Use EditableTable ONLY when the user or app explicitly asks for an editable table; default to Table for read-only data.", 'Every Form MUST have EXACTLY ONE submit Button with variant "primary" — that button validates the form; other buttons (secondary/tertiary escape hatches like "Just Surprise Me") skip validation, and there is never a reset button.', "Prefer structured inputs (Select, Chips, OptionCards, DatePicker, Slider) over free-text Input, give every FormControl a clear label (and a hint where it prevents mistakes), and keep each field name unique within the response.", @@ -413,11 +442,12 @@ export const openuiChatAdditionalRules: string[] = [ "Form inputs work ONLY inside a FormControl within a Form; for tappable suggestion choices OUTSIDE a form, use Buttons whose Button actions are @ToAssistant, never a bare OptionCards or Chips.", "Only add Buttons for real actions: form submit, @OpenUrl with a URL from a tool or the user (never fabricated), or a @ToAssistant follow-up. Never add a button for an app action (open, download, export, edit) you cannot perform.", 'Every Button label must accurately describe what clicking it does; use "primary" for the main action and "secondary"/"tertiary" only for lower-emphasis supporting actions.', - "Icon: always include the category argument — it enables fallback matching when the exact lucide name is unavailable.", - "Pass sources on Card ONLY when the answer relies on real references you actually have, and cite them inline in TextContent as [1], [2] (1-based index into sources).", + 'Icon names must be exact lucide kebab-case names (e.g. "calendar", "circle-check"); an unknown name renders a plain circle, so prefer common icons over guesses.', + "Pass sources on Card ONLY when the answer relies on real references you actually have, and cite them inline in Markdown as [1], [2] (1-based index into sources).", ]; export const openuiChatPromptOptions: PromptOptions = { examples: openuiChatExamples, + preamble: openuiChatPreamble, additionalRules: openuiChatAdditionalRules, }; diff --git a/packages/react-ui/src/genui-lib/sectionContentUnion.ts b/packages/react-ui/src/genui-lib/sectionContentUnion.ts index d8ae2c216a..c18db42f34 100644 --- a/packages/react-ui/src/genui-lib/sectionContentUnion.ts +++ b/packages/react-ui/src/genui-lib/sectionContentUnion.ts @@ -1,15 +1,20 @@ import { z } from "zod/v4"; +import { Avatar } from "./Avatar"; +import { AvatarStack } from "./AvatarStack"; import { Callout } from "./Callout"; -import { CardHeader } from "./CardHeader"; import { CodeBlock } from "./CodeBlock"; +import { EntityTitle } from "./EntityTitle"; +import { Heading } from "./Heading"; import { Image } from "./Image"; -import { ImageBlock } from "./ImageBlock"; import { ImageGallery } from "./ImageGallery"; -import { MarkDownRenderer } from "./MarkDownRenderer"; +import { ImageTitle } from "./ImageTitle"; +import { LeadingText } from "./LeadingText"; +import { Link } from "./Link"; +import { Markdown } from "./Markdown"; +import { Metric } from "./Metric"; import { Separator } from "./Separator"; -import { TextCallout } from "./TextCallout"; -import { TextContent } from "./TextContent"; +import { Text } from "./Text"; import { AreaChartCondensed, @@ -24,24 +29,31 @@ import { } from "./Charts"; import { Buttons } from "./Buttons"; +import { Checklist } from "./Checklist"; import { FollowUpBlock } from "./FollowUpBlock"; import { Form } from "./Form"; import { ListBlock } from "./ListBlock"; import { Steps } from "./Steps"; import { Table } from "./Table"; import { TagBlock } from "./TagBlock"; +import { Timeline } from "./Timeline"; // Content allowed inside a SectionBlock section. // Does NOT include SectionBlock (no nesting), Tabs, or Accordion (avoids circular deps). export const SectionContentChildUnion = z.union([ - TextContent.ref, - MarkDownRenderer.ref, - CardHeader.ref, + Text.ref, + Markdown.ref, + Link.ref, + Metric.ref, + Avatar.ref, + AvatarStack.ref, + Heading.ref, + LeadingText.ref, + EntityTitle.ref, + ImageTitle.ref, Callout.ref, - TextCallout.ref, CodeBlock.ref, Image.ref, - ImageBlock.ref, ImageGallery.ref, Separator.ref, HorizontalBarChart.ref, @@ -58,6 +70,8 @@ export const SectionContentChildUnion = z.union([ Form.ref, Buttons.ref, Steps.ref, + Timeline.ref, + Checklist.ref, ListBlock.ref, FollowUpBlock.ref, ]); diff --git a/packages/react-ui/src/genui-lib/unions.ts b/packages/react-ui/src/genui-lib/unions.ts index 2a159e079e..037ddc4491 100644 --- a/packages/react-ui/src/genui-lib/unions.ts +++ b/packages/react-ui/src/genui-lib/unions.ts @@ -1,15 +1,20 @@ import { z } from "zod/v4"; +import { Avatar } from "./Avatar"; +import { AvatarStack } from "./AvatarStack"; import { Callout } from "./Callout"; -import { CardHeader } from "./CardHeader"; import { CodeBlock } from "./CodeBlock"; +import { EntityTitle } from "./EntityTitle"; +import { Heading } from "./Heading"; import { Image } from "./Image"; -import { ImageBlock } from "./ImageBlock"; import { ImageGallery } from "./ImageGallery"; -import { MarkDownRenderer } from "./MarkDownRenderer"; +import { ImageTitle } from "./ImageTitle"; +import { LeadingText } from "./LeadingText"; +import { Link } from "./Link"; +import { Markdown } from "./Markdown"; +import { Metric } from "./Metric"; import { Separator } from "./Separator"; -import { TextCallout } from "./TextCallout"; -import { TextContent } from "./TextContent"; +import { Text } from "./Text"; import { AreaChartCondensed, @@ -30,30 +35,36 @@ import { TagBlock } from "./TagBlock"; import { Buttons } from "./Buttons"; import { Form } from "./Form"; +import { Checklist } from "./Checklist"; import { Steps } from "./Steps"; +import { Timeline } from "./Timeline"; import { FollowUpBlock } from "./FollowUpBlock"; import { ListBlock } from "./ListBlock"; import { SectionBlock } from "./SectionBlock"; import { CompositeCardBlock } from "./CompositeCardBlock"; -import { ContextCardBlock } from "./ContextCardBlock"; import { EditableTable } from "./EditableTable"; import { EntityList } from "./EntityList"; -import { InlineHeader } from "./InlineHeader"; -import { OverviewCardBlock } from "./OverviewCardBlock"; -import { SnippetCardBlock } from "./SnippetCardBlock"; -import { VisualCardBlock } from "./VisualCardBlock"; +import { ImageCard } from "./ImageCard"; +import { InsightCard } from "./InsightCard"; +import { ValueCard } from "./ValueCard"; +import { ValueLayout } from "./ValueLayout"; export const ContentChildUnion = z.union([ - TextContent.ref, - MarkDownRenderer.ref, - CardHeader.ref, + Text.ref, + Markdown.ref, + Link.ref, + Metric.ref, + Avatar.ref, + AvatarStack.ref, + Heading.ref, + LeadingText.ref, + EntityTitle.ref, + ImageTitle.ref, Callout.ref, - TextCallout.ref, CodeBlock.ref, Image.ref, - ImageBlock.ref, ImageGallery.ref, Separator.ref, HorizontalBarChart.ref, @@ -71,19 +82,20 @@ export const ContentChildUnion = z.union([ Buttons.ref, IconButton.ref, Steps.ref, - InlineHeader.ref, + Timeline.ref, + Checklist.ref, EntityList.ref, EditableTable.ref, - SnippetCardBlock.ref, - OverviewCardBlock.ref, - ContextCardBlock.ref, + ValueCard.ref, + ValueLayout.ref, + InsightCard.ref, CompositeCardBlock.ref, - VisualCardBlock.ref, + ImageCard.ref, ]); // Chat-specific content union — no Stack, adds ListBlock / FollowUpBlock / SectionBlock // (conversational features with no place in the base library). Everything else -// (InlineHeader, EntityList, EditableTable, card blocks) now lives in the base +// (EntityList, EditableTable, card blocks) now lives in the base // ContentChildUnion above and is inherited here via the spread. // Note: Tabs and Carousel are NOT included here to avoid circular deps (Tabs/schema.ts imports ContentChildUnion). // ChatCardChildUnion (which adds Tabs + Carousel) is defined in openuiChatLibrary.tsx. diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index eabdfa44e7..01f5652ba8 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -97,6 +97,7 @@ export * from "./components/InlineHeader"; export * from "./components/InlineMarkdownRenderer"; export * from "./components/Input"; export * from "./components/Label"; +export * from "./components/Link"; export * from "./components/ListBlock"; export * from "./components/ListItem"; export * from "./components/MarkDownRenderer"; @@ -112,7 +113,6 @@ export * from "./components/Select"; export * from "./components/Separator"; export * from "./components/Skeleton"; export * from "./components/Slider"; -export * from "./components/SnippetCardBlock"; export * from "./components/SourceFaviconImage"; export * from "./components/Sources"; export * from "./components/Steps"; @@ -128,6 +128,8 @@ export * from "./components/TextCallout"; export * from "./components/TextContent"; export * from "./components/TextContentWrapper"; export * from "./components/ThemeProvider"; +export * from "./components/Timeline"; +export * from "./components/ValueCardBlock"; export * from "./components/ToolCall"; export * from "./components/ToolResult"; @@ -141,6 +143,7 @@ export { safeOpenUrl, safeUrl } from "./components/_shared/utils"; // Genui-lib standard library export { openuiAdditionalRules, + openuiArchivedComponents, openuiChatAdditionalRules, openuiChatComponentGroups, openuiChatExamples, diff --git a/packages/react-ui/src/openui-defaults.scss b/packages/react-ui/src/openui-defaults.scss index 0110eea7c2..23001ee14a 100644 --- a/packages/react-ui/src/openui-defaults.scss +++ b/packages/react-ui/src/openui-defaults.scss @@ -150,6 +150,7 @@ --openui-font-weight-heavy: 700; --openui-font-variation-regular: "wght" 440; --openui-font-variation-medium: "wght" 560; + --openui-font-variation-bold: "wght" 640; --openui-line-height-body: 1.5; --openui-line-height-heading: 1.25; --openui-line-height-heading-large: 1.1; @@ -396,6 +397,7 @@ --openui-font-weight-heavy: 700; --openui-font-variation-regular: "wght" 420; --openui-font-variation-medium: "wght" 560; + --openui-font-variation-bold: "wght" 640; --openui-line-height-body: 1.5; --openui-line-height-heading: 1.25; --openui-line-height-heading-large: 1.1; @@ -642,6 +644,7 @@ --openui-font-weight-heavy: 700; --openui-font-variation-regular: "wght" 420; --openui-font-variation-medium: "wght" 560; + --openui-font-variation-bold: "wght" 640; --openui-line-height-body: 1.5; --openui-line-height-heading: 1.25; --openui-line-height-heading-large: 1.1; diff --git a/packages/react-ui/src/scripts/generate-css-utils.ts b/packages/react-ui/src/scripts/generate-css-utils.ts index 3e78af51cc..109005e768 100644 --- a/packages/react-ui/src/scripts/generate-css-utils.ts +++ b/packages/react-ui/src/scripts/generate-css-utils.ts @@ -47,8 +47,8 @@ lines.push("$chat-container-bg: $background;"); lines.push(""); lines.push(`\ // $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 { @@ -87,11 +87,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/templates/openui-cloud/src/generated/spec.json b/templates/openui-cloud/src/generated/spec.json index 839162334b..cc435ceb6f 100644 --- a/templates/openui-cloud/src/generated/spec.json +++ b/templates/openui-cloud/src/generated/spec.json @@ -2,60 +2,88 @@ "root": "Stack", "components": { "Card": { - "signature": "Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | InlineHeader | EntityList | EditableTable | SnippetCardBlock | OverviewCardBlock | ContextCardBlock | CompositeCardBlock | VisualCardBlock | Tabs | Carousel | Stack)[], variant?: \"card\" | \"sunk\" | \"clear\", direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", - "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." + "signature": "Card(children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard | Tabs | Carousel | Stack | Section | Grid | Shell | Bleed)[], variant?: \"outlined\" | \"lifted\" | \"sunk\", title?: string, subtitle?: string)", + "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." }, - "CardHeader": { - "signature": "CardHeader(title?: string, subtitle?: string)", - "description": "Header with optional title and subtitle" + "Text": { + "signature": "Text(text: string, variant?: \"small\" | \"default\" | \"body\" | \"h1\" | \"h2\" | \"h3\", emphasis?: boolean)", + "description": "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." }, - "TextContent": { - "signature": "TextContent(text: string, size?: \"small\" | \"default\" | \"large\" | \"small-heavy\" | \"large-heavy\")", - "description": "Text block. Supports markdown. Optional size: \"small\" | \"default\" | \"large\" | \"small-heavy\" | \"large-heavy\"." + "Markdown": { + "signature": "Markdown(textMarkdown: string)", + "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." }, - "MarkDownRenderer": { - "signature": "MarkDownRenderer(textMarkdown: string, variant?: \"clear\" | \"card\" | \"sunk\")", - "description": "Renders markdown text with optional container variant" + "Metric": { + "signature": "Metric(value: string, delta?: string, variant?: \"horizontal\" | \"vertical\", size?: \"sm\" | \"default\" | \"large\", previous?: string)", + "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." }, - "Callout": { - "signature": "Callout(variant: \"info\" | \"warning\" | \"error\" | \"success\" | \"neutral\", title: string, description: string, visible?: $binding)", - "description": "Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false." + "Avatar": { + "signature": "Avatar(name: string, src?: string, shape?: \"circle\" | \"rounded\", domain?: string, size?: \"inline\" | \"md\" | \"lg\", icon?: Icon | Shape, color?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"purple\" | \"pink\" | \"inverted\")", + "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." }, - "TextCallout": { - "signature": "TextCallout(variant?: \"neutral\" | \"info\" | \"warning\" | \"success\" | \"danger\", title?: string, description?: string)", - "description": "Text callout with variant, title, and description" + "Emoji": { + "signature": "Emoji(emoji: string, label?: string)", + "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." }, - "Image": { - "signature": "Image(alt: string, src?: string)", - "description": "Image with alt text and optional URL" + "Shape": { + "signature": "Shape(shape: \"circle\" | \"square\" | \"diamond\" | \"triangle-up\" | \"triangle-down\" | \"triangle-left\" | \"triangle-right\" | \"hexagon\", color?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"purple\" | \"pink\" | \"inverted\", label?: string)", + "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." + }, + "AvatarStack": { + "signature": "AvatarStack(avatars: Avatar[], max?: number, size?: \"inline\" | \"md\" | \"lg\")", + "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." + }, + "Heading": { + "signature": "Heading(title: string, description?: string, size?: \"default\" | \"lg\")", + "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." + }, + "LeadingText": { + "signature": "LeadingText(leading: Icon | Avatar | AvatarStack | Emoji | Shape, text: Text, tone?: \"primary\" | \"secondary\")", + "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." + }, + "EntityTitle": { + "signature": "EntityTitle(leading: Avatar | Icon | Emoji, title: string, subtitle: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[], layout?: \"horizontal\" | \"vertical\")", + "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." + }, + "ImageTitle": { + "signature": "ImageTitle(image: Image, content: (Text | Heading | EntityTitle | LeadingText | Metric | Tag | TagBlock | Link | (Text | Heading | EntityTitle | LeadingText | Metric | Tag | TagBlock | Link)[])[])", + "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." }, - "ImageBlock": { - "signature": "ImageBlock(src: string, alt?: string)", - "description": "Image block with loading state" + "Link": { + "signature": "Link(text: string, url: string, size?: \"default\" | \"lg\")", + "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." + }, + "Callout": { + "signature": "Callout(variant: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"purple\" | \"pink\" | \"inverted\" | \"error\", content: string, visible?: $binding, icon?: Icon | Emoji)", + "description": "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." + }, + "Image": { + "signature": "Image(alt: string, src?: string, aspectRatio?: \"auto\" | \"1:1\" | \"4:3\" | \"3:2\" | \"16:9\" | \"3:4\" | \"9:16\")", + "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)." }, "ImageGallery": { "signature": "ImageGallery(images: {src: string, alt?: string, details?: string}[])", - "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." }, "CodeBlock": { "signature": "CodeBlock(language: string, codeString: string)", "description": "Syntax-highlighted code block" }, - "InlineHeader": { - "signature": "InlineHeader(heading: string, description?: string)", - "description": "Compact section heading with an optional one-line description, for use inside cards." - }, "Table": { "signature": "Table(columns: Col[])", - "description": "Data table — column-oriented. Each Col holds its own data array." + "description": "Data table, column-oriented: Table([Col(\"Language\", langs), Col(\"Users (M)\", users, \"number\")]); each Col holds its own data array. It draws its own frame, so never wrap it in a Card (that doubles the border): place it directly in a Section, TabItem or AccordionItem, with a Heading or Text title above it if needed." }, "Col": { "signature": "Col(label: string, data: any, type?: \"string\" | \"number\" | \"action\")", "description": "Column definition — holds label + data array" }, "EditableTable": { - "signature": "EditableTable(name?: string, columns?: {type: \"text\" | \"number\" | \"date-single\" | \"select\" | \"url\", key?: string, header?: string, width?: number, options?: {value: string, label: string}[]}[], data?: {id: string, values: (string | number)[]}[])", - "description": "Spreadsheet-like table whose cells the user can edit inline (text, number, url, date, select columns); edits are saved back as a form field" + "signature": "EditableTable(name?: string, columns?: EditableCol[], rows?: (string | number | any)[][])", + "description": "Spreadsheet-like table the user edits inline: EditableTable(name, columns, rows), e.g. EditableTable(\"tasks\", [EditableCol(\"task\", \"Task\"), EditableCol(\"status\", \"Status\", \"select\", [SelectItem(\"todo\", \"Todo\"), SelectItem(\"done\", \"Done\")]), EditableCol(\"due\", \"Due\", \"date\")], [[\"Design review\", \"todo\", \"2026-10-12\"]]). Each row is an array of values in column order (dates as ISO strings, selects as the SelectItem value). Edits are submitted as a form field named after the table when the user clicks Save Changes. It draws its own frame, so never wrap it in a Card; place it directly in a Section or TabItem." + }, + "EditableCol": { + "signature": "EditableCol(key: string, label: string, type?: \"text\" | \"number\" | \"date\" | \"select\" | \"url\", options?: SelectItem[], icon?: Icon, width?: number)", + "description": "One EditableTable column: EditableCol(key, label, type?, options?, icon?, width?). type is the cell editor: \"text\" (default), \"number\", \"date\", \"select\" (pass options as SelectItems) or \"url\". icon is an optional leading header Icon; each type has its own default." }, "BarChart": { "signature": "BarChart(labels: string[], series: Series[], variant?: \"grouped\" | \"stacked\", xLabel?: string, yLabel?: string, height?: number)", @@ -63,11 +91,11 @@ }, "LineChart": { "signature": "LineChart(labels: string[], series: Series[], variant?: \"linear\" | \"natural\" | \"step\", xLabel?: string, yLabel?: string, height?: number)", - "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." }, "AreaChart": { "signature": "AreaChart(labels: string[], series: Series[], variant?: \"linear\" | \"natural\" | \"step\", xLabel?: string, yLabel?: string, height?: number)", - "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." }, "RadarChart": { "signature": "RadarChart(labels: string[], series: Series[])", @@ -110,36 +138,36 @@ "description": "Data point with numeric coordinates" }, "Form": { - "signature": "Form(name: string, buttons: Buttons, fields?: FormControl[])", - "description": "Form container with fields and explicit action buttons" + "signature": "Form(name: string, buttons: Buttons, fields?: (FormControl | FormRow)[])", + "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." + }, + "FormRow": { + "signature": "FormRow(fields: FormControl[])", + "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." }, "FormControl": { "signature": "FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup | Chips | OptionCards, hint?: string)", "description": "Field with label, input component, and optional hint text" }, - "Label": { - "signature": "Label(text: string)", - "description": "Text label" - }, "Input": { - "signature": "Input(name: string, placeholder?: string, type?: \"text\" | \"email\" | \"password\" | \"number\" | \"url\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", - "description": "" + "signature": "Input(name: string, placeholder?: string, type?: \"text\" | \"email\" | \"password\" | \"number\" | \"url\" | \"search\" | \"tel\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding, variant?: \"default\" | \"inline\")", + "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." }, "TextArea": { - "signature": "TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", - "description": "" + "signature": "TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding, variant?: \"default\" | \"inline\")", + "description": "A multi-line text field inside a FormControl, for free-form notes or messages: TextArea(name, placeholder?, rows?, rules?, value?, variant?), e.g. TextArea(\"notes\", \"Dietary needs, accessibility, special requests…\", 4). rows sets the visible height; 3–5 is typical. Never require it when leaving it blank is a valid answer. variant \"inline\" is borderless until hover or focus, for table cells and dense rows; use \"default\" everywhere else. For a one-line answer use Input." }, "Select": { - "signature": "Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding, size?: \"small\" | \"medium\" | \"large\")", - "description": "" + "signature": "Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding, variant?: \"default\" | \"inline\")", + "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." }, "SelectItem": { "signature": "SelectItem(value: string, label: string)", - "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." }, "DatePicker": { - "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", - "description": "" + "signature": "DatePicker(name: string, mode?: \"single\" | \"range\", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding, variant?: \"default\" | \"inline\")", + "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." }, "Slider": { "signature": "Slider(name: string, variant: \"continuous\" | \"discrete\", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding)", @@ -162,172 +190,188 @@ "description": "" }, "SwitchGroup": { - "signature": "SwitchGroup(name: string, items: SwitchItem[], variant?: \"clear\" | \"card\" | \"sunk\", value?: $binding>)", - "description": "Group of switch toggles" + "signature": "SwitchGroup(name: string, items: SwitchItem[], value?: $binding>)", + "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." }, "SwitchItem": { "signature": "SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean)", - "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." }, "ChipItem": { - "signature": "ChipItem(value: string, label: string, icon?: Icon, disabled?: boolean)", - "description": "A single selectable chip inside a Chips group, with a value, label and optional icon." + "signature": "ChipItem(value: string, label: string, leading?: Icon | Avatar | Emoji, disabled?: boolean)", + "description": "One selectable pill: ChipItem(value, label, leading?, disabled?), e.g. ChipItem(\"hotel\", \"Hotel\", Icon(\"building\", \"buildings\")). label is 1–3 words; leading is an optional Icon, an Avatar for a person or company, or a rare Emoji. Give every chip a leading or none of them." }, "Chips": { "signature": "Chips(name: string, type?: \"single\" | \"multiple\", items?: ChipItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, defaultValue?: string | string[])", - "description": "A form field of compact selectable chips for choosing one or many short options; the selection is stored under `name`." + "description": "A form field of compact selectable pills, inside a FormControl, for choosing one or many short options: Chips(name, \"single\" | \"multiple\", [ChipItem(...), ...], rules?, defaultValue?). A selected chip shows a checkmark at the end. Use it for short labels (1–3 words) such as tags, amenities or tools; for choices that need a description use OptionCards. The selection is stored under `name`." }, "OptionCard": { - "signature": "OptionCard(value: string, title: string, subtitle?: string, topContent?: Icon | Image, disabled?: boolean)", - "description": "A single selectable card inside an OptionCards group, with a value, title, optional subtitle and an optional Icon or Image on top." + "signature": "OptionCard(value: string, title: string, subtitle?: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[], leading?: Icon | Avatar | Emoji | Image, disabled?: boolean)", + "description": "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." }, "OptionCards": { "signature": "OptionCards(name: string, type?: \"single\" | \"multiple\", items?: OptionCard[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, defaultValue?: string | string[])", - "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`." + "description": "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`." }, "Button": { - "signature": "Button(label: string, action?: ActionExpression, variant?: \"primary\" | \"secondary\" | \"tertiary\", type?: \"normal\" | \"destructive\", size?: \"extra-small\" | \"small\" | \"medium\" | \"large\")", - "description": "Clickable button" + "signature": "Button(label: string, action?: ActionExpression, variant?: \"primary\" | \"secondary\" | \"tertiary\", type?: \"normal\" | \"destructive\", size?: \"default\" | \"large\", leading?: Icon)", + "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." }, "Buttons": { "signature": "Buttons(buttons: Button[], direction?: \"row\" | \"column\")", "description": "Group of Button components. direction: \"row\" (default) | \"column\"." }, "IconButton": { - "signature": "IconButton(name: string, icon: Icon, action?: ActionExpression, variant?: \"primary\" | \"secondary\" | \"tertiary\", size?: \"extra-small\" | \"small\" | \"medium\" | \"large\", shape?: \"square\" | \"circle\")", - "description": "Icon-only button. name is the accessible label and the action label; icon is an Icon; action fires on click." + "signature": "IconButton(name: string, icon: Icon, action?: ActionExpression, variant?: \"primary\" | \"secondary\" | \"tertiary\", size?: \"default\" | \"large\")", + "description": "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." }, "Stack": { "signature": "Stack(children: any[], direction?: \"row\" | \"column\", gap?: \"none\" | \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"2xl\", align?: \"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\", justify?: \"start\" | \"center\" | \"end\" | \"between\" | \"around\" | \"evenly\", wrap?: boolean)", "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." }, + "Section": { + "signature": "Section(children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard | Tabs | Carousel | Stack | Grid | Shell | Bleed | FollowUpBlock | Sources)[])", + "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." + }, + "Grid": { + "signature": "Grid(children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard | Card | Tabs | Carousel | Stack)[], columns?: 2 | 3 | 4)", + "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." + }, + "Shell": { + "signature": "Shell(title: string, children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard | Card | Grid | Tabs | Carousel | Stack)[])", + "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." + }, + "Bleed": { + "signature": "Bleed(children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard | Card | Grid | Tabs | Carousel | Stack)[])", + "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." + }, "Tabs": { "signature": "Tabs(items: TabItem[])", - "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." }, "TabItem": { - "signature": "TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | InlineHeader | EntityList | EditableTable | SnippetCardBlock | OverviewCardBlock | ContextCardBlock | CompositeCardBlock | VisualCardBlock)[])", - "description": "value is unique id, trigger is tab label, content is array of components" + "signature": "TabItem(value: string, trigger: string, content: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard)[], leading?: Icon | Avatar | Emoji)", + "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." }, "Accordion": { "signature": "Accordion(items: AccordionItem[])", - "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." }, "AccordionItem": { - "signature": "AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | InlineHeader | EntityList | EditableTable | SnippetCardBlock | OverviewCardBlock | ContextCardBlock | CompositeCardBlock | VisualCardBlock)[])", - "description": "value is unique id, trigger is section title" + "signature": "AccordionItem(value: string, trigger: string, content: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard)[], leading?: Icon | Avatar | Emoji)", + "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." }, "Steps": { "signature": "Steps(items: StepsItem[])", - "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." }, "StepsItem": { - "signature": "StepsItem(title: string, details: string)", - "description": "title and details text for one step" + "signature": "StepsItem(title: string | Text | Link | Tag | (string | Text | Link | Tag)[], subtitle?: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[])", + "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." }, "Carousel": { - "signature": "Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | InlineHeader | EntityList | EditableTable | SnippetCardBlock | OverviewCardBlock | ContextCardBlock | CompositeCardBlock | VisualCardBlock)[][], variant?: \"card\" | \"sunk\")", - "description": "Horizontal scrollable carousel" + "signature": "Carousel(children: (Card | Image | CompositeCardItem | ImageCardItem | InsightCardItem | ValueCardItem)[])", + "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." }, "Separator": { "signature": "Separator(orientation?: \"horizontal\" | \"vertical\", decorative?: boolean)", "description": "Visual divider between content sections" }, "TagBlock": { - "signature": "TagBlock(tags: string[], size?: \"sm\" | \"md\" | \"lg\")", - "description": "tags is an array of strings; optional size sm | md | lg" + "signature": "TagBlock(tags: string[])", + "description": "tags is an array of strings, each shown as a 24px Tag pill" }, "Tag": { - "signature": "Tag(text: string, icon?: Icon, size?: \"sm\" | \"md\" | \"lg\", variant?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\")", - "description": "Styled tag/badge with optional Icon and variant" + "signature": "Tag(text: string, leading?: Icon | Avatar | Emoji | Shape, variant?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"purple\" | \"pink\" | \"inverted\")", + "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\")." }, "Icon": { "signature": "Icon(name: string, category?: string)", - "description": "A lucide icon by kebab-case name (e.g. 'circle-check'). Optional category picks a topical fallback when the name doesn't resolve." + "description": "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." }, "EntityList": { - "signature": "EntityList(rows?: {left: string, right: string, rightVariant?: \"text\" | \"number\"}[], size?: \"small\" | \"default\", header?: {left: string, right: string, rightVariant?: \"text\" | \"number\"}, footer?: {left: string, right: string, rightVariant?: \"text\" | \"number\"})", - "description": "Two-column key/value rows (left label, right value). size 'default' supports optional header and footer rows; rightVariant 'number' uses tabular numbers." + "signature": "EntityList(rows?: EntityListItem[], size?: \"small\" | \"default\", header?: EntityListItem, footer?: EntityListItem)", + "description": "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." + }, + "EntityListItem": { + "signature": "EntityListItem(left: string | EntityTitle | LeadingText | (string | EntityTitle | LeadingText)[], right?: string | Metric | Tag | Text | (string | Metric | Tag | Text)[], rightVariant?: \"text\" | \"number\")", + "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\"))." }, "ListBlock": { - "signature": "ListBlock(items: ListItem[], variant?: \"number\" | \"image\", size?: \"default\" | \"small\")", - "description": "A list of items with number or image indicators. Each item can optionally have an action. size small renders a compact list." + "signature": "ListBlock(items: ListItem[], variant?: \"number\" | \"plain\")", + "description": "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." }, "ListItem": { - "signature": "ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: ActionExpression)", - "description": "Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable." - }, - "Text": { - "signature": "Text(variant?: \"text\" | \"number\", value: string, subtext?: string, subtextVariant?: \"text\" | \"number\" | \"metric\", size?: \"xs\" | \"sm\" | \"md\" | \"lg\")", - "description": "Plain text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red." - }, - "BoldText": { - "signature": "BoldText(variant?: \"text\" | \"number\", value: string, subtext?: string, subtextVariant?: \"text\" | \"number\" | \"metric\", size?: \"xs\" | \"sm\" | \"md\" | \"lg\")", - "description": "Emphasized (bold) text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red." + "signature": "ListItem(title: string | Text | Link | Tag | (string | Text | Link | Tag)[], subtitle?: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[], leading?: Avatar | Icon | Emoji, actionLabel?: string, action?: ActionExpression, actionSlot?: Button | Buttons | IconButton)", + "description": "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." }, - "IconText": { - "signature": "IconText(icon: Icon, iconVariant?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"inverted\" | \"filled\" | \"soft\", iconSize?: \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\" | \"sm\" | \"md\" | \"lg\", title: string, subtitle?: string, bold?: boolean, layout?: \"horizontal\" | \"vertical\")", - "description": "An icon badge with a title and optional subtitle, laid out horizontally or vertically. iconVariant sets the badge color." + "Timeline": { + "signature": "Timeline(items: TimelineItem[])", + "description": "Events in time order along a dashed rail: an itinerary, a schedule, order status, a project history or an activity log. Timeline([TimelineItem(\"Check in\", \"Hotel Lutetia\", \"3:00 PM\"), TimelineItem(Text(\"Dinner\", \"body\", true), \"Septime · table for 2\", \"8:30 PM\")]). Give every item a time when the moments are known; use status to show progress through a plan. Use Steps for how-to instructions and ListBlock for an unordered set." }, - "ImageText": { - "signature": "ImageText(src: string, alt?: string, title: string, subtitle?: string, bold?: boolean, layout?: \"horizontal\" | \"vertical\", imageSize?: number)", - "description": "A small square image (thumbnail/avatar) with a title and optional subtitle. src must be a real image URL." + "TimelineItem": { + "signature": "TimelineItem(title: string | Text | Link | Tag | (string | Text | Link | Tag)[], subtitle?: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[], time?: string | Text | Tag | (string | Text | Tag)[], leading?: Avatar | Icon | Emoji, status?: \"done\" | \"current\" | \"upcoming\")", + "description": "One event in a Timeline: TimelineItem(title, subtitle?, time?, leading?, status?). title is a string, or a Text, Link or Tag; emphasize a named event with Text(\"Doors open\", \"body\", true). subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. time is when it happens, shown in a column on the left: a short string such as \"9:00 AM\", \"Day 2\" or \"Mar 2024\", or a Text or Tag. leading is an optional Avatar, Icon or Emoji that replaces the dot, e.g. Avatar(\"Priya Shah\", null, null, null, \"inline\", Icon(\"user\", \"people\")) for who acted. status \"done\" fills the dot, \"current\" highlights it, \"upcoming\" quiets the title; leave it out for a plain history. When title or subtitle needs two or more pieces side by side, pass an array." }, - "ImageTextLarge": { - "signature": "ImageTextLarge(src: string, alt?: string, title: string, subtitle?: string, bold?: boolean)", - "description": "A full-width banner image above a bold title and optional subtitle. src must be a real image URL." + "Checklist": { + "signature": "Checklist(items: ChecklistItem[], name?: string, value?: $binding>)", + "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." }, - "MetricIndicatorInline": { - "signature": "MetricIndicatorInline(value: string, subtext?: string, trend?: {direction: \"up\" | \"down\", value: number})", - "description": "Headline metric value with an optional +/- percentage trend and subtext, all on one line." + "ChecklistItem": { + "signature": "ChecklistItem(title: string | Text | Link | Tag | (string | Text | Link | Tag)[], subtitle?: string | Text | Metric | Tag | TagBlock | LeadingText | Link | (string | Text | Metric | Tag | TagBlock | LeadingText | Link)[], checked?: boolean, value?: string)", + "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." }, - "MetricIndicatorWithStrikethrough": { - "signature": "MetricIndicatorWithStrikethrough(value: string, subtext?: string, previousValue?: string, trend?: {direction: \"up\" | \"down\", value: number})", - "description": "Headline metric value with an optional struck-through previousValue, a +/- percentage trend, and subtext below." + "ValueCardItem": { + "signature": "ValueCardItem(id?: string, title: string, subtitle?: string | Text | Metric | Tag | LeadingText | Link | (string | Text | Metric | Tag | LeadingText | Link)[], leading?: Avatar | Icon | Emoji, value?: Metric | Text | Tag | TagBlock | LeadingText | (Metric | Text | Tag | TagBlock | LeadingText)[])", + "description": "One card in a ValueCard: ValueCardItem(id?, title, subtitle?, leading?, value?). title says what the value is; subtitle adds a short context line (a string, or a Text, Metric, Tag, LeadingText or Link, or an array of them on one line); leading is an optional Avatar (a photo, logo or tinted icon badge), Icon or Emoji. In a horizontal card with a subtitle, the title stacks over the subtitle beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge) and the value sits on the right; without a subtitle the leading sits inline before the title like a LeadingText. In a vertical card the leading and title share one line, the subtitle sits under the title, and the hero value follows below. value is a Metric, Text, Tag, TagBlock or LeadingText, or an array of them on one line." }, - "SnippetCardItem": { - "signature": "SnippetCardItem(id?: string, lhs: IconText | ImageText, rhs?: Text | BoldText)", - "description": "One row-style snippet card: a label on the left (IconText or ImageText) and an optional value on the right (Text or BoldText)." + "ValueCard": { + "signature": "ValueCard(items: ValueCardItem[], layout?: \"horizontal\" | \"vertical\", responsive?: boolean, action?: ActionExpression, gap?: number | string, carousel?: boolean)", + "description": "A responsive grid of 2 or more compact cards, each one short fact or KPI: ValueCard([item1, item2, item3], layout?, responsive?, action?, gap?, carousel?). Each card is a ValueCardItem(id?, title, subtitle?, leading?, value?), e.g. ValueCardItem(\"revenue\", \"Revenue\", \"Month to date\", Icon(\"circle-dollar-sign\", \"finance\"), Metric(\"$48,200\", \"+8.1%\", null, \"sm\")). Set layout once on the ValueCard for the whole group, never per item. Use \"horizontal\" only when every value is a single short piece (one Metric or a short Text); if any value has more, such as a Metric plus a Tag or a Metric plus a Text note, use \"vertical\" for the whole block. \"horizontal\" (default) is for key/value facts: the value goes on the right, with a Metric at \"sm\"; with a subtitle the title stacks over it beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge), and without one the leading sits inline before the title like a LeadingText. \"vertical\" is for KPI tiles where the number is the hero: the leading and title sit on one line, the subtitle on its own line under the title, and the value goes below at the default size, e.g. ValueCard([v1, v2, v3], \"vertical\", true). leading is an Icon for a concept, or an Avatar for a person or company with a real photo or logo. carousel (vertical only) scrolls the cards sideways instead of wrapping, for secondary browsing. Every item uses the same parts. action is one shared action for all cards, with the clicked card's details attached, so keep its @ToAssistant message generic and never use @OpenUrl. Use ValueCard for dashboard-like output, such as an analytics or monitoring view; for an inline section of figures inside a normal response use ValueLayout instead. For a long list of key/value rows use EntityList; for one headline figure use a Metric on its own." }, - "SnippetCardBlock": { - "signature": "SnippetCardBlock(items: SnippetCardItem[], layout?: \"grid\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", - "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." + "ValueLayout": { + "signature": "ValueLayout(items: ValueCardItem[], layout?: \"horizontal\" | \"vertical\", responsive?: boolean, gap?: number | string, carousel?: boolean)", + "description": "A lighter ValueCard: the same ValueCardItems, laid out in a grid or carousel with dashed rules between them instead of tinted cards. ValueLayout([item1, item2, item3], layout?, responsive?, gap?, carousel?), e.g. ValueLayout([ValueCardItem(\"revenue\", \"Revenue\", \"Month to date\", Icon(\"circle-dollar-sign\", \"finance\"), Metric(\"$48,200\", \"+8.1%\", null, \"sm\")), ValueCardItem(\"orders\", \"Orders\", \"Month to date\", Icon(\"shopping-cart\", \"shopping\"), Metric(\"1,204\", \"+3.4%\", null, \"sm\"))]). It follows every ValueCard rule: 2 or more items of the same shape, layout set once on the group, \"horizontal\" only when every value is one short piece and \"vertical\" otherwise, and carousel for vertical only. It has no action: it is informational only, so use ValueCard if the figures must be clickable. Use ValueLayout as an inline section of figures inside a normal UI response, such as key numbers in an answer about a company, city or product, where tinted cards would feel heavy. Use ValueCard for dashboard-like output, such as an analytics or monitoring view." }, - "OverviewCardItem": { - "signature": "OverviewCardItem(id?: string, top: IconText | ImageText | Text, bottom?: MetricIndicatorInline)", - "description": "One overview card: a heading slot at the top (IconText, ImageText or Text) and an optional MetricIndicatorInline at the bottom." + "InsightCardItem": { + "signature": "InsightCardItem(id?: string, title: string, subtitle?: string | Text | Metric | Tag | LeadingText | Link | (string | Text | Metric | Tag | LeadingText | Link)[], overline?: string, leading?: Avatar | Icon | Emoji, color?: \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\" | \"purple\" | \"pink\" | \"inverted\")", + "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." }, - "OverviewCardBlock": { - "signature": "OverviewCardBlock(items: OverviewCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", - "description": "A grid or horizontal carousel of compact overview cards, each with a heading (icon/image/text) on top and an inline metric below; optionally clickable with a shared action." - }, - "ContextCardItem": { - "signature": "ContextCardItem(id?: string, title: string | Tag, body?: string, bgColor?: \"gray\", bgImageSrc?: string, bgImageAlt?: string)", - "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." - }, - "ContextCardBlock": { - "signature": "ContextCardBlock(items: ContextCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", - "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." + "InsightCard": { + "signature": "InsightCard(items: InsightCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", + "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." }, "CompositeCardItem": { - "signature": "CompositeCardItem(id?: string, header?: IconText | ImageText | ImageTextLarge | Text | Image, body?: (Text | BoldText | MetricIndicatorInline | IconText | Image | AreaChart | BarChart | LineChart | ListBlock | TagBlock | EntityList)[], footer?: {price?: BoldText | MetricIndicatorWithStrikethrough, button?: Button})", - "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." + "signature": "CompositeCardItem(id?: string, header?: EntityTitle | ImageTitle | Text | Image | LeadingText, body?: (Text | Metric | EntityTitle | Image | AreaChart | BarChart | LineChart | ListBlock | TagBlock | EntityList | Checklist)[], footer?: {price?: Metric, button?: Button | Buttons | IconButton | IconButton[]}, media?: Image | AreaChart | BarChart | LineChart)", + "description": "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." }, "CompositeCardBlock": { "signature": "CompositeCardBlock(items: CompositeCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", - "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." + "description": "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." }, - "VisualCardItem": { - "signature": "VisualCardItem(body: BoldText, id?: string, bgImageSrc?: string, tag?: Tag, bgImageAlt?: string)", - "description": "A single photo-first card inside a VisualCardBlock: a BoldText body panel, an optional Tag, and a background image (bgImageSrc must be a real URL; bgImageAlt is its alt text)." + "ImageCardItem": { + "signature": "ImageCardItem(id?: string, src: string, title: string, subtitle?: string | Text | Metric | Tag | LeadingText | Link | (string | Text | Metric | Tag | LeadingText | Link)[], overline?: string, leading?: Avatar | Icon | Emoji, alt?: string, actions?: IconButton | IconButton[])", + "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." }, - "VisualCardBlock": { - "signature": "VisualCardBlock(items: VisualCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", - "description": "A grid or carousel of photo-first cards: a full-bleed background image with a tag on top and a bold text panel at the bottom; an optional action makes every card clickable." + "ImageCard": { + "signature": "ImageCard(items: ImageCardItem[], layout?: \"grid\" | \"carousel\", responsive?: boolean, action?: ActionExpression, gap?: number | string)", + "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." }, "Modal": { - "signature": "Modal(title: string, open?: $binding, children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | InlineHeader | EntityList | EditableTable | SnippetCardBlock | OverviewCardBlock | ContextCardBlock | CompositeCardBlock | VisualCardBlock)[], size?: \"sm\" | \"md\" | \"lg\")", + "signature": "Modal(title: string, open?: $binding, children: (Text | Markdown | Link | Metric | Avatar | AvatarStack | Heading | LeadingText | EntityTitle | ImageTitle | Callout | CodeBlock | Image | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | IconButton | Steps | Timeline | Checklist | EntityList | EditableTable | ValueCard | ValueLayout | InsightCard | CompositeCardBlock | ImageCard)[], size?: \"sm\" | \"md\" | \"lg\")", "description": "Modal dialog. open is a reactive $boolean binding — set to true to open, X/Escape/backdrop auto-closes. Put Form with buttons inside children." + }, + "FollowUpBlock": { + "signature": "FollowUpBlock(items: FollowUpItem[])", + "description": "List of clickable follow-up suggestions placed at the end of a response" + }, + "FollowUpItem": { + "signature": "FollowUpItem(text: string)", + "description": "Clickable follow-up suggestion — when clicked, sends text as user message" + }, + "Sources": { + "signature": "Sources(items: {url?: string, title: string, sourceName: string}[])", + "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." } }, "componentGroups": [ @@ -335,6 +379,10 @@ "name": "Layout", "components": [ "Stack", + "Section", + "Grid", + "Shell", + "Bleed", "Tabs", "TabItem", "Accordion", @@ -346,9 +394,10 @@ "Modal" ], "notes": [ - "- For grid-like layouts, use Stack with direction \"row\" and wrap set to true.", - "- Prefer justify \"start\" (or omit justify) with wrap=true for stable columns instead of uneven gutters.", + "- Shell(title, [children]) frames a dashboard or large Grid layout: a small title, an outline, wider than the chat column, with an expand button for full screen. At most one per response. Bleed([children]) is the same width with no frame, for a wide hero Image, comparison Grid or Carousel; wrap a Grid comparing an odd number of cards (3 or 5 plans) in a Bleed, Bleed([Grid([p1, p2, p3], 3)]), so they sit in one row instead of 2 + 1. Never nest either.", + "- For dashboards and equal columns use Grid(children, columns?): columns 2 (default) | 3 | 4, collapsing to fewer on narrow screens, e.g. a KPI row Grid([c1, c2, c3, c4], 4) above Grid([chartCard1, chartCard2]). Use a Stack row only for a few items of mixed width.", "- Use nested Stacks when you need explicit rows/sections.", + "- Build every response from Sections: Section([children]) holds one part (an overview, then options, then next steps) with more space after it and tight spacing inside. Compose each part yourself: a Heading title first when it needs one, then content; rows or grids inside with Stack or card blocks. Never nested. Sections are high-level divisions and many responses have only one; never give a lone Text, Markdown, Buttons row or title (Heading or Text, with or without a subtitle) its own Section, so an intro or a dashboard header goes first inside the Section holding the content it introduces.", "- Show/hide sections: $editId != \"\" ? Card([editForm]) : null", "- Modal: Modal(\"Title\", $showModal, [content]) — $showModal is boolean, X/Escape auto-closes. Put Form with its own buttons inside children.", "- Use Tabs for alternative views (chart types, data sections) — no $variable needed", @@ -359,22 +408,42 @@ "name": "Content", "components": [ "Card", - "CardHeader", - "TextContent", - "MarkDownRenderer", + "Text", + "Markdown", + "Metric", + "Avatar", + "Emoji", + "Shape", + "AvatarStack", + "Heading", + "LeadingText", + "EntityTitle", + "ImageTitle", + "Link", "Callout", - "TextCallout", "Image", - "ImageBlock", "ImageGallery", - "CodeBlock", - "InlineHeader" + "CodeBlock" ], "notes": [ - "- InlineHeader is a compact heading + description pair for labelling a block (lighter than CardHeader).", - "- Use Cards to group related KPIs or sections. Stack with direction \"row\" for side-by-side layouts.", - "- Success toast: Callout(\"success\", \"Saved\", \"Done.\", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == \"error\" ? Callout(\"error\", \"Failed\", result.error) : null", - "- KPI card: Card([TextContent(\"Label\", \"small\"), TextContent(\"\" + @Count(@Filter(data.rows, \"field\", \"==\", \"value\")), \"large-heavy\")])" + "- Metric(value, delta?, variant?, size?, previous?) is a value with an optional change (+/- shows an up/down arrow); label it with the Card title inside a Card, or compose one with Text or Heading elsewhere. It uses the UI font, so use size \"sm\" whenever it is not the hero number: Table cells, EntityListItem right, ListItem/EntityTitle subtitles, ImageTitle content, ValueCardItem value, CompositeCardItem body (a CompositeCardItem footer price always renders at 18px), e.g. Col(\"Change\", @Each(data.rows, \"r\", Metric(r.value, r.change, \"horizontal\", \"sm\"))). Only a standalone headline figure gets \"vertical\", \"large\", e.g. Card([Metric(\"$48,200\", \"+8.1%\", \"vertical\", \"large\")], null, \"Revenue\"). previous (5th) strikes through an earlier value for a real before/after, like a price drop: Metric(\"$89\", \"-26%\", null, \"sm\", \"$120\").", + "- Avatar is a photo, logo or tinted icon badge that can stand in for an Icon (LeadingText, Tag, Chips): Avatar(\"Ada Lovelace\", \"https://…/ada.jpg\", null, null, null, Icon(\"user\", \"people\")) for a person, Avatar(\"Stripe\", null, \"rounded\", \"stripe.com\", null, Icon(\"building-2\", \"business\")) for a company logo, Avatar(\"Billing\", null, \"rounded\", null, \"md\", Icon(\"credit-card\", \"finance\"), \"info\") for a standalone concept badge. ALWAYS pass an icon, even with src or domain: it renders when the image is missing or broken. src must be a real URL; a person with no real photo URL gets Icon(\"user\", \"people\") inside and no src. The icon slot also takes a Shape for a solid colored mark. A concept on a text line stays a plain Icon.", + "- Heading(\"Title\", \"Supporting line\") titles a UI section when the description adds a fact the title lacks (scope, unit, time range, what to do). Never a description that greets, introduces you, restates the title or says what the section contains; a bare title is Text h2/h3.", + "- Greetings, small talk and short conversational replies are one plain Markdown block with no heading, Heading or Text title.", + "- EntityTitle(leading, title, subtitle) names a person, company, product, place or topic behind an Avatar-first leading visual (a photo, a domain logo or a tinted icon badge): EntityTitle(Avatar(\"Stripe\", null, \"rounded\", \"stripe.com\", null, Icon(\"building-2\", \"business\")), \"Stripe\", \"Payments infrastructure\"). subtitle is required: a string, or a Tag, Metric, Link, TagBlock or LeadingText for a status, price, source or labels. A title with no subtitle is a LeadingText. Heading titles a section.", + "- ImageTitle(Image(alt, src, ratio), [content]) is one item in a grid or Carousel of 2+ products, articles or places: image on top, then a short content stack, usually a title Text(\"…\", \"body\", true) (an EntityTitle only for a real person, brand or seller with their own photo or logo, never an icon Avatar), then one secondary line (an article author as a LeadingText with an inline Avatar and tone \"secondary\", a price Metric, Tags). Keep every item the same shape and ratio; a single illustrative picture is Image plus Markdown. Real image URLs only.", + "- LeadingText(leading, Text(...)) puts an Icon, Avatar or AvatarStack before any Text, centered on its first line: Icon for a concept or fact (LeadingText(Icon(\"calendar\", \"time\"), Text(\"Opens daily 9am–6pm\"))), Shape for a status dot, legend marker or trend triangle (LeadingText(Shape(\"triangle-up\", \"success\"), Text(\"+12% this week\"))), Avatar for one real person or company, AvatarStack for a group of them, and rarely an Emoji for expressive generated content (never in UI chrome). Avatars here use size \"inline\".", + "- AvatarStack([a1, a2, a3, a4, a5], 3) overlaps 2+ Avatars with a count chip for the rest: a team, attendees, who replied. Keep max at 3–5, set size on the stack, and name the group in Text beside it.", + "- Markdown is for explanations and the top-level prose that sets context around UI. Text is for short UI text: headings, labels, captions, single values.", + "- Answering a question with a title and prose: one Markdown block with a # heading, not Text h1 + Markdown. Text headings title UI (dashboards, forms, card groups); a part of a longer multi-part response is a Section that starts with a Heading.", + "- Callout(\"warning\", \"**Visa required.** Apply 3 weeks ahead.\") is an icon (or a rare Emoji) plus short Markdown content for one standout aside (tip, caveat, warning, result), with no title. Pick variant by meaning; one or two per response at most; never for notes about what you did.", + "- Highlight in Markdown with ==key phrase==: a soft green marker behind the text.", + "- Use it for the one phrase that directly answers the question, at most 1-2 per response. Never for whole sentences, headings, links or code.", + "- Links: in Markdown write [descriptive text](https://…); the site's favicon renders before the link text automatically. Never use a bare URL as link text.", + "- For a standalone link outside prose (a source, docs or product page), use Link(\"Page name\", \"https://…\"). When a Link is the title of a ListItem, StepsItem or TimelineItem, pass size \"lg\" so it matches the title size: Link(\"Septime\", \"https://…\", \"lg\").", + "- Use Cards to group related KPIs or blocks: variant \"outlined\" (default) for ordinary groups, \"lifted\" for the one block that should stand out, \"sunk\" for secondary or nested content. For grouping without a frame use Stack; Stack with direction \"row\" for side-by-side layouts. A Card's children stack on their own; for a row or custom spacing inside a Card, pass one Stack child, e.g. Card([Stack([chartA, chartB], \"row\", \"m\")]).", + "- Success toast: Callout(\"success\", \"**Saved.** Your changes are live.\", $showSuccess) — use @Set($showSuccess, true) in save action, auto-dismisses after 3s. For errors: result.status == \"error\" ? Callout(\"error\", result.error) : null", + "- KPI card: Card([Metric(\"\" + @Count(@Filter(data.rows, \"field\", \"==\", \"value\")), null, \"vertical\", \"large\")], null, \"Label\")" ] }, { @@ -382,18 +451,19 @@ "components": [ "Table", "Col", - "EditableTable" + "EditableTable", + "EditableCol" ], "notes": [ - "- EditableTable lets the user edit cells inline. Give it a unique name, columns of { type, key, header } with type one of text | number | date-single | select | url (select also needs options: [{ value, label }]).", - "- data is an array of { id, values } rows where values are ordered positionally to match columns. Edited data is submitted when the user clicks Save Changes.", + "- EditableTable(name, [EditableCol(key, label, type?, options?, icon?)], rows) lets the user edit cells inline. type is the cell editor: \"text\" (default), \"number\", \"date\", \"select\" (options as SelectItems) or \"url\"; each column shows a leading header icon for its type unless you pass an Icon.", + "- rows is an array of value arrays in column order (dates as ISO strings, selects as the SelectItem value). Edited data is submitted when the user clicks Save Changes.", "- Table is COLUMN-oriented: Table([Col(\"Label\", dataArray), Col(\"Count\", countArray, \"number\")]). Use array pluck for data: data.rows.fieldName", - "- Col data can be component arrays for styled cells: Col(\"Status\", @Each(data.rows, \"item\", Tag(item.status, null, \"sm\", item.status == \"open\" ? \"success\" : \"danger\")))", + "- Col data can be component arrays for styled cells: Col(\"Status\", @Each(data.rows, \"item\", Tag(item.status, null, item.status == \"open\" ? \"success\" : \"danger\")))", "- Row actions: Col(\"Actions\", @Each(data.rows, \"t\", Button(\"Edit\", Action([@Set($showEdit, true), @Set($editId, t.id)]))))", "- Sortable: sorted = @Sort(data.rows, $sortField, \"desc\"). Bind $sortField to Select. Use sorted.fieldName for Col data", "- Searchable: filtered = @Filter(data.rows, \"title\", \"contains\", $search). Bind $search to Input", "- Chain sort + filter: filtered = @Filter(...) then sorted = @Sort(filtered, ...) — use sorted for both Table and Charts", - "- Empty state: @Count(data.rows) > 0 ? Table([...]) : TextContent(\"No data yet\")" + "- Empty state: @Count(data.rows) > 0 ? Table([...]) : Text(\"No data yet\")" ] }, { @@ -408,7 +478,7 @@ ], "notes": [ "- Charts accept column arrays: LineChart(labels, [Series(\"Name\", values)]). Use array pluck: LineChart(data.rows.day, [Series(\"Views\", data.rows.views)])", - "- Use Cards to wrap charts with CardHeader for titled sections", + "- Use Cards to wrap charts, titled with the Card title and subtitle args, e.g. Card([chart], null, \"Revenue by month\", \"Jul–Sep\"), never a Heading or Text title inside the Card, but never wrap a Table or EditableTable in a Card: tables draw their own frame, so they sit directly in a Section or TabItem.", "- Chart + Table from same source: use @Sort or @Filter result for both LineChart and Table Col data", "- Multiple chart views: use Tabs — Tabs([TabItem(\"line\", \"Line\", [LineChart(...)]), TabItem(\"bar\", \"Bar\", [BarChart(...)])])" ] @@ -424,7 +494,7 @@ "notes": [ "- PieChart and BarChart need NUMBERS, not objects. For list data, use @Count(@Filter(...)) to aggregate:", "- PieChart from list: `PieChart([\"Low\", \"Med\", \"High\"], [@Count(@Filter(data.rows, \"priority\", \"==\", \"low\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"medium\")), @Count(@Filter(data.rows, \"priority\", \"==\", \"high\"))], \"donut\")`", - "- KPI from count: `TextContent(\"\" + @Count(@Filter(data.rows, \"status\", \"==\", \"open\")), \"large-heavy\")`" + "- KPI from count: `Metric(\"\" + @Count(@Filter(data.rows, \"status\", \"==\", \"open\")), null, \"vertical\", \"large\")`" ] }, { @@ -439,8 +509,8 @@ "name": "Forms", "components": [ "Form", + "FormRow", "FormControl", - "Label", "Input", "TextArea", "Select", @@ -460,7 +530,9 @@ ], "notes": [ "- Chips: compact single/multiple selection pills. Use ChipItem references for each option.", - "- OptionCards: larger selectable cards with title, subtitle and an optional Icon or Image on top. Use OptionCard references for each option.", + "- OptionCards: larger selectable cards with a title, a composable subtitle and an optional leading (Icon, Avatar, Image or rare Emoji); the selected card shows a checkmark. Use OptionCard references for each option; use it for 2–6 choices that need a short explanation, Chips for short labels.", + "- FormRow([fieldA, fieldB]) puts 2–3 short related FormControls side by side (first and last name; city, state and zip) and stacks them on narrow widths. Mix FormRows with full-width FormControls in Form fields; keep TextArea and other long fields full width.", + "- Pick Input type for what is entered (\"email\", \"password\", \"number\", \"url\", \"search\", \"tel\", or \"text\") since it sets the leading icon automatically. Use variant \"inline\" on Input, TextArea, Select and DatePicker only inside table cells or dense rows, where a borderless field fits; \"default\" everywhere else.", "- For Form fields, define EACH FormControl as its own reference — do NOT inline all controls in one array. This allows progressive field-by-field streaming.", "- NEVER nest Form inside Form — each Form should be a standalone container.", "- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument.", @@ -479,7 +551,6 @@ "IconButton" ], "notes": [ - "- Icon renders a lucide icon by kebab-case name; it is also used as the icon of IconButton, IconText and OptionCard.", "- Toggle in @Each: @Each(rows, \"t\", Button(t.status == \"open\" ? \"Close\" : \"Reopen\", Action([...])))" ] }, @@ -490,44 +561,56 @@ "Tag", "Icon", "EntityList", + "EntityListItem", "ListBlock", - "ListItem" + "ListItem", + "Timeline", + "TimelineItem", + "Checklist", + "ChecklistItem" ], "notes": [ - "- Color-mapped Tag: Tag(value, null, \"sm\", value == \"high\" ? \"danger\" : value == \"medium\" ? \"warning\" : \"neutral\")", - "- EntityList is a compact two-column list of { left, right } rows (e.g. name / value). size='default' also supports a header and footer row; size='small' does not.", - "- ListBlock is a numbered or image list of ListItem references. An action on ListItem is optional." + "- Icon is a lucide line icon by exact kebab-case name that fills icon slots (LeadingText, Tag, ChipItem, Avatar, EntityTitle, IconButton, OptionCard); the host sets its size, stroke and color. An unknown name shows a plain circle.", + "- Color-mapped Tag: Tag(value, null, value == \"high\" ? \"danger\" : value == \"medium\" ? \"warning\" : \"neutral\")", + "- EntityList is a compact list of EntityListItem(left, right?, rightVariant?) rows. left is a plain string for key/value facts, an EntityTitle for people, companies or products, or a LeadingText for facts with an icon; right is an optional string, Metric, Tag or Text. size='default' also supports a header and footer row; size='small' does not.", + "- ListBlock is a numbered (default) or plain list of ListItem references. Numbered suits rankings and ordered picks; plain suits people, companies or places that lead with an Avatar. A ListItem is clickable only with an action.", + "- Timeline lists events in time order along a dashed rail, with an optional time column on the left: itineraries, schedules, project histories, order tracking. TimelineItem(title, subtitle?, time?, leading?, status?); status done/current/upcoming shows progress through a plan. Use Steps for numbered how-to instructions and ListBlock for unordered or ranked sets.", + "- Steps([StepsItem(title, subtitle?)]) is a numbered how-to; the number chips are automatic. title is a string or a Text (pass Text(\"Install the CLI\", \"body\", true) to emphasize it), and subtitle is one or two plain sentences on how to do it.", + "- Checklist(items, name?, value?) with ChecklistItem(title, subtitle?, checked?, value?) is a checkable list of to-dos, packing items, launch or onboarding steps: the user ticks items, checked sets the starting state, and value is a stable key per item. Give it a name when the ticks should persist or you need to read them later. Inside a CompositeCardItem body it is read-only, for what a plan includes. Steps is for numbered how-tos, Timeline for time-ordered events." ] }, { "name": "Cards", "components": [ - "SnippetCardBlock", - "SnippetCardItem", - "OverviewCardBlock", - "OverviewCardItem", - "ContextCardBlock", - "ContextCardItem", + "ValueCard", + "ValueCardItem", + "ValueLayout", + "InsightCard", + "InsightCardItem", "CompositeCardBlock", "CompositeCardItem", - "VisualCardBlock", - "VisualCardItem", - "Text", - "BoldText", - "IconText", - "ImageText", - "ImageTextLarge", - "MetricIndicatorInline", - "MetricIndicatorWithStrikethrough" + "ImageCard", + "ImageCardItem" ], "notes": [ "- Card blocks lay out 2+ items in a responsive grid (or carousel where supported). Every item in a block must have the same structure.", - "- SnippetCardItem: small card with lhs (IconText | ImageText) and optional rhs (Text | BoldText) — good for key/value facts.", - "- OverviewCardItem: small card with top (IconText | ImageText | Text) and optional bottom MetricIndicatorInline — good for KPIs.", - "- ContextCardItem: medium card with a title (string or Tag), body text and optional background image — good for summaries.", - "- CompositeCardItem: rich card with header, body array (Text, BoldText, MetricIndicatorInline, IconText, Image, charts, ListBlock, TagBlock, EntityList) and footer (price + Button) — good for products/offers.", - "- VisualCardItem: image-first card with a BoldText body and optional Tag.", - "- Text / BoldText / IconText / ImageText / ImageTextLarge / MetricIndicator* are the inline building blocks used INSIDE card items; do not place them directly in the root Card." + "- ValueCard: a grid of 2+ small ValueCardItem(id?, title, subtitle?, leading?, value?) cards, e.g. ValueCardItem(\"revenue\", \"Revenue\", \"Month to date\", Icon(\"circle-dollar-sign\", \"finance\"), Metric(\"$48,200\", \"+8.1%\", null, \"sm\")). leading is an Icon for a concept or an Avatar for a real person or company (36px beside a stacked title and subtitle in horizontal cards, inline before the title otherwise); subtitle and value each take one piece or an array on one line, e.g. value [Metric(\"99.98%\"), Tag(\"Healthy\", null, \"success\")]. layout \"horizontal\" (default) for key/value facts (value on the right, Metric at \"sm\"); \"vertical\" for KPI tiles (a hero Metric at the default size), optionally with carousel. Set layout on the group, never per item: \"horizontal\" only when every value is one short piece (one Metric or short Text), otherwise \"vertical\" for the whole block. Keep every item the same shape, and use one generic shared action at most.", + "- ValueLayout takes the same ValueCardItems and layout rules as ValueCard but draws dashed rules instead of tinted cards and has no action. Use ValueCard for dashboard-like output (analytics, monitoring) and ValueLayout as an inline section of figures inside a normal UI response.", + "- InsightCard: 2+ tall InsightCardItem(id?, title, subtitle?, overline?, leading?, color?) tiles, each one takeaway: a short overline (with an optional leading) at the top, and the title (the insight, 22px) with a composable subtitle at the bottom, on a palette color (picked by meaning, never invented); no photo, which is ImageCard. Keep the overline to a few words and the title to one short insight, never a Tag. Use it for research takeaways, trends or news with a status Tag in the subtitle, topics to explore, plan options and suggested next topics.", + "- CompositeCardItem: rich white card with a header (EntityTitle, ImageTitle, Image, or a Text(\"…\", \"h3\") title), an optional edge-to-edge media at the top (5th argument: a real photo Image, or an AreaChart, BarChart or LineChart for a metric or trend card), a body array (Text as 14px body copy, Metric at sm, EntityTitle, Image, charts, ListBlock, TagBlock, EntityList, or a Checklist for what a plan includes) and a footer (price Metric, always 18px, with previous for a sale, plus a Button, Buttons([primary, secondary], \"column\") for two actions, or IconButtons on the right; real actions only). The fallback block: use it only when no specialised card (ImageCard, InsightCard, ValueCard, EntityList, ListBlock, OptionCards) fits, for products, offers and plans that need mixed content. Keep one strong block per card: never put an EntityTitle header next to another big block (media, a chart, an ImageTitle, a large Metric); with a photo or chart hero, title the card with a plain Text, or a LeadingText(Icon(...), Text(..., \"body\", true)) when an icon helps.", + "- ImageCard: 2+ tall ImageCardItem(id?, src, title, subtitle?, overline?, leading?, alt?, actions?) photo tiles for places, products, media or inspiration: a real photo behind an overline (top left), optional IconButton actions (top right), and the title with a composable subtitle (bottom). Only with real image URLs for every item, otherwise InsightCard; IconButtons only for real @OpenUrl or @ToAssistant actions, never save or share." + ] + }, + { + "name": "Response endings", + "components": [ + "FollowUpBlock", + "FollowUpItem", + "Sources" + ], + "notes": [ + "- FollowUpBlock([FollowUpItem(\"Show 5-year price performance\"), FollowUpItem(\"Add NVDA to this comparison\")]) lists 3–4 short next questions under a \"Related Queries\" header; clicking one sends its text as the user's next message. Put it last in the final Section of an answer that invites more exploration (research, comparisons, plans), never after a clarifying question or a form.", + "- Sources([{ title, sourceName, url }]) is a row of source cards with favicons. Add it only when the answer used real references from a search or tool result, never invented ones, as the very last child of the final Section, after any FollowUpBlock." ] } ], @@ -538,36 +621,54 @@ "Card": { "$ref": "#/$defs/Card" }, - "CardHeader": { - "$ref": "#/$defs/CardHeader" + "Text": { + "$ref": "#/$defs/Text" + }, + "Markdown": { + "$ref": "#/$defs/Markdown" + }, + "Metric": { + "$ref": "#/$defs/Metric" + }, + "Avatar": { + "$ref": "#/$defs/Avatar" + }, + "Emoji": { + "$ref": "#/$defs/Emoji" + }, + "Shape": { + "$ref": "#/$defs/Shape" }, - "TextContent": { - "$ref": "#/$defs/TextContent" + "AvatarStack": { + "$ref": "#/$defs/AvatarStack" }, - "MarkDownRenderer": { - "$ref": "#/$defs/MarkDownRenderer" + "Heading": { + "$ref": "#/$defs/Heading" + }, + "LeadingText": { + "$ref": "#/$defs/LeadingText" + }, + "EntityTitle": { + "$ref": "#/$defs/EntityTitle" + }, + "ImageTitle": { + "$ref": "#/$defs/ImageTitle" + }, + "Link": { + "$ref": "#/$defs/Link" }, "Callout": { "$ref": "#/$defs/Callout" }, - "TextCallout": { - "$ref": "#/$defs/TextCallout" - }, "Image": { "$ref": "#/$defs/Image" }, - "ImageBlock": { - "$ref": "#/$defs/ImageBlock" - }, "ImageGallery": { "$ref": "#/$defs/ImageGallery" }, "CodeBlock": { "$ref": "#/$defs/CodeBlock" }, - "InlineHeader": { - "$ref": "#/$defs/InlineHeader" - }, "Table": { "$ref": "#/$defs/Table" }, @@ -577,6 +678,9 @@ "EditableTable": { "$ref": "#/$defs/EditableTable" }, + "EditableCol": { + "$ref": "#/$defs/EditableCol" + }, "BarChart": { "$ref": "#/$defs/BarChart" }, @@ -619,12 +723,12 @@ "Form": { "$ref": "#/$defs/Form" }, + "FormRow": { + "$ref": "#/$defs/FormRow" + }, "FormControl": { "$ref": "#/$defs/FormControl" }, - "Label": { - "$ref": "#/$defs/Label" - }, "Input": { "$ref": "#/$defs/Input" }, @@ -685,6 +789,18 @@ "Stack": { "$ref": "#/$defs/Stack" }, + "Section": { + "$ref": "#/$defs/Section" + }, + "Grid": { + "$ref": "#/$defs/Grid" + }, + "Shell": { + "$ref": "#/$defs/Shell" + }, + "Bleed": { + "$ref": "#/$defs/Bleed" + }, "Tabs": { "$ref": "#/$defs/Tabs" }, @@ -721,50 +837,41 @@ "EntityList": { "$ref": "#/$defs/EntityList" }, + "EntityListItem": { + "$ref": "#/$defs/EntityListItem" + }, "ListBlock": { "$ref": "#/$defs/ListBlock" }, "ListItem": { "$ref": "#/$defs/ListItem" }, - "Text": { - "$ref": "#/$defs/Text" - }, - "BoldText": { - "$ref": "#/$defs/BoldText" - }, - "IconText": { - "$ref": "#/$defs/IconText" - }, - "ImageText": { - "$ref": "#/$defs/ImageText" + "Timeline": { + "$ref": "#/$defs/Timeline" }, - "ImageTextLarge": { - "$ref": "#/$defs/ImageTextLarge" + "TimelineItem": { + "$ref": "#/$defs/TimelineItem" }, - "MetricIndicatorInline": { - "$ref": "#/$defs/MetricIndicatorInline" + "Checklist": { + "$ref": "#/$defs/Checklist" }, - "MetricIndicatorWithStrikethrough": { - "$ref": "#/$defs/MetricIndicatorWithStrikethrough" + "ChecklistItem": { + "$ref": "#/$defs/ChecklistItem" }, - "SnippetCardItem": { - "$ref": "#/$defs/SnippetCardItem" + "ValueCardItem": { + "$ref": "#/$defs/ValueCardItem" }, - "SnippetCardBlock": { - "$ref": "#/$defs/SnippetCardBlock" + "ValueCard": { + "$ref": "#/$defs/ValueCard" }, - "OverviewCardItem": { - "$ref": "#/$defs/OverviewCardItem" + "ValueLayout": { + "$ref": "#/$defs/ValueLayout" }, - "OverviewCardBlock": { - "$ref": "#/$defs/OverviewCardBlock" + "InsightCardItem": { + "$ref": "#/$defs/InsightCardItem" }, - "ContextCardItem": { - "$ref": "#/$defs/ContextCardItem" - }, - "ContextCardBlock": { - "$ref": "#/$defs/ContextCardBlock" + "InsightCard": { + "$ref": "#/$defs/InsightCard" }, "CompositeCardItem": { "$ref": "#/$defs/CompositeCardItem" @@ -772,31 +879,47 @@ "CompositeCardBlock": { "$ref": "#/$defs/CompositeCardBlock" }, - "VisualCardItem": { - "$ref": "#/$defs/VisualCardItem" + "ImageCardItem": { + "$ref": "#/$defs/ImageCardItem" }, - "VisualCardBlock": { - "$ref": "#/$defs/VisualCardBlock" + "ImageCard": { + "$ref": "#/$defs/ImageCard" }, "Modal": { "$ref": "#/$defs/Modal" + }, + "FollowUpBlock": { + "$ref": "#/$defs/FollowUpBlock" + }, + "FollowUpItem": { + "$ref": "#/$defs/FollowUpItem" + }, + "Sources": { + "$ref": "#/$defs/Sources" } }, "required": [ "Card", - "CardHeader", - "TextContent", - "MarkDownRenderer", + "Text", + "Markdown", + "Metric", + "Avatar", + "Emoji", + "Shape", + "AvatarStack", + "Heading", + "LeadingText", + "EntityTitle", + "ImageTitle", + "Link", "Callout", - "TextCallout", "Image", - "ImageBlock", "ImageGallery", "CodeBlock", - "InlineHeader", "Table", "Col", "EditableTable", + "EditableCol", "BarChart", "LineChart", "AreaChart", @@ -811,8 +934,8 @@ "ScatterSeries", "Point", "Form", + "FormRow", "FormControl", - "Label", "Input", "TextArea", "Select", @@ -833,6 +956,10 @@ "Buttons", "IconButton", "Stack", + "Section", + "Grid", + "Shell", + "Bleed", "Tabs", "TabItem", "Accordion", @@ -845,26 +972,26 @@ "Tag", "Icon", "EntityList", + "EntityListItem", "ListBlock", "ListItem", - "Text", - "BoldText", - "IconText", - "ImageText", - "ImageTextLarge", - "MetricIndicatorInline", - "MetricIndicatorWithStrikethrough", - "SnippetCardItem", - "SnippetCardBlock", - "OverviewCardItem", - "OverviewCardBlock", - "ContextCardItem", - "ContextCardBlock", + "Timeline", + "TimelineItem", + "Checklist", + "ChecklistItem", + "ValueCardItem", + "ValueCard", + "ValueLayout", + "InsightCardItem", + "InsightCard", "CompositeCardItem", "CompositeCardBlock", - "VisualCardItem", - "VisualCardBlock", - "Modal" + "ImageCardItem", + "ImageCard", + "Modal", + "FollowUpBlock", + "FollowUpItem", + "Sources" ], "additionalProperties": false, "$defs": { @@ -876,28 +1003,43 @@ "items": { "anyOf": [ { - "$ref": "#/$defs/TextContent" + "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/MarkDownRenderer" + "$ref": "#/$defs/Markdown" }, { - "$ref": "#/$defs/CardHeader" + "$ref": "#/$defs/Link" }, { - "$ref": "#/$defs/Callout" + "$ref": "#/$defs/Metric" }, { - "$ref": "#/$defs/TextCallout" + "$ref": "#/$defs/Avatar" }, { - "$ref": "#/$defs/CodeBlock" + "$ref": "#/$defs/AvatarStack" }, { - "$ref": "#/$defs/Image" + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" }, { - "$ref": "#/$defs/ImageBlock" + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" + }, + { + "$ref": "#/$defs/Image" }, { "$ref": "#/$defs/ImageGallery" @@ -951,7 +1093,10 @@ "$ref": "#/$defs/Steps" }, { - "$ref": "#/$defs/InlineHeader" + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" }, { "$ref": "#/$defs/EntityList" @@ -960,19 +1105,19 @@ "$ref": "#/$defs/EditableTable" }, { - "$ref": "#/$defs/SnippetCardBlock" + "$ref": "#/$defs/ValueCard" }, { - "$ref": "#/$defs/OverviewCardBlock" + "$ref": "#/$defs/ValueLayout" }, { - "$ref": "#/$defs/ContextCardBlock" + "$ref": "#/$defs/InsightCard" }, { "$ref": "#/$defs/CompositeCardBlock" }, { - "$ref": "#/$defs/VisualCardBlock" + "$ref": "#/$defs/ImageCard" }, { "$ref": "#/$defs/Tabs" @@ -982,6 +1127,18 @@ }, { "$ref": "#/$defs/Stack" + }, + { + "$ref": "#/$defs/Section" + }, + { + "$ref": "#/$defs/Grid" + }, + { + "$ref": "#/$defs/Shell" + }, + { + "$ref": "#/$defs/Bleed" } ] } @@ -989,494 +1146,698 @@ "variant": { "type": "string", "enum": [ - "card", - "sunk", - "clear" - ] - }, - "direction": { - "type": "string", - "enum": [ - "row", - "column" - ] - }, - "gap": { - "type": "string", - "enum": [ - "none", - "xs", - "s", - "m", - "l", - "xl", - "2xl" - ] - }, - "align": { - "type": "string", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" + "outlined", + "lifted", + "sunk" ] }, - "justify": { - "type": "string", - "enum": [ - "start", - "center", - "end", - "between", - "around", - "evenly" - ] + "title": { + "type": "string" }, - "wrap": { - "type": "boolean" + "subtitle": { + "type": "string" } }, "required": [ "children" ], "additionalProperties": false, - "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." }, - "TextContent": { + "Text": { "type": "object", "properties": { "text": { "type": "string" }, - "size": { + "variant": { "type": "string", "enum": [ "small", "default", - "large", - "small-heavy", - "large-heavy" + "body", + "h1", + "h2", + "h3" ] + }, + "emphasis": { + "type": "boolean" } }, "required": [ "text" ], "additionalProperties": false, - "description": "Text block. Supports markdown. Optional size: \"small\" | \"default\" | \"large\" | \"small-heavy\" | \"large-heavy\"." + "description": "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." }, - "MarkDownRenderer": { + "Markdown": { "type": "object", "properties": { "textMarkdown": { "type": "string" - }, - "variant": { - "type": "string", - "enum": [ - "clear", - "card", - "sunk" - ] } }, "required": [ "textMarkdown" ], "additionalProperties": false, - "description": "Renders markdown text with optional container variant" + "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." }, - "CardHeader": { + "Link": { "type": "object", "properties": { - "title": { + "text": { "type": "string" }, - "subtitle": { + "url": { "type": "string" + }, + "size": { + "type": "string", + "enum": [ + "default", + "lg" + ] } }, + "required": [ + "text", + "url" + ], "additionalProperties": false, - "description": "Header with optional title and subtitle" + "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." }, - "Callout": { + "Metric": { "type": "object", "properties": { + "value": { + "type": "string" + }, + "delta": { + "type": "string" + }, "variant": { "type": "string", "enum": [ - "info", - "warning", - "error", - "success", - "neutral" + "horizontal", + "vertical" ] }, - "title": { - "type": "string" + "size": { + "type": "string", + "enum": [ + "sm", + "default", + "large" + ] }, - "description": { + "previous": { "type": "string" - }, - "visible": { - "type": "boolean" } }, "required": [ - "variant", - "title", - "description" + "value" ], "additionalProperties": false, - "description": "Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false." + "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." }, - "TextCallout": { + "Avatar": { "type": "object", "properties": { - "variant": { + "name": { + "type": "string" + }, + "src": { + "type": "string" + }, + "shape": { "type": "string", "enum": [ - "neutral", - "info", - "warning", - "success", - "danger" + "circle", + "rounded" ] }, - "title": { + "domain": { "type": "string" }, - "description": { - "type": "string" + "size": { + "type": "string", + "enum": [ + "inline", + "md", + "lg" + ] + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Shape" + } + ] + }, + "color": { + "type": "string", + "enum": [ + "neutral", + "info", + "success", + "warning", + "danger", + "purple", + "pink", + "inverted" + ] } }, + "required": [ + "name" + ], "additionalProperties": false, - "description": "Text callout with variant, title, and description" + "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." }, - "CodeBlock": { + "Icon": { "type": "object", "properties": { - "language": { + "name": { "type": "string" }, - "codeString": { + "category": { "type": "string" } }, "required": [ - "language", - "codeString" + "name" ], "additionalProperties": false, - "description": "Syntax-highlighted code block" + "description": "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." }, - "Image": { + "Shape": { "type": "object", "properties": { - "alt": { - "type": "string" - }, - "src": { - "type": "string" - } - }, - "required": [ - "alt" - ], - "additionalProperties": false, - "description": "Image with alt text and optional URL" - }, - "ImageBlock": { - "type": "object", - "properties": { - "src": { - "type": "string" + "shape": { + "type": "string", + "enum": [ + "circle", + "square", + "diamond", + "triangle-up", + "triangle-down", + "triangle-left", + "triangle-right", + "hexagon" + ] }, - "alt": { + "color": { + "type": "string", + "enum": [ + "neutral", + "info", + "success", + "warning", + "danger", + "purple", + "pink", + "inverted" + ] + }, + "label": { "type": "string" } }, "required": [ - "src" + "shape" ], "additionalProperties": false, - "description": "Image block with loading state" + "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." }, - "ImageGallery": { + "AvatarStack": { "type": "object", "properties": { - "images": { + "avatars": { + "minItems": 1, "type": "array", "items": { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "alt": { - "type": "string" - }, - "details": { - "type": "string" - } - }, - "required": [ - "src" - ], - "additionalProperties": false + "$ref": "#/$defs/Avatar" } + }, + "max": { + "type": "number" + }, + "size": { + "type": "string", + "enum": [ + "inline", + "md", + "lg" + ] } }, "required": [ - "images" + "avatars" ], "additionalProperties": false, - "description": "Gallery grid of images with modal preview" + "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." }, - "Separator": { + "Heading": { "type": "object", "properties": { - "orientation": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "size": { "type": "string", "enum": [ - "horizontal", - "vertical" + "default", + "lg" ] - }, - "decorative": { - "type": "boolean" } }, + "required": [ + "title" + ], "additionalProperties": false, - "description": "Visual divider between content sections" + "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." }, - "HorizontalBarChart": { + "LeadingText": { "type": "object", "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/AvatarStack" + }, + { + "$ref": "#/$defs/Emoji" + }, + { + "$ref": "#/$defs/Shape" + } + ] }, - "series": { - "type": "array", - "items": { - "$ref": "#/$defs/Series" - } + "text": { + "$ref": "#/$defs/Text" }, - "variant": { + "tone": { "type": "string", "enum": [ - "grouped", - "stacked" + "primary", + "secondary" ] - }, - "xLabel": { - "type": "string" - }, - "yLabel": { - "type": "string" } }, "required": [ - "labels", - "series" + "leading", + "text" ], "additionalProperties": false, - "description": "Horizontal bars; prefer when category labels are long or for ranked lists" + "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." }, - "Series": { + "Emoji": { "type": "object", "properties": { - "category": { + "emoji": { "type": "string" }, - "values": { - "type": "array", - "items": { - "type": "number" - } + "label": { + "type": "string" } }, "required": [ - "category", - "values" + "emoji" ], "additionalProperties": false, - "description": "One data series" + "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." }, - "RadarChart": { + "EntityTitle": { "type": "object", "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } + ] }, - "series": { - "type": "array", - "items": { - "$ref": "#/$defs/Series" - } + "title": { + "type": "string" + }, + "subtitle": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + }, + "layout": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ] } }, "required": [ - "labels", - "series" + "leading", + "title", + "subtitle" ], "additionalProperties": false, - "description": "Spider/web chart; use for comparing multiple variables across one or more entities" + "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." }, - "PieChart": { + "Tag": { "type": "object", "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "values": { - "type": "array", - "items": { - "type": "number" - } + "text": { + "type": "string" }, - "variant": { - "type": "string", - "enum": [ - "pie", - "donut" + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Emoji" + }, + { + "$ref": "#/$defs/Shape" + } ] }, - "appearance": { + "variant": { "type": "string", "enum": [ - "circular", - "semiCircular" + "neutral", + "info", + "success", + "warning", + "danger", + "purple", + "pink", + "inverted" ] } }, "required": [ - "labels", - "values" + "text" ], "additionalProperties": false, - "description": "Circular slices; use plucked arrays: PieChart(data.categories, data.values)" + "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\")." }, - "RadialChart": { + "TagBlock": { "type": "object", "properties": { - "labels": { + "tags": { "type": "array", "items": { "type": "string" } - }, - "values": { - "type": "array", - "items": { - "type": "number" - } } }, "required": [ - "labels", - "values" + "tags" ], "additionalProperties": false, - "description": "Radial bars; use plucked arrays: RadialChart(data.categories, data.values)" + "description": "tags is an array of strings, each shown as a 24px Tag pill" }, - "SingleStackedBarChart": { + "ImageTitle": { "type": "object", "properties": { - "labels": { + "image": { + "$ref": "#/$defs/Image" + }, + "content": { "type": "array", "items": { - "type": "string" - } - }, - "values": { - "type": "array", - "items": { - "type": "number" + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] } } }, "required": [ - "labels", - "values" + "image", + "content" ], "additionalProperties": false, - "description": "Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)" + "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." }, - "ScatterChart": { + "Image": { "type": "object", "properties": { - "datasets": { - "type": "array", - "items": { - "$ref": "#/$defs/ScatterSeries" - } + "alt": { + "type": "string" }, - "xLabel": { + "src": { "type": "string" }, - "yLabel": { + "aspectRatio": { + "type": "string", + "enum": [ + "auto", + "1:1", + "4:3", + "3:2", + "16:9", + "3:4", + "9:16" + ] + } + }, + "required": [ + "alt" + ], + "additionalProperties": false, + "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)." + }, + "Callout": { + "type": "object", + "properties": { + "variant": { + "type": "string", + "enum": [ + "neutral", + "info", + "success", + "warning", + "danger", + "purple", + "pink", + "inverted", + "error" + ] + }, + "content": { "type": "string" + }, + "visible": { + "type": "boolean" + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } + ] } }, "required": [ - "datasets" + "variant", + "content" ], "additionalProperties": false, - "description": "X/Y scatter plot; use for correlations, distributions, and clustering" + "description": "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." }, - "ScatterSeries": { + "CodeBlock": { "type": "object", "properties": { - "name": { + "language": { "type": "string" }, - "points": { + "codeString": { + "type": "string" + } + }, + "required": [ + "language", + "codeString" + ], + "additionalProperties": false, + "description": "Syntax-highlighted code block" + }, + "ImageGallery": { + "type": "object", + "properties": { + "images": { "type": "array", "items": { - "$ref": "#/$defs/Point" + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "alt": { + "type": "string" + }, + "details": { + "type": "string" + } + }, + "required": [ + "src" + ], + "additionalProperties": false } } }, "required": [ - "name", - "points" + "images" ], "additionalProperties": false, - "description": "Named dataset" + "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." }, - "Point": { + "Separator": { "type": "object", "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" + "orientation": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ] }, - "z": { - "type": "number" + "decorative": { + "type": "boolean" } }, - "required": [ - "x", - "y" - ], "additionalProperties": false, - "description": "Data point with numeric coordinates" + "description": "Visual divider between content sections" }, - "AreaChart": { + "HorizontalBarChart": { "type": "object", "properties": { "labels": { @@ -1494,9 +1855,8 @@ "variant": { "type": "string", "enum": [ - "linear", - "natural", - "step" + "grouped", + "stacked" ] }, "xLabel": { @@ -1504,9 +1864,6 @@ }, "yLabel": { "type": "string" - }, - "height": { - "type": "number" } }, "required": [ @@ -1514,9 +1871,29 @@ "series" ], "additionalProperties": false, - "description": "Filled area under lines; use for cumulative totals or volume trends over time" + "description": "Horizontal bars; prefer when category labels are long or for ranked lists" }, - "BarChart": { + "Series": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "values": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "category", + "values" + ], + "additionalProperties": false, + "description": "One data series" + }, + "RadarChart": { "type": "object", "properties": { "labels": { @@ -1530,22 +1907,6 @@ "items": { "$ref": "#/$defs/Series" } - }, - "variant": { - "type": "string", - "enum": [ - "grouped", - "stacked" - ] - }, - "xLabel": { - "type": "string" - }, - "yLabel": { - "type": "string" - }, - "height": { - "type": "number" } }, "required": [ @@ -1553,9 +1914,9 @@ "series" ], "additionalProperties": false, - "description": "Vertical bars; use for comparing values across categories with one or more series" + "description": "Spider/web chart; use for comparing multiple variables across one or more entities" }, - "LineChart": { + "PieChart": { "type": "object", "properties": { "labels": { @@ -1564,99 +1925,299 @@ "type": "string" } }, - "series": { + "values": { "type": "array", "items": { - "$ref": "#/$defs/Series" + "type": "number" } }, "variant": { "type": "string", "enum": [ - "linear", - "natural", - "step" + "pie", + "donut" ] }, - "xLabel": { - "type": "string" - }, - "yLabel": { - "type": "string" - }, - "height": { - "type": "number" + "appearance": { + "type": "string", + "enum": [ + "circular", + "semiCircular" + ] } }, "required": [ "labels", - "series" + "values" ], "additionalProperties": false, - "description": "Lines over categories; use for trends and continuous data over time" + "description": "Circular slices; use plucked arrays: PieChart(data.categories, data.values)" }, - "Table": { + "RadialChart": { "type": "object", "properties": { - "columns": { + "labels": { "type": "array", "items": { - "$ref": "#/$defs/Col" + "type": "string" + } + }, + "values": { + "type": "array", + "items": { + "type": "number" } } }, "required": [ - "columns" + "labels", + "values" ], "additionalProperties": false, - "description": "Data table — column-oriented. Each Col holds its own data array." + "description": "Radial bars; use plucked arrays: RadialChart(data.categories, data.values)" }, - "Col": { + "SingleStackedBarChart": { "type": "object", "properties": { - "label": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "values": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "labels", + "values" + ], + "additionalProperties": false, + "description": "Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values)" + }, + "ScatterChart": { + "type": "object", + "properties": { + "datasets": { + "type": "array", + "items": { + "$ref": "#/$defs/ScatterSeries" + } + }, + "xLabel": { "type": "string" }, - "data": {}, - "type": { + "yLabel": { + "type": "string" + } + }, + "required": [ + "datasets" + ], + "additionalProperties": false, + "description": "X/Y scatter plot; use for correlations, distributions, and clustering" + }, + "ScatterSeries": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "points": { + "type": "array", + "items": { + "$ref": "#/$defs/Point" + } + } + }, + "required": [ + "name", + "points" + ], + "additionalProperties": false, + "description": "Named dataset" + }, + "Point": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ], + "additionalProperties": false, + "description": "Data point with numeric coordinates" + }, + "AreaChart": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "series": { + "type": "array", + "items": { + "$ref": "#/$defs/Series" + } + }, + "variant": { "type": "string", "enum": [ - "string", - "number", - "action" + "linear", + "natural", + "step" ] + }, + "xLabel": { + "type": "string" + }, + "yLabel": { + "type": "string" + }, + "height": { + "type": "number" } }, "required": [ - "label", - "data" + "labels", + "series" ], "additionalProperties": false, - "description": "Column definition — holds label + data array" + "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." }, - "TagBlock": { + "BarChart": { "type": "object", "properties": { - "tags": { + "labels": { "type": "array", "items": { "type": "string" } }, - "size": { + "series": { + "type": "array", + "items": { + "$ref": "#/$defs/Series" + } + }, + "variant": { "type": "string", "enum": [ - "sm", - "md", - "lg" + "grouped", + "stacked" ] + }, + "xLabel": { + "type": "string" + }, + "yLabel": { + "type": "string" + }, + "height": { + "type": "number" } }, "required": [ - "tags" + "labels", + "series" + ], + "additionalProperties": false, + "description": "Vertical bars; use for comparing values across categories with one or more series" + }, + "LineChart": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "series": { + "type": "array", + "items": { + "$ref": "#/$defs/Series" + } + }, + "variant": { + "type": "string", + "enum": [ + "linear", + "natural", + "step" + ] + }, + "xLabel": { + "type": "string" + }, + "yLabel": { + "type": "string" + }, + "height": { + "type": "number" + } + }, + "required": [ + "labels", + "series" + ], + "additionalProperties": false, + "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." + }, + "Table": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/Col" + } + } + }, + "required": [ + "columns" + ], + "additionalProperties": false, + "description": "Data table, column-oriented: Table([Col(\"Language\", langs), Col(\"Users (M)\", users, \"number\")]); each Col holds its own data array. It draws its own frame, so never wrap it in a Card (that doubles the border): place it directly in a Section, TabItem or AccordionItem, with a Heading or Text title above it if needed." + }, + "Col": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "data": {}, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "action" + ] + } + }, + "required": [ + "label", + "data" ], "additionalProperties": false, - "description": "tags is an array of strings; optional size sm | md | lg" + "description": "Column definition — holds label + data array" }, "Form": { "type": "object", @@ -1671,7 +2232,14 @@ "default": [], "type": "array", "items": { - "$ref": "#/$defs/FormControl" + "anyOf": [ + { + "$ref": "#/$defs/FormControl" + }, + { + "$ref": "#/$defs/FormRow" + } + ] } } }, @@ -1681,7 +2249,7 @@ "fields" ], "additionalProperties": false, - "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." }, "Buttons": { "type": "object", @@ -1713,60 +2281,7 @@ "type": "string" }, "action": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - ] + "$ref": "#/$defs/ActionExpression" }, "variant": { "type": "string", @@ -1786,18 +2301,75 @@ "size": { "type": "string", "enum": [ - "extra-small", - "small", - "medium", + "default", "large" ] + }, + "leading": { + "$ref": "#/$defs/Icon" } }, "required": [ "label" ], "additionalProperties": false, - "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." + }, + "ActionExpression": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "open_url" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "continue_conversation" + }, + "context": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] }, "FormControl": { "type": "object", @@ -1863,7 +2435,9 @@ "email", "password", "number", - "url" + "url", + "search", + "tel" ] }, "rules": { @@ -1901,12 +2475,20 @@ }, "value": { "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "inline" + ] } }, "required": [ "name" ], - "additionalProperties": false + "additionalProperties": false, + "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." }, "TextArea": { "type": "object", @@ -1955,12 +2537,20 @@ }, "value": { "type": "string" + }, + "variant": { + "type": "string", + "enum": [ + "default", + "inline" + ] } }, "required": [ "name" ], - "additionalProperties": false + "additionalProperties": false, + "description": "A multi-line text field inside a FormControl, for free-form notes or messages: TextArea(name, placeholder?, rows?, rules?, value?, variant?), e.g. TextArea(\"notes\", \"Dietary needs, accessibility, special requests…\", 4). rows sets the visible height; 3–5 is typical. Never require it when leaving it blank is a valid answer. variant \"inline\" is borderless until hover or focus, for table cells and dense rows; use \"default\" everywhere else. For a one-line answer use Input." }, "Select": { "type": "object", @@ -2013,12 +2603,11 @@ "value": { "type": "string" }, - "size": { + "variant": { "type": "string", "enum": [ - "small", - "medium", - "large" + "default", + "inline" ] } }, @@ -2026,7 +2615,8 @@ "name", "items" ], - "additionalProperties": false + "additionalProperties": false, + "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." }, "SelectItem": { "type": "object", @@ -2043,7 +2633,7 @@ "label" ], "additionalProperties": false, - "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." }, "DatePicker": { "type": "object", @@ -2091,12 +2681,20 @@ }, "additionalProperties": false }, - "value": {} + "value": {}, + "variant": { + "type": "string", + "enum": [ + "default", + "inline" + ] + } }, "required": [ "name" ], - "additionalProperties": false + "additionalProperties": false, + "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." }, "Slider": { "type": "object", @@ -2414,7 +3012,7 @@ "items" ], "additionalProperties": false, - "description": "A form field of compact selectable chips for choosing one or many short options; the selection is stored under `name`." + "description": "A form field of compact selectable pills, inside a FormControl, for choosing one or many short options: Chips(name, \"single\" | \"multiple\", [ChipItem(...), ...], rules?, defaultValue?). A selected chip shows a checkmark at the end. Use it for short labels (1–3 words) such as tags, amenities or tools; for choices that need a description use OptionCards. The selection is stored under `name`." }, "ChipItem": { "type": "object", @@ -2425,8 +3023,18 @@ "label": { "type": "string" }, - "icon": { - "$ref": "#/$defs/Icon" + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Emoji" + } + ] }, "disabled": { "type": "boolean" @@ -2437,23 +3045,7 @@ "label" ], "additionalProperties": false, - "description": "A single selectable chip inside a Chips group, with a value, label and optional icon." - }, - "Icon": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "description": "A lucide icon by kebab-case name (e.g. 'circle-check'). Optional category picks a topical fallback when the name doesn't resolve." + "description": "One selectable pill: ChipItem(value, label, leading?, disabled?), e.g. ChipItem(\"hotel\", \"Hotel\", Icon(\"building\", \"buildings\")). label is 1–3 words; leading is an optional Icon, an Avatar for a person or company, or a rare Emoji. Give every chip a leading or none of them." }, "OptionCards": { "type": "object", @@ -2529,7 +3121,7 @@ "items" ], "additionalProperties": false, - "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`." + "description": "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`." }, "OptionCard": { "type": "object", @@ -2541,13 +3133,69 @@ "type": "string" }, "subtitle": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] }, - "topContent": { + "leading": { "anyOf": [ { "$ref": "#/$defs/Icon" }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Emoji" + }, { "$ref": "#/$defs/Image" } @@ -2562,7 +3210,23 @@ "title" ], "additionalProperties": false, - "description": "A single selectable card inside an OptionCards group, with a value, title, optional subtitle and an optional Icon or Image on top." + "description": "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." + }, + "FormRow": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/FormControl" + } + } + }, + "required": [ + "fields" + ], + "additionalProperties": false, + "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." }, "IconButton": { "type": "object", @@ -2574,60 +3238,7 @@ "$ref": "#/$defs/Icon" }, "action": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false - } - ] + "$ref": "#/$defs/ActionExpression" }, "variant": { "type": "string", @@ -2640,18 +3251,9 @@ "size": { "type": "string", "enum": [ - "extra-small", - "small", - "medium", + "default", "large" ] - }, - "shape": { - "type": "string", - "enum": [ - "square", - "circle" - ] } }, "required": [ @@ -2659,7 +3261,7 @@ "icon" ], "additionalProperties": false, - "description": "Icon-only button. name is the accessible label and the action label; icon is an Icon; action fires on click." + "description": "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." }, "Steps": { "type": "object", @@ -2675,727 +3277,810 @@ "items" ], "additionalProperties": false, - "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." }, "StepsItem": { "type": "object", "properties": { "title": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + } + ] + } + } + ] }, - "details": { - "type": "string" + "subtitle": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] } }, "required": [ - "title", - "details" + "title" ], "additionalProperties": false, - "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." }, - "InlineHeader": { + "Timeline": { "type": "object", "properties": { - "heading": { - "type": "string" - }, - "description": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/TimelineItem" + } } }, "required": [ - "heading" + "items" ], "additionalProperties": false, - "description": "Compact section heading with an optional one-line description, for use inside cards." + "description": "Events in time order along a dashed rail: an itinerary, a schedule, order status, a project history or an activity log. Timeline([TimelineItem(\"Check in\", \"Hotel Lutetia\", \"3:00 PM\"), TimelineItem(Text(\"Dinner\", \"body\", true), \"Septime · table for 2\", \"8:30 PM\")]). Give every item a time when the moments are known; use status to show progress through a plan. Use Steps for how-to instructions and ListBlock for an unordered set." }, - "EntityList": { + "TimelineItem": { "type": "object", "properties": { - "rows": { - "default": [], - "type": "array", - "items": { - "type": "object", - "properties": { - "left": { - "type": "string" - }, - "right": { - "type": "string" - }, - "rightVariant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number" + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + } ] } - }, - "required": [ - "left", - "right", - "rightVariant" - ], - "additionalProperties": false - } - }, - "size": { - "default": "default", - "type": "string", - "enum": [ - "small", - "default" + } ] }, - "header": { - "type": "object", - "properties": { - "left": { + "subtitle": { + "anyOf": [ + { "type": "string" }, - "right": { - "type": "string" + { + "$ref": "#/$defs/Text" }, - "rightVariant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number" - ] + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } } - }, - "required": [ - "left", - "right", - "rightVariant" - ], - "additionalProperties": false + ] }, - "footer": { - "type": "object", - "properties": { - "left": { + "time": { + "anyOf": [ + { "type": "string" }, - "right": { - "type": "string" + { + "$ref": "#/$defs/Text" }, - "rightVariant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number" - ] + { + "$ref": "#/$defs/Tag" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Tag" + } + ] + } } - }, - "required": [ - "left", - "right", - "rightVariant" - ], - "additionalProperties": false + ] + }, + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "done", + "current", + "upcoming" + ] } }, "required": [ - "rows", - "size" + "title" ], "additionalProperties": false, - "description": "Two-column key/value rows (left label, right value). size 'default' supports optional header and footer rows; rightVariant 'number' uses tabular numbers." + "description": "One event in a Timeline: TimelineItem(title, subtitle?, time?, leading?, status?). title is a string, or a Text, Link or Tag; emphasize a named event with Text(\"Doors open\", \"body\", true). subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. time is when it happens, shown in a column on the left: a short string such as \"9:00 AM\", \"Day 2\" or \"Mar 2024\", or a Text or Tag. leading is an optional Avatar, Icon or Emoji that replaces the dot, e.g. Avatar(\"Priya Shah\", null, null, null, \"inline\", Icon(\"user\", \"people\")) for who acted. status \"done\" fills the dot, \"current\" highlights it, \"upcoming\" quiets the title; leave it out for a plain history. When title or subtitle needs two or more pieces side by side, pass an array." }, - "EditableTable": { + "Checklist": { "type": "object", "properties": { - "name": { - "default": "", - "type": "string" - }, - "columns": { - "default": [], + "items": { "type": "array", "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "text", - "number", - "date-single", - "select", - "url" - ] - }, - "key": { - "default": "default", - "type": "string" - }, - "header": { - "default": "", - "type": "string" - }, - "width": { - "type": "number" - }, - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "value", - "label" - ], - "additionalProperties": false - } - } - }, - "required": [ - "type", - "key", - "header" - ], - "additionalProperties": false + "$ref": "#/$defs/ChecklistItem" } }, - "data": { - "default": [], - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "values": { - "type": "array", - "items": { - "type": [ - "string", - "number" - ] - } - } - }, - "required": [ - "id", - "values" - ], - "additionalProperties": false + "name": { + "type": "string" + }, + "value": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" } } }, "required": [ - "name", - "columns", - "data" + "items" ], "additionalProperties": false, - "description": "Spreadsheet-like table whose cells the user can edit inline (text, number, url, date, select columns); edits are saved back as a form field" + "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." }, - "SnippetCardBlock": { + "ChecklistItem": { "type": "object", "properties": { - "items": { - "minItems": 2, - "type": "array", - "items": { - "$ref": "#/$defs/SnippetCardItem" - } - }, - "layout": { - "default": "grid", - "type": "string", - "enum": [ - "grid" - ] - }, - "responsive": { - "default": true, - "type": "boolean" - }, - "action": { + "title": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false + "type": "string" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "$ref": "#/$defs/Text" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + } + ] + } } ] }, - "gap": { - "type": [ - "number", - "string" - ] - } - }, - "required": [ - "items", - "layout", - "responsive" - ], - "additionalProperties": false, - "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." - }, - "SnippetCardItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "lhs": { + "subtitle": { "anyOf": [ { - "$ref": "#/$defs/IconText" + "type": "string" }, - { - "$ref": "#/$defs/ImageText" - } - ] - }, - "rhs": { - "anyOf": [ { "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/BoldText" - } + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } ] + }, + "checked": { + "type": "boolean" + }, + "value": { + "type": "string" } }, "required": [ - "lhs" + "title" ], "additionalProperties": false, - "description": "One row-style snippet card: a label on the left (IconText or ImageText) and an optional value on the right (Text or BoldText)." + "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." }, - "IconText": { + "EntityList": { "type": "object", "properties": { - "icon": { - "$ref": "#/$defs/Icon" - }, - "iconVariant": { - "default": "neutral", - "type": "string", - "enum": [ - "neutral", - "info", - "success", - "warning", - "danger", - "inverted", - "filled", - "soft" - ] + "rows": { + "default": [], + "type": "array", + "items": { + "$ref": "#/$defs/EntityListItem" + } }, - "iconSize": { - "default": "m", + "size": { + "default": "default", "type": "string", "enum": [ - "xs", - "s", - "m", - "l", - "xl", - "sm", - "md", - "lg" + "small", + "default" ] }, - "title": { - "type": "string" - }, - "subtitle": { - "type": "string" - }, - "bold": { - "default": false, - "type": "boolean" + "header": { + "$ref": "#/$defs/EntityListItem" }, - "layout": { - "default": "horizontal", - "type": "string", - "enum": [ - "horizontal", - "vertical" - ] + "footer": { + "$ref": "#/$defs/EntityListItem" } }, "required": [ - "icon", - "iconVariant", - "iconSize", - "title", - "bold", - "layout" + "rows", + "size" ], "additionalProperties": false, - "description": "An icon badge with a title and optional subtitle, laid out horizontally or vertically. iconVariant sets the badge color." + "description": "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." }, - "ImageText": { + "EntityListItem": { "type": "object", "properties": { - "src": { - "type": "string" - }, - "alt": { - "type": "string" - }, - "title": { - "type": "string" - }, - "subtitle": { - "type": "string" + "left": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/LeadingText" + } + ] + } + } + ] }, - "bold": { - "default": false, - "type": "boolean" + "right": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/Text" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/Text" + } + ] + } + } + ] }, - "layout": { - "default": "horizontal", + "rightVariant": { "type": "string", "enum": [ - "horizontal", - "vertical" + "text", + "number" ] - }, - "imageSize": { - "type": "number" } }, "required": [ - "src", - "title", - "bold", - "layout" + "left" ], "additionalProperties": false, - "description": "A small square image (thumbnail/avatar) with a title and optional subtitle. src must be a real image URL." + "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\"))." }, - "Text": { + "EditableTable": { "type": "object", "properties": { - "variant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number" - ] - }, - "value": { - "type": "string" - }, - "subtext": { + "name": { + "default": "", "type": "string" }, - "subtextVariant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number", - "metric" - ] + "columns": { + "default": [], + "type": "array", + "items": { + "$ref": "#/$defs/EditableCol" + } }, - "size": { - "default": "sm", - "type": "string", - "enum": [ - "xs", - "sm", - "md", - "lg" - ] + "rows": { + "default": [], + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ] + } + } } }, "required": [ - "variant", - "value", - "subtextVariant", - "size" + "name", + "columns", + "rows" ], "additionalProperties": false, - "description": "Plain text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red." + "description": "Spreadsheet-like table the user edits inline: EditableTable(name, columns, rows), e.g. EditableTable(\"tasks\", [EditableCol(\"task\", \"Task\"), EditableCol(\"status\", \"Status\", \"select\", [SelectItem(\"todo\", \"Todo\"), SelectItem(\"done\", \"Done\")]), EditableCol(\"due\", \"Due\", \"date\")], [[\"Design review\", \"todo\", \"2026-10-12\"]]). Each row is an array of values in column order (dates as ISO strings, selects as the SelectItem value). Edits are submitted as a form field named after the table when the user clicks Save Changes. It draws its own frame, so never wrap it in a Card; place it directly in a Section or TabItem." }, - "BoldText": { + "EditableCol": { "type": "object", "properties": { - "variant": { - "default": "text", - "type": "string", - "enum": [ - "text", - "number" - ] - }, - "value": { + "key": { "type": "string" }, - "subtext": { + "label": { "type": "string" }, - "subtextVariant": { + "type": { "default": "text", "type": "string", "enum": [ "text", "number", - "metric" + "date", + "select", + "url" ] }, - "size": { - "default": "sm", - "type": "string", - "enum": [ - "xs", - "sm", - "md", - "lg" - ] + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/SelectItem" + } + }, + "icon": { + "$ref": "#/$defs/Icon" + }, + "width": { + "type": "number" } }, "required": [ - "variant", - "value", - "subtextVariant", - "size" + "key", + "label", + "type" ], "additionalProperties": false, - "description": "Emphasized (bold) text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red." + "description": "One EditableTable column: EditableCol(key, label, type?, options?, icon?, width?). type is the cell editor: \"text\" (default), \"number\", \"date\", \"select\" (pass options as SelectItems) or \"url\". icon is an optional leading header Icon; each type has its own default." }, - "OverviewCardBlock": { + "ValueCard": { "type": "object", "properties": { "items": { "minItems": 2, "type": "array", "items": { - "$ref": "#/$defs/OverviewCardItem" + "$ref": "#/$defs/ValueCardItem" } }, "layout": { - "default": "grid", "type": "string", "enum": [ - "grid", - "carousel" + "horizontal", + "vertical" ] }, "responsive": { - "default": true, "type": "boolean" }, "action": { + "$ref": "#/$defs/ActionExpression" + }, + "gap": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "number" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "string" } ] }, - "gap": { - "type": [ - "number", - "string" - ] + "carousel": { + "type": "boolean" } }, "required": [ - "items", - "layout", - "responsive" + "items" ], "additionalProperties": false, - "description": "A grid or horizontal carousel of compact overview cards, each with a heading (icon/image/text) on top and an inline metric below; optionally clickable with a shared action." + "description": "A responsive grid of 2 or more compact cards, each one short fact or KPI: ValueCard([item1, item2, item3], layout?, responsive?, action?, gap?, carousel?). Each card is a ValueCardItem(id?, title, subtitle?, leading?, value?), e.g. ValueCardItem(\"revenue\", \"Revenue\", \"Month to date\", Icon(\"circle-dollar-sign\", \"finance\"), Metric(\"$48,200\", \"+8.1%\", null, \"sm\")). Set layout once on the ValueCard for the whole group, never per item. Use \"horizontal\" only when every value is a single short piece (one Metric or a short Text); if any value has more, such as a Metric plus a Tag or a Metric plus a Text note, use \"vertical\" for the whole block. \"horizontal\" (default) is for key/value facts: the value goes on the right, with a Metric at \"sm\"; with a subtitle the title stacks over it beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge), and without one the leading sits inline before the title like a LeadingText. \"vertical\" is for KPI tiles where the number is the hero: the leading and title sit on one line, the subtitle on its own line under the title, and the value goes below at the default size, e.g. ValueCard([v1, v2, v3], \"vertical\", true). leading is an Icon for a concept, or an Avatar for a person or company with a real photo or logo. carousel (vertical only) scrolls the cards sideways instead of wrapping, for secondary browsing. Every item uses the same parts. action is one shared action for all cards, with the clicked card's details attached, so keep its @ToAssistant message generic and never use @OpenUrl. Use ValueCard for dashboard-like output, such as an analytics or monitoring view; for an inline section of figures inside a normal response use ValueLayout instead. For a long list of key/value rows use EntityList; for one headline figure use a Metric on its own." }, - "OverviewCardItem": { + "ValueCardItem": { "type": "object", "properties": { "id": { "type": "string" }, - "top": { + "title": { + "type": "string" + }, + "subtitle": { "anyOf": [ { - "$ref": "#/$defs/IconText" + "type": "string" }, { - "$ref": "#/$defs/ImageText" + "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/Text" + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + }, + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" } ] }, - "bottom": { - "$ref": "#/$defs/MetricIndicatorInline" + "value": { + "anyOf": [ + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + } + ] + } + } + ] } }, "required": [ - "top" + "title" ], "additionalProperties": false, - "description": "One overview card: a heading slot at the top (IconText, ImageText or Text) and an optional MetricIndicatorInline at the bottom." + "description": "One card in a ValueCard: ValueCardItem(id?, title, subtitle?, leading?, value?). title says what the value is; subtitle adds a short context line (a string, or a Text, Metric, Tag, LeadingText or Link, or an array of them on one line); leading is an optional Avatar (a photo, logo or tinted icon badge), Icon or Emoji. In a horizontal card with a subtitle, the title stacks over the subtitle beside a 36px leading (an Avatar fills it, a bare Icon becomes a neutral badge) and the value sits on the right; without a subtitle the leading sits inline before the title like a LeadingText. In a vertical card the leading and title share one line, the subtitle sits under the title, and the hero value follows below. value is a Metric, Text, Tag, TagBlock or LeadingText, or an array of them on one line." }, - "MetricIndicatorInline": { + "ValueLayout": { "type": "object", "properties": { - "value": { - "type": "string" + "items": { + "minItems": 2, + "type": "array", + "items": { + "$ref": "#/$defs/ValueCardItem" + } }, - "subtext": { - "type": "string" + "layout": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ] }, - "trend": { - "type": "object", - "properties": { - "direction": { - "type": "string", - "enum": [ - "up", - "down" - ] - }, - "value": { + "responsive": { + "type": "boolean" + }, + "gap": { + "anyOf": [ + { "type": "number" + }, + { + "type": "string" } - }, - "required": [ - "direction", - "value" - ], - "additionalProperties": false + ] + }, + "carousel": { + "type": "boolean" } }, "required": [ - "value" + "items" ], "additionalProperties": false, - "description": "Headline metric value with an optional +/- percentage trend and subtext, all on one line." + "description": "A lighter ValueCard: the same ValueCardItems, laid out in a grid or carousel with dashed rules between them instead of tinted cards. ValueLayout([item1, item2, item3], layout?, responsive?, gap?, carousel?), e.g. ValueLayout([ValueCardItem(\"revenue\", \"Revenue\", \"Month to date\", Icon(\"circle-dollar-sign\", \"finance\"), Metric(\"$48,200\", \"+8.1%\", null, \"sm\")), ValueCardItem(\"orders\", \"Orders\", \"Month to date\", Icon(\"shopping-cart\", \"shopping\"), Metric(\"1,204\", \"+3.4%\", null, \"sm\"))]). It follows every ValueCard rule: 2 or more items of the same shape, layout set once on the group, \"horizontal\" only when every value is one short piece and \"vertical\" otherwise, and carousel for vertical only. It has no action: it is informational only, so use ValueCard if the figures must be clickable. Use ValueLayout as an inline section of figures inside a normal UI response, such as key numbers in an answer about a company, city or product, where tinted cards would feel heavy. Use ValueCard for dashboard-like output, such as an analytics or monitoring view." }, - "ContextCardBlock": { + "InsightCard": { "type": "object", "properties": { "items": { "minItems": 2, "type": "array", "items": { - "$ref": "#/$defs/ContextCardItem" + "$ref": "#/$defs/InsightCardItem" } }, "layout": { - "default": "grid", "type": "string", "enum": [ "grid", @@ -3403,151 +4088,119 @@ ] }, "responsive": { - "default": true, "type": "boolean" }, "action": { + "$ref": "#/$defs/ActionExpression" + }, + "gap": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "number" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "string" } ] - }, - "gap": { - "type": [ - "number", - "string" - ] } }, "required": [ - "items", - "layout", - "responsive" + "items" ], "additionalProperties": false, - "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." + "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." }, - "ContextCardItem": { + "InsightCardItem": { "type": "object", "properties": { "id": { "type": "string" }, "title": { + "type": "string" + }, + "subtitle": { "anyOf": [ { "type": "string" }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, { "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } } ] }, - "body": { - "type": "string" - }, - "bgColor": { - "type": "string", - "enum": [ - "gray" - ] - }, - "bgImageSrc": { - "type": "string" - }, - "bgImageAlt": { - "type": "string" - } - }, - "required": [ - "title" - ], - "additionalProperties": false, - "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." - }, - "Tag": { - "type": "object", - "properties": { - "text": { + "overline": { "type": "string" }, - "icon": { - "$ref": "#/$defs/Icon" - }, - "size": { - "type": "string", - "enum": [ - "sm", - "md", - "lg" + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } ] }, - "variant": { + "color": { "type": "string", "enum": [ "neutral", "info", "success", "warning", - "danger" + "danger", + "purple", + "pink", + "inverted" ] } }, "required": [ - "text" + "title" ], "additionalProperties": false, - "description": "Styled tag/badge with optional Icon and variant" + "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." }, "CompositeCardBlock": { "type": "object", @@ -3572,66 +4225,17 @@ "type": "boolean" }, "action": { + "$ref": "#/$defs/ActionExpression" + }, + "gap": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "number" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "string" } ] - }, - "gap": { - "type": [ - "number", - "string" - ] } }, "required": [ @@ -3640,7 +4244,7 @@ "responsive" ], "additionalProperties": false, - "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." + "description": "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." }, "CompositeCardItem": { "type": "object", @@ -3651,19 +4255,19 @@ "header": { "anyOf": [ { - "$ref": "#/$defs/IconText" - }, - { - "$ref": "#/$defs/ImageText" + "$ref": "#/$defs/EntityTitle" }, { - "$ref": "#/$defs/ImageTextLarge" + "$ref": "#/$defs/ImageTitle" }, { "$ref": "#/$defs/Text" }, { "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/LeadingText" } ] }, @@ -3676,13 +4280,10 @@ "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/BoldText" - }, - { - "$ref": "#/$defs/MetricIndicatorInline" + "$ref": "#/$defs/Metric" }, { - "$ref": "#/$defs/IconText" + "$ref": "#/$defs/EntityTitle" }, { "$ref": "#/$defs/Image" @@ -3704,6 +4305,9 @@ }, { "$ref": "#/$defs/EntityList" + }, + { + "$ref": "#/$defs/Checklist" } ] } @@ -3712,55 +4316,52 @@ "type": "object", "properties": { "price": { + "$ref": "#/$defs/Metric" + }, + "button": { "anyOf": [ { - "$ref": "#/$defs/BoldText" + "$ref": "#/$defs/Button" + }, + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" }, { - "$ref": "#/$defs/MetricIndicatorWithStrikethrough" + "type": "array", + "items": { + "$ref": "#/$defs/IconButton" + } } ] - }, - "button": { - "$ref": "#/$defs/Button" } }, "additionalProperties": false - } - }, - "required": [ - "body" - ], - "additionalProperties": false, - "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." - }, - "ImageTextLarge": { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "alt": { - "type": "string" - }, - "title": { - "type": "string" - }, - "subtitle": { - "type": "string" }, - "bold": { - "default": false, - "type": "boolean" + "media": { + "anyOf": [ + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/AreaChart" + }, + { + "$ref": "#/$defs/BarChart" + }, + { + "$ref": "#/$defs/LineChart" + } + ] } }, "required": [ - "src", - "title", - "bold" + "body" ], "additionalProperties": false, - "description": "A full-width banner image above a bold title and optional subtitle. src must be a real image URL." + "description": "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." }, "ListBlock": { "type": "object", @@ -3775,14 +4376,7 @@ "type": "string", "enum": [ "number", - "image" - ] - }, - "size": { - "type": "string", - "enum": [ - "default", - "small" + "plain" ] } }, @@ -3790,150 +4384,149 @@ "items" ], "additionalProperties": false, - "description": "A list of items with number or image indicators. Each item can optionally have an action. size small renders a compact list." + "description": "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." }, "ListItem": { "type": "object", "properties": { "title": { - "type": "string" - }, - "subtitle": { - "type": "string" - }, - "image": { - "type": "object", - "properties": { - "src": { + "anyOf": [ + { "type": "string" }, - "alt": { - "type": "string" + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Tag" + } + ] + } } - }, - "required": [ - "src", - "alt" - ], - "additionalProperties": false - }, - "actionLabel": { - "type": "string" + ] }, - "action": { + "subtitle": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false + "type": "string" }, { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "$ref": "#/$defs/Text" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { "type": "string" }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } } ] - } - }, - "required": [ - "title" - ], - "additionalProperties": false, - "description": "Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable." - }, - "MetricIndicatorWithStrikethrough": { - "type": "object", - "properties": { - "value": { - "type": "string" }, - "subtext": { - "type": "string" + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } + ] }, - "previousValue": { + "actionLabel": { "type": "string" }, - "trend": { - "type": "object", - "properties": { - "direction": { - "type": "string", - "enum": [ - "up", - "down" - ] + "action": { + "$ref": "#/$defs/ActionExpression" + }, + "actionSlot": { + "anyOf": [ + { + "$ref": "#/$defs/Button" }, - "value": { - "type": "number" + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" } - }, - "required": [ - "direction", - "value" - ], - "additionalProperties": false + ] } }, "required": [ - "value" + "title" ], "additionalProperties": false, - "description": "Headline metric value with an optional struck-through previousValue, a +/- percentage trend, and subtext below." + "description": "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." }, - "VisualCardBlock": { + "ImageCard": { "type": "object", "properties": { "items": { "minItems": 2, "type": "array", "items": { - "$ref": "#/$defs/VisualCardItem" + "$ref": "#/$defs/ImageCardItem" } }, "layout": { - "default": "grid", "type": "string", "enum": [ "grid", @@ -3941,104 +4534,126 @@ ] }, "responsive": { - "default": true, "type": "boolean" }, "action": { + "$ref": "#/$defs/ActionExpression" + }, + "gap": { "anyOf": [ { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "open_url" - }, - "url": { - "type": "string" - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "continue_conversation" - }, - "context": { - "type": "string" - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "number" }, { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": [ - "type" - ], - "additionalProperties": false + "type": "string" } ] - }, - "gap": { - "type": [ - "number", - "string" - ] } }, "required": [ - "items", - "layout", - "responsive" + "items" ], "additionalProperties": false, - "description": "A grid or carousel of photo-first cards: a full-bleed background image with a tag on top and a bold text panel at the bottom; an optional action makes every card clickable." + "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." }, - "VisualCardItem": { + "ImageCardItem": { "type": "object", "properties": { - "body": { - "$ref": "#/$defs/BoldText" - }, "id": { "type": "string" }, - "bgImageSrc": { + "src": { + "type": "string" + }, + "title": { + "type": "string" + }, + "subtitle": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Tag" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + }, + "overline": { "type": "string" }, - "tag": { - "$ref": "#/$defs/Tag" + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Emoji" + } + ] }, - "bgImageAlt": { + "alt": { "type": "string" + }, + "actions": { + "anyOf": [ + { + "$ref": "#/$defs/IconButton" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/IconButton" + } + } + ] } }, "required": [ - "body" + "src", + "title" ], "additionalProperties": false, - "description": "A single photo-first card inside a VisualCardBlock: a BoldText body panel, an optional Tag, and a background image (bgImageSrc must be a real URL; bgImageAlt is its alt text)." + "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." }, "Tabs": { "type": "object", @@ -4054,7 +4669,7 @@ "items" ], "additionalProperties": false, - "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." }, "TabItem": { "type": "object", @@ -4070,28 +4685,757 @@ "items": { "anyOf": [ { - "$ref": "#/$defs/TextContent" + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/AvatarStack" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/ImageGallery" + }, + { + "$ref": "#/$defs/Separator" + }, + { + "$ref": "#/$defs/HorizontalBarChart" + }, + { + "$ref": "#/$defs/RadarChart" + }, + { + "$ref": "#/$defs/PieChart" + }, + { + "$ref": "#/$defs/RadialChart" + }, + { + "$ref": "#/$defs/SingleStackedBarChart" + }, + { + "$ref": "#/$defs/ScatterChart" + }, + { + "$ref": "#/$defs/AreaChart" + }, + { + "$ref": "#/$defs/BarChart" + }, + { + "$ref": "#/$defs/LineChart" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Form" + }, + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" + }, + { + "$ref": "#/$defs/EntityList" + }, + { + "$ref": "#/$defs/EditableTable" + }, + { + "$ref": "#/$defs/ValueCard" + }, + { + "$ref": "#/$defs/ValueLayout" + }, + { + "$ref": "#/$defs/InsightCard" + }, + { + "$ref": "#/$defs/CompositeCardBlock" + }, + { + "$ref": "#/$defs/ImageCard" + } + ] + } + }, + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Emoji" + } + ] + } + }, + "required": [ + "value", + "trigger", + "content" + ], + "additionalProperties": false, + "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." + }, + "Carousel": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/CompositeCardItem" + }, + { + "$ref": "#/$defs/ImageCardItem" + }, + { + "$ref": "#/$defs/InsightCardItem" + }, + { + "$ref": "#/$defs/ValueCardItem" + } + ] + } + } + }, + "required": [ + "children" + ], + "additionalProperties": false, + "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." + }, + "Stack": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": {} + }, + "direction": { + "type": "string", + "enum": [ + "row", + "column" + ] + }, + "gap": { + "type": "string", + "enum": [ + "none", + "xs", + "s", + "m", + "l", + "xl", + "2xl" + ] + }, + "align": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "stretch", + "baseline" + ] + }, + "justify": { + "type": "string", + "enum": [ + "start", + "center", + "end", + "between", + "around", + "evenly" + ] + }, + "wrap": { + "type": "boolean" + } + }, + "required": [ + "children" + ], + "additionalProperties": false, + "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." + }, + "Section": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/AvatarStack" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/ImageGallery" + }, + { + "$ref": "#/$defs/Separator" + }, + { + "$ref": "#/$defs/HorizontalBarChart" + }, + { + "$ref": "#/$defs/RadarChart" + }, + { + "$ref": "#/$defs/PieChart" + }, + { + "$ref": "#/$defs/RadialChart" + }, + { + "$ref": "#/$defs/SingleStackedBarChart" + }, + { + "$ref": "#/$defs/ScatterChart" + }, + { + "$ref": "#/$defs/AreaChart" + }, + { + "$ref": "#/$defs/BarChart" + }, + { + "$ref": "#/$defs/LineChart" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Form" + }, + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" + }, + { + "$ref": "#/$defs/EntityList" + }, + { + "$ref": "#/$defs/EditableTable" + }, + { + "$ref": "#/$defs/ValueCard" + }, + { + "$ref": "#/$defs/ValueLayout" + }, + { + "$ref": "#/$defs/InsightCard" + }, + { + "$ref": "#/$defs/CompositeCardBlock" + }, + { + "$ref": "#/$defs/ImageCard" + }, + { + "$ref": "#/$defs/Tabs" + }, + { + "$ref": "#/$defs/Carousel" + }, + { + "$ref": "#/$defs/Stack" + }, + { + "$ref": "#/$defs/Grid" + }, + { + "$ref": "#/$defs/Shell" + }, + { + "$ref": "#/$defs/Bleed" + }, + { + "$ref": "#/$defs/FollowUpBlock" + }, + { + "$ref": "#/$defs/Sources" + } + ] + } + } + }, + "required": [ + "children" + ], + "additionalProperties": false, + "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." + }, + "Grid": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/AvatarStack" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/ImageGallery" + }, + { + "$ref": "#/$defs/Separator" + }, + { + "$ref": "#/$defs/HorizontalBarChart" + }, + { + "$ref": "#/$defs/RadarChart" + }, + { + "$ref": "#/$defs/PieChart" + }, + { + "$ref": "#/$defs/RadialChart" + }, + { + "$ref": "#/$defs/SingleStackedBarChart" + }, + { + "$ref": "#/$defs/ScatterChart" + }, + { + "$ref": "#/$defs/AreaChart" + }, + { + "$ref": "#/$defs/BarChart" + }, + { + "$ref": "#/$defs/LineChart" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Form" + }, + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" + }, + { + "$ref": "#/$defs/EntityList" + }, + { + "$ref": "#/$defs/EditableTable" + }, + { + "$ref": "#/$defs/ValueCard" + }, + { + "$ref": "#/$defs/ValueLayout" + }, + { + "$ref": "#/$defs/InsightCard" + }, + { + "$ref": "#/$defs/CompositeCardBlock" + }, + { + "$ref": "#/$defs/ImageCard" + }, + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Tabs" + }, + { + "$ref": "#/$defs/Carousel" + }, + { + "$ref": "#/$defs/Stack" + } + ] + } + }, + "columns": { + "anyOf": [ + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + } + ] + } + }, + "required": [ + "children" + ], + "additionalProperties": false, + "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." + }, + "Shell": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/Metric" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/AvatarStack" + }, + { + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" + }, + { + "$ref": "#/$defs/Image" + }, + { + "$ref": "#/$defs/ImageGallery" + }, + { + "$ref": "#/$defs/Separator" + }, + { + "$ref": "#/$defs/HorizontalBarChart" + }, + { + "$ref": "#/$defs/RadarChart" + }, + { + "$ref": "#/$defs/PieChart" + }, + { + "$ref": "#/$defs/RadialChart" + }, + { + "$ref": "#/$defs/SingleStackedBarChart" + }, + { + "$ref": "#/$defs/ScatterChart" + }, + { + "$ref": "#/$defs/AreaChart" + }, + { + "$ref": "#/$defs/BarChart" + }, + { + "$ref": "#/$defs/LineChart" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/TagBlock" + }, + { + "$ref": "#/$defs/Form" + }, + { + "$ref": "#/$defs/Buttons" + }, + { + "$ref": "#/$defs/IconButton" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" + }, + { + "$ref": "#/$defs/EntityList" + }, + { + "$ref": "#/$defs/EditableTable" + }, + { + "$ref": "#/$defs/ValueCard" + }, + { + "$ref": "#/$defs/ValueLayout" + }, + { + "$ref": "#/$defs/InsightCard" + }, + { + "$ref": "#/$defs/CompositeCardBlock" + }, + { + "$ref": "#/$defs/ImageCard" + }, + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Grid" + }, + { + "$ref": "#/$defs/Tabs" + }, + { + "$ref": "#/$defs/Carousel" + }, + { + "$ref": "#/$defs/Stack" + } + ] + } + } + }, + "required": [ + "title", + "children" + ], + "additionalProperties": false, + "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." + }, + "Bleed": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/Markdown" + }, + { + "$ref": "#/$defs/Link" }, { - "$ref": "#/$defs/MarkDownRenderer" + "$ref": "#/$defs/Metric" }, { - "$ref": "#/$defs/CardHeader" + "$ref": "#/$defs/Avatar" }, { - "$ref": "#/$defs/Callout" + "$ref": "#/$defs/AvatarStack" }, { - "$ref": "#/$defs/TextCallout" + "$ref": "#/$defs/Heading" }, { - "$ref": "#/$defs/CodeBlock" + "$ref": "#/$defs/LeadingText" }, { - "$ref": "#/$defs/Image" + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" }, { - "$ref": "#/$defs/ImageBlock" + "$ref": "#/$defs/Image" }, { "$ref": "#/$defs/ImageGallery" @@ -4145,7 +5489,10 @@ "$ref": "#/$defs/Steps" }, { - "$ref": "#/$defs/InlineHeader" + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" }, { "$ref": "#/$defs/EntityList" @@ -4154,214 +5501,105 @@ "$ref": "#/$defs/EditableTable" }, { - "$ref": "#/$defs/SnippetCardBlock" + "$ref": "#/$defs/ValueCard" }, { - "$ref": "#/$defs/OverviewCardBlock" + "$ref": "#/$defs/ValueLayout" }, { - "$ref": "#/$defs/ContextCardBlock" + "$ref": "#/$defs/InsightCard" }, { "$ref": "#/$defs/CompositeCardBlock" }, { - "$ref": "#/$defs/VisualCardBlock" + "$ref": "#/$defs/ImageCard" + }, + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Grid" + }, + { + "$ref": "#/$defs/Tabs" + }, + { + "$ref": "#/$defs/Carousel" + }, + { + "$ref": "#/$defs/Stack" } ] } } }, "required": [ - "value", - "trigger", - "content" + "children" ], "additionalProperties": false, - "description": "value is unique id, trigger is tab label, content is array of components" + "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." }, - "Carousel": { + "FollowUpBlock": { "type": "object", "properties": { - "children": { + "items": { "type": "array", "items": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/$defs/TextContent" - }, - { - "$ref": "#/$defs/MarkDownRenderer" - }, - { - "$ref": "#/$defs/CardHeader" - }, - { - "$ref": "#/$defs/Callout" - }, - { - "$ref": "#/$defs/TextCallout" - }, - { - "$ref": "#/$defs/CodeBlock" - }, - { - "$ref": "#/$defs/Image" - }, - { - "$ref": "#/$defs/ImageBlock" - }, - { - "$ref": "#/$defs/ImageGallery" - }, - { - "$ref": "#/$defs/Separator" - }, - { - "$ref": "#/$defs/HorizontalBarChart" - }, - { - "$ref": "#/$defs/RadarChart" - }, - { - "$ref": "#/$defs/PieChart" - }, - { - "$ref": "#/$defs/RadialChart" - }, - { - "$ref": "#/$defs/SingleStackedBarChart" - }, - { - "$ref": "#/$defs/ScatterChart" - }, - { - "$ref": "#/$defs/AreaChart" - }, - { - "$ref": "#/$defs/BarChart" - }, - { - "$ref": "#/$defs/LineChart" - }, - { - "$ref": "#/$defs/Table" - }, - { - "$ref": "#/$defs/TagBlock" - }, - { - "$ref": "#/$defs/Form" - }, - { - "$ref": "#/$defs/Buttons" - }, - { - "$ref": "#/$defs/IconButton" - }, - { - "$ref": "#/$defs/Steps" - }, - { - "$ref": "#/$defs/InlineHeader" - }, - { - "$ref": "#/$defs/EntityList" - }, - { - "$ref": "#/$defs/EditableTable" - }, - { - "$ref": "#/$defs/SnippetCardBlock" - }, - { - "$ref": "#/$defs/OverviewCardBlock" - }, - { - "$ref": "#/$defs/ContextCardBlock" - }, - { - "$ref": "#/$defs/CompositeCardBlock" - }, - { - "$ref": "#/$defs/VisualCardBlock" - } - ] - } + "$ref": "#/$defs/FollowUpItem" } - }, - "variant": { - "type": "string", - "enum": [ - "card", - "sunk" - ] } }, "required": [ - "children" + "items" ], "additionalProperties": false, - "description": "Horizontal scrollable carousel" + "description": "List of clickable follow-up suggestions placed at the end of a response" }, - "Stack": { + "FollowUpItem": { "type": "object", "properties": { - "children": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "description": "Clickable follow-up suggestion — when clicked, sends text as user message" + }, + "Sources": { + "type": "object", + "properties": { + "items": { "type": "array", - "items": {} - }, - "direction": { - "type": "string", - "enum": [ - "row", - "column" - ] - }, - "gap": { - "type": "string", - "enum": [ - "none", - "xs", - "s", - "m", - "l", - "xl", - "2xl" - ] - }, - "align": { - "type": "string", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" - ] - }, - "justify": { - "type": "string", - "enum": [ - "start", - "center", - "end", - "between", - "around", - "evenly" - ] - }, - "wrap": { - "type": "boolean" + "items": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "sourceName": { + "type": "string" + } + }, + "required": [ + "title", + "sourceName" + ], + "additionalProperties": false + } } }, "required": [ - "children" + "items" ], "additionalProperties": false, - "description": "Flex container. direction: \"row\"|\"column\" (default \"column\"). gap: \"none\"|\"xs\"|\"s\"|\"m\"|\"l\"|\"xl\"|\"2xl\" (default \"m\"). align: \"start\"|\"center\"|\"end\"|\"stretch\"|\"baseline\". justify: \"start\"|\"center\"|\"end\"|\"between\"|\"around\"|\"evenly\"." + "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." }, "Slice": { "type": "object", @@ -4380,19 +5618,6 @@ "additionalProperties": false, "description": "One slice with label and numeric value" }, - "Label": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false, - "description": "Text label" - }, "SwitchGroup": { "type": "object", "properties": { @@ -4405,14 +5630,6 @@ "$ref": "#/$defs/SwitchItem" } }, - "variant": { - "type": "string", - "enum": [ - "clear", - "card", - "sunk" - ] - }, "value": { "type": "object", "propertyNames": { @@ -4428,7 +5645,7 @@ "items" ], "additionalProperties": false, - "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." }, "SwitchItem": { "type": "object", @@ -4450,7 +5667,7 @@ "name" ], "additionalProperties": false, - "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." }, "Accordion": { "type": "object", @@ -4466,7 +5683,7 @@ "items" ], "additionalProperties": false, - "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." }, "AccordionItem": { "type": "object", @@ -4482,28 +5699,43 @@ "items": { "anyOf": [ { - "$ref": "#/$defs/TextContent" + "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/MarkDownRenderer" + "$ref": "#/$defs/Markdown" }, { - "$ref": "#/$defs/CardHeader" + "$ref": "#/$defs/Link" }, { - "$ref": "#/$defs/Callout" + "$ref": "#/$defs/Metric" }, { - "$ref": "#/$defs/TextCallout" + "$ref": "#/$defs/Avatar" }, { - "$ref": "#/$defs/CodeBlock" + "$ref": "#/$defs/AvatarStack" }, { - "$ref": "#/$defs/Image" + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" }, { - "$ref": "#/$defs/ImageBlock" + "$ref": "#/$defs/Image" }, { "$ref": "#/$defs/ImageGallery" @@ -4557,7 +5789,10 @@ "$ref": "#/$defs/Steps" }, { - "$ref": "#/$defs/InlineHeader" + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" }, { "$ref": "#/$defs/EntityList" @@ -4566,22 +5801,35 @@ "$ref": "#/$defs/EditableTable" }, { - "$ref": "#/$defs/SnippetCardBlock" + "$ref": "#/$defs/ValueCard" }, { - "$ref": "#/$defs/OverviewCardBlock" + "$ref": "#/$defs/ValueLayout" }, { - "$ref": "#/$defs/ContextCardBlock" + "$ref": "#/$defs/InsightCard" }, { "$ref": "#/$defs/CompositeCardBlock" }, { - "$ref": "#/$defs/VisualCardBlock" + "$ref": "#/$defs/ImageCard" } ] } + }, + "leading": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Avatar" + }, + { + "$ref": "#/$defs/Emoji" + } + ] } }, "required": [ @@ -4590,7 +5838,7 @@ "content" ], "additionalProperties": false, - "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." }, "Modal": { "type": "object", @@ -4606,28 +5854,43 @@ "items": { "anyOf": [ { - "$ref": "#/$defs/TextContent" + "$ref": "#/$defs/Text" }, { - "$ref": "#/$defs/MarkDownRenderer" + "$ref": "#/$defs/Markdown" }, { - "$ref": "#/$defs/CardHeader" + "$ref": "#/$defs/Link" }, { - "$ref": "#/$defs/Callout" + "$ref": "#/$defs/Metric" }, { - "$ref": "#/$defs/TextCallout" + "$ref": "#/$defs/Avatar" }, { - "$ref": "#/$defs/CodeBlock" + "$ref": "#/$defs/AvatarStack" }, { - "$ref": "#/$defs/Image" + "$ref": "#/$defs/Heading" + }, + { + "$ref": "#/$defs/LeadingText" + }, + { + "$ref": "#/$defs/EntityTitle" + }, + { + "$ref": "#/$defs/ImageTitle" + }, + { + "$ref": "#/$defs/Callout" + }, + { + "$ref": "#/$defs/CodeBlock" }, { - "$ref": "#/$defs/ImageBlock" + "$ref": "#/$defs/Image" }, { "$ref": "#/$defs/ImageGallery" @@ -4681,7 +5944,10 @@ "$ref": "#/$defs/Steps" }, { - "$ref": "#/$defs/InlineHeader" + "$ref": "#/$defs/Timeline" + }, + { + "$ref": "#/$defs/Checklist" }, { "$ref": "#/$defs/EntityList" @@ -4690,19 +5956,19 @@ "$ref": "#/$defs/EditableTable" }, { - "$ref": "#/$defs/SnippetCardBlock" + "$ref": "#/$defs/ValueCard" }, { - "$ref": "#/$defs/OverviewCardBlock" + "$ref": "#/$defs/ValueLayout" }, { - "$ref": "#/$defs/ContextCardBlock" + "$ref": "#/$defs/InsightCard" }, { "$ref": "#/$defs/CompositeCardBlock" }, { - "$ref": "#/$defs/VisualCardBlock" + "$ref": "#/$defs/ImageCard" } ] }