diff --git a/.changeset/cuddly-moons-hide.md b/.changeset/cuddly-moons-hide.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/cuddly-moons-hide.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/.github/genui-tsconfig.instructions.md b/.github/genui-tsconfig.instructions.md new file mode 100644 index 0000000000..4d0be8dcb3 --- /dev/null +++ b/.github/genui-tsconfig.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/genui/**/*" +--- + +When adding a new package under `packages/genui`, add a package-level `tsconfig.json` and wire it into `packages/genui/tsconfig.json` references in the same change. Root type-aware ESLint uses the TypeScript project service, so new GenUI source files that are not reachable from those references will fail pre-commit parsing before code linting even starts. diff --git a/biome.jsonc b/biome.jsonc index afce235fed..76db89d25f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -43,6 +43,7 @@ "packages/tools/**", "packages/genui/a2ui/tools/**", + "packages/genui/openui/**", "packages/use-sync-external-store/test/**", diff --git a/packages/genui/a2ui-playground/lynx-src/openui/App.tsx b/packages/genui/a2ui-playground/lynx-src/openui/App.tsx new file mode 100644 index 0000000000..15e53d1818 --- /dev/null +++ b/packages/genui/a2ui-playground/lynx-src/openui/App.tsx @@ -0,0 +1,85 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { + OpenUiRenderer, + createOpenUiLibrary, + createStreamingParser, +} from '@lynx-js/openui-reactlynx'; +import type { ActionEvent, ParseResult } from '@lynx-js/openui-reactlynx'; +import { useCallback, useEffect, useMemo, useState } from '@lynx-js/react'; + +import { OPENUI_SCENARIOS } from './mockData.js'; + +const mockData = OPENUI_SCENARIOS[0].raw; + +export function App() { + const openUiLibrary = useMemo(() => createOpenUiLibrary(), []); + + const [parseResult, setParseResult] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(''); + + try { + const streamParser = createStreamingParser(openUiLibrary.toJSONSchema()); + const result = streamParser.push(mockData); + if (!cancelled) { + setParseResult(result); + } + } catch (e) { + if (!cancelled) { + setError(String(e)); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + + return () => { + cancelled = true; + }; + }, [openUiLibrary]); + + const onOpenUiAction = useCallback((_event: ActionEvent) => { + // noop for now + }, []); + + return ( + + {error + ? ( + + {error} + + ) + : null} + + {loading + ? ( + + Loading... + + ) + : null} + + {parseResult?.root + ? ( + + + + ) + : null} + + ); +} diff --git a/packages/genui/a2ui-playground/lynx-src/openui/index.css b/packages/genui/a2ui-playground/lynx-src/openui/index.css new file mode 100644 index 0000000000..047d6a50a4 --- /dev/null +++ b/packages/genui/a2ui-playground/lynx-src/openui/index.css @@ -0,0 +1,4 @@ +page { + padding: 10px; + box-sizing: border-box; +} diff --git a/packages/genui/a2ui-playground/lynx-src/openui/index.tsx b/packages/genui/a2ui-playground/lynx-src/openui/index.tsx new file mode 100644 index 0000000000..3f88998c88 --- /dev/null +++ b/packages/genui/a2ui-playground/lynx-src/openui/index.tsx @@ -0,0 +1,9 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +import { App } from './App.jsx'; +import './index.css'; + +root.render(); diff --git a/packages/genui/a2ui-playground/lynx-src/openui/mockData.ts b/packages/genui/a2ui-playground/lynx-src/openui/mockData.ts new file mode 100644 index 0000000000..a38e9ec268 --- /dev/null +++ b/packages/genui/a2ui-playground/lynx-src/openui/mockData.ts @@ -0,0 +1,62 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export interface OpenUIScenario { + id: string; + title: string; + raw: string; +} + +export const OPENUI_SCENARIOS: OpenUIScenario[] = [ + { + id: 'pricing-cards', + title: 'Pricing Cards', + raw: `root = Stack([header, cards], "column", "l", "center") +header = TextContent("Choose Your Plan", "large-heavy") +cards = Stack([freeCard, proCard, enterpriseCard], "row", "l", "stretch") + +freeCard = Card([freeHeader, freePrice, freeSep, freeFeatures, freeBtn], "card", "column", "stretch", "between") +freeHeader = CardHeader("Free", "For individuals just getting started") +freePrice = Stack([freePriceAmt, freePricePer], "row", "none", "baseline") +freePriceAmt = TextContent("$0", "large-heavy") +freePricePer = TextContent(" / month", "small") +freeSep = Separator("horizontal", true) +freeFeatures = Stack([ff1, ff2, ff3, ff4], "column", "s") +ff1 = TextContent("✓ 1 user") +ff2 = TextContent("✓ 5 projects") +ff3 = TextContent("✓ 2 GB storage") +ff4 = TextContent("✗ Priority support") +freeBtn = Buttons([Button("Get Started", Action([@ToAssistant("Get started with Free plan")]), "secondary")]) + +proCard = Card([proHeader, proPrice, proBadge, proSep, proFeatures, proBtn], "card", "column", "stretch", "between") +proHeader = CardHeader("Pro", "For growing teams and professionals") +proPrice = Stack([proPriceAmt, proPricePer], "row", "none", "baseline") +proPriceAmt = TextContent("$29", "large-heavy") +proPricePer = TextContent(" / month", "small") +proBadge = Tag("Most Popular", null, "sm", "info") +proSep = Separator("horizontal", true) +proFeatures = Stack([pf1, pf2, pf3, pf4, pf5], "column", "s") +pf1 = TextContent("✓ Up to 10 users") +pf2 = TextContent("✓ Unlimited projects") +pf3 = TextContent("✓ 50 GB storage") +pf4 = TextContent("✓ Priority support") +pf5 = TextContent("✓ Advanced analytics") +proBtn = Buttons([Button("Start Free Trial", Action([@ToAssistant("Start free trial with Pro plan")]), "primary")]) + +enterpriseCard = Card([entHeader, entPrice, entSep, entFeatures, entBtn], "card", "column", "stretch", "between") +entHeader = CardHeader("Enterprise", "For large organizations at scale") +entPrice = Stack([entPriceAmt, entPricePer], "row", "none", "baseline") +entPriceAmt = TextContent("$99", "large-heavy") +entPricePer = TextContent(" / month", "small") +entSep = Separator("horizontal", true) +entFeatures = Stack([ef1, ef2, ef3, ef4, ef5, ef6], "column", "s") +ef1 = TextContent("✓ Unlimited users") +ef2 = TextContent("✓ Unlimited projects") +ef3 = TextContent("✓ 1 TB storage") +ef4 = TextContent("✓ 24/7 dedicated support") +ef5 = TextContent("✓ Advanced analytics") +ef6 = TextContent("✓ Custom integrations") +entBtn = Buttons([Button("Contact Sales", Action([@ToAssistant("Contact sales for Enterprise plan")]), "secondary")])`, + }, +]; diff --git a/packages/genui/a2ui-playground/lynx.config.ts b/packages/genui/a2ui-playground/lynx.config.ts index caf0bde1b3..6a750961d0 100644 --- a/packages/genui/a2ui-playground/lynx.config.ts +++ b/packages/genui/a2ui-playground/lynx.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ source: { entry: { a2ui: './lynx-src/a2ui/index.tsx', + openui: './lynx-src/openui/index.tsx', }, }, environments: { diff --git a/packages/genui/a2ui-playground/package.json b/packages/genui/a2ui-playground/package.json index 1045883264..8de36cc181 100644 --- a/packages/genui/a2ui-playground/package.json +++ b/packages/genui/a2ui-playground/package.json @@ -15,6 +15,7 @@ "@codemirror/lang-json": "^6.0.2", "@lynx-js/a2ui-reactlynx": "workspace:*", "@lynx-js/lynx-core": "0.1.3", + "@lynx-js/openui-reactlynx": "workspace:*", "@lynx-js/react": "workspace:*", "@lynx-js/web-core": "workspace:*", "@lynx-js/web-elements": "workspace:*", diff --git a/packages/genui/a2ui-playground/src/mock/openui-scenarios.ts b/packages/genui/a2ui-playground/src/mock/openui-scenarios.ts index 9b3ee0305d..bb7dbaea1a 100644 --- a/packages/genui/a2ui-playground/src/mock/openui-scenarios.ts +++ b/packages/genui/a2ui-playground/src/mock/openui-scenarios.ts @@ -62,24 +62,561 @@ entBtn = Buttons([Button("Contact Sales", Action([@ToAssistant("Contact sales fo parsed: JSON.stringify( { root: { - type: 'Stack', - direction: 'column', - spacing: 'l', - align: 'center', - children: ['header', 'cards'], + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { + text: 'Choose Your Plan', + size: 'large-heavy', + }, + partial: false, + hasDynamicProps: false, + statementId: 'header', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'Card', + props: { + children: [ + { + type: 'element', + typeName: 'CardHeader', + props: { + title: 'Starter', + subtitle: + 'Perfect for individuals and small projects', + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterHeader', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { + text: '$9', + size: 'large-heavy', + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterAmount', + }, + { + type: 'element', + typeName: 'TextContent', + props: { + text: '/ month', + size: 'small', + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterPeriod', + }, + ], + direction: 'row', + gap: 'baseline', + align: 'start', + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterPrice', + }, + { + type: 'element', + typeName: 'Separator', + props: { + orientation: 'horizontal', + decorative: true, + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterSep', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ 5 Projects' }, + partial: false, + hasDynamicProps: false, + statementId: 'sf1', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ 10 GB Storage' }, + partial: false, + hasDynamicProps: false, + statementId: 'sf2', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Email Support' }, + partial: false, + hasDynamicProps: false, + statementId: 'sf3', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✗ Custom Domain' }, + partial: false, + hasDynamicProps: false, + statementId: 'sf4', + }, + ], + direction: 'column', + gap: 's', + }, + partial: false, + hasDynamicProps: false, + statementId: 'starterFeatures', + }, + { + type: 'element', + typeName: 'Buttons', + props: { + buttons: [ + { + type: 'element', + typeName: 'Button', + props: { + label: 'Get Started', + action: { + k: 'Comp', + name: 'Action', + args: [ + { + k: 'Arr', + els: [ + { + k: 'Comp', + name: 'ToAssistant', + args: [ + { + k: 'Str', + v: 'I\'d like the Starter plan', + }, + ], + }, + ], + }, + ], + }, + variant: 'secondary', + }, + partial: false, + hasDynamicProps: true, + }, + ], + }, + partial: false, + hasDynamicProps: true, + statementId: 'starterBtn', + }, + ], + variant: 'card', + direction: 'column', + gap: 'stretch', + align: 'between', + }, + partial: false, + hasDynamicProps: true, + statementId: 'starterCard', + }, + { + type: 'element', + typeName: 'Card', + props: { + children: [ + { + type: 'element', + typeName: 'Tag', + props: { + text: 'Most Popular', + icon: null, + size: 'sm', + variant: 'info', + }, + partial: false, + hasDynamicProps: false, + statementId: 'proTag', + }, + { + type: 'element', + typeName: 'CardHeader', + props: { + title: 'Pro', + subtitle: + 'Great for growing teams and businesses', + }, + partial: false, + hasDynamicProps: false, + statementId: 'proHeader', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { text: '$29', size: 'large-heavy' }, + partial: false, + hasDynamicProps: false, + statementId: 'proAmount', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '/ month', size: 'small' }, + partial: false, + hasDynamicProps: false, + statementId: 'proPeriod', + }, + ], + direction: 'row', + gap: 'baseline', + align: 'start', + }, + partial: false, + hasDynamicProps: false, + statementId: 'proPrice', + }, + { + type: 'element', + typeName: 'Separator', + props: { + orientation: 'horizontal', + decorative: true, + }, + partial: false, + hasDynamicProps: false, + statementId: 'proSep', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Unlimited Projects' }, + partial: false, + hasDynamicProps: false, + statementId: 'pf1', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ 100 GB Storage' }, + partial: false, + hasDynamicProps: false, + statementId: 'pf2', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Priority Support' }, + partial: false, + hasDynamicProps: false, + statementId: 'pf3', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Custom Domain' }, + partial: false, + hasDynamicProps: false, + statementId: 'pf4', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Analytics Dashboard' }, + partial: false, + hasDynamicProps: false, + statementId: 'pf5', + }, + ], + direction: 'column', + gap: 's', + }, + partial: false, + hasDynamicProps: false, + statementId: 'proFeatures', + }, + { + type: 'element', + typeName: 'Buttons', + props: { + buttons: [ + { + type: 'element', + typeName: 'Button', + props: { + label: 'Get Started', + action: { + k: 'Comp', + name: 'Action', + args: [ + { + k: 'Arr', + els: [ + { + k: 'Comp', + name: 'ToAssistant', + args: [ + { + k: 'Str', + v: 'I\'d like the Pro plan', + }, + ], + }, + ], + }, + ], + }, + variant: 'primary', + }, + partial: false, + hasDynamicProps: true, + }, + ], + }, + partial: false, + hasDynamicProps: true, + statementId: 'proBtn', + }, + ], + variant: 'card', + direction: 'column', + gap: 'stretch', + align: 'between', + }, + partial: false, + hasDynamicProps: true, + statementId: 'proCard', + }, + { + type: 'element', + typeName: 'Card', + props: { + children: [ + { + type: 'element', + typeName: 'CardHeader', + props: { + title: 'Enterprise', + subtitle: 'For large-scale operations and teams', + }, + partial: false, + hasDynamicProps: false, + statementId: 'enterpriseHeader', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { text: '$99', size: 'large-heavy' }, + partial: false, + hasDynamicProps: false, + statementId: 'enterpriseAmount', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '/ month', size: 'small' }, + partial: false, + hasDynamicProps: false, + statementId: 'enterprisePeriod', + }, + ], + direction: 'row', + gap: 'baseline', + align: 'start', + }, + partial: false, + hasDynamicProps: false, + statementId: 'enterprisePrice', + }, + { + type: 'element', + typeName: 'Separator', + props: { + orientation: 'horizontal', + decorative: true, + }, + partial: false, + hasDynamicProps: false, + statementId: 'enterpriseSep', + }, + { + type: 'element', + typeName: 'Stack', + props: { + children: [ + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Unlimited Projects' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef1', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ 1 TB Storage' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef2', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ 24/7 Dedicated Support' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef3', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Custom Domain' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef4', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ Advanced Analytics' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef5', + }, + { + type: 'element', + typeName: 'TextContent', + props: { text: '✓ SSO & Security Audit' }, + partial: false, + hasDynamicProps: false, + statementId: 'ef6', + }, + ], + direction: 'column', + gap: 's', + }, + partial: false, + hasDynamicProps: false, + statementId: 'enterpriseFeatures', + }, + { + type: 'element', + typeName: 'Buttons', + props: { + buttons: [ + { + type: 'element', + typeName: 'Button', + props: { + label: 'Contact Sales', + action: { + k: 'Comp', + name: 'Action', + args: [ + { + k: 'Arr', + els: [ + { + k: 'Comp', + name: 'ToAssistant', + args: [ + { + k: 'Str', + v: 'I\'d like to learn about the Enterprise plan', + }, + ], + }, + ], + }, + ], + }, + variant: 'secondary', + }, + partial: false, + hasDynamicProps: true, + }, + ], + }, + partial: false, + hasDynamicProps: true, + statementId: 'enterpriseBtn', + }, + ], + variant: 'card', + direction: 'column', + gap: 'stretch', + align: 'between', + }, + partial: false, + hasDynamicProps: true, + statementId: 'enterpriseCard', + }, + ], + direction: 'row', + gap: 'l', + align: 'center', + }, + partial: false, + hasDynamicProps: true, + statementId: 'cards', + }, + ], + direction: 'column', + gap: 'l', + align: 'center', + }, + partial: false, + hasDynamicProps: true, + statementId: 'root', }, - header: { - type: 'TextContent', - text: 'Choose Your Plan', - variant: 'large-heavy', - }, - cards: { - type: 'Stack', - direction: 'row', - spacing: 'l', - align: 'stretch', - children: ['freeCard', 'proCard', 'enterpriseCard'], + meta: { + incomplete: false, + unresolved: [], + orphaned: [], + statementCount: 43, + errors: [], }, + stateDeclarations: {}, + queryStatements: [], + mutationStatements: [], }, null, 2, diff --git a/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx b/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx index b7fbbcf8ac..c046960498 100644 --- a/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/OpenUIDemosPage.tsx @@ -73,6 +73,8 @@ export function OpenUIDemosPage(_props: { protocol: Protocol }) { const [renderUrl, setRenderUrl] = useState(''); const [lynxDevUrl, setLynxDevUrl] = useState(''); const [lynxDevCopied, setLynxDevCopied] = useState(false); + const [renderCopied, setRenderCopied] = useState(false); + const [renderCopyFailed, setRenderCopyFailed] = useState(false); const [, setRenderQrError] = useState(''); const [lynxDevQrError, setLynxDevQrError] = useState(''); const [speed, setSpeed] = useState(1); @@ -125,6 +127,8 @@ export function OpenUIDemosPage(_props: { protocol: Protocol }) { (rawText: string) => { const url = buildOpenUIRenderUrl(rawText, networkBaseUrl, speed); setRenderUrl(url); + setRenderCopied(false); + setRenderCopyFailed(false); // Native Lynx dev URL const seq = ++lynxUrlSeqRef.current; @@ -146,7 +150,48 @@ export function OpenUIDemosPage(_props: { protocol: Protocol }) { setFullscreen(true); } - void seq; // suppress unused warning + // Try to shorten URLs via payload store for scannable QR codes + void (async () => { + if (!rspeedyDevUrl) return; + try { + const rspeedyOrigin = new URL(rspeedyDevUrl).origin; + const res = await window.fetch(`${rspeedyOrigin}/__openui_payload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rawText }), + }); + if (!res.ok) return; + const data = (await res.json()) as { rawTextUrl?: string }; + + if (seq !== lynxUrlSeqRef.current) return; + + if (typeof data.rawTextUrl === 'string') { + const rawTextUrlAbs = `${rspeedyOrigin}${data.rawTextUrl}`; + + // Shorten native dev URL + const u = new URL(rspeedyDevUrl); + u.pathname = u.pathname.replace('a2ui.lynx', 'openui.lynx'); + u.searchParams.set('rawTextUrl', rawTextUrlAbs); + u.searchParams.delete('rawText'); + if (speed !== 1) { + u.searchParams.set('speed', String(speed)); + } + setLynxDevUrl(u.toString()); + + // Shorten web render URL + const r = new URL('render.html', networkBaseUrl); + r.searchParams.set('protocol', 'openui'); + r.searchParams.set('demoUrl', './openui.web.js'); + r.searchParams.set('rawTextUrl', rawTextUrlAbs); + if (speed !== 1) { + r.searchParams.set('speed', String(speed)); + } + setRenderUrl(r.toString()); + } + } catch { + // Fall back to inline URLs — QR may show "URL too long" + } + })(); }, [networkBaseUrl, rspeedyDevUrl, speed], ); @@ -402,12 +447,36 @@ export function OpenUIDemosPage(_props: { protocol: Protocol }) { type='button' className='previewQrCopyBtn' aria-label='Copy render URL' - title='Copy URL' + title={renderCopied + ? 'Copied' + : ( + renderCopyFailed ? 'Copy failed' : 'Copy URL' + )} onClick={() => { - void copyToClipboard(renderUrl); + void copyToClipboard(renderUrl).then((ok) => { + setRenderCopyFailed(false); + if (!ok) { + setRenderCopied(false); + setRenderCopyFailed(true); + window.setTimeout( + () => setRenderCopyFailed(false), + 1200, + ); + return; + } + setRenderCopied(true); + window.setTimeout( + () => setRenderCopied(false), + 1200, + ); + }); }} > - Copy + {renderCopied + ? 'Copied' + : ( + renderCopyFailed ? 'Failed' : 'Copy' + )} diff --git a/packages/genui/openui/package.json b/packages/genui/openui/package.json new file mode 100644 index 0000000000..9b62bf3bd6 --- /dev/null +++ b/packages/genui/openui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lynx-js/openui-reactlynx", + "version": "0.0.0", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": { + "types": "./src/core/index.ts", + "default": "./src/core/index.ts" + }, + "./catalog": { + "types": "./src/catalog/index.ts", + "default": "./src/catalog/index.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "@openuidev/lang-core": "^0.2.4", + "zod": "^3.25.76" + }, + "devDependencies": { + "@lynx-js/react": "workspace:*", + "@lynx-js/types": "3.7.0", + "@types/react": "^18.3.28" + } +} diff --git a/packages/genui/openui/src/catalog/Action/index.tsx b/packages/genui/openui/src/catalog/Action/index.tsx new file mode 100644 index 0000000000..e000de6c35 --- /dev/null +++ b/packages/genui/openui/src/catalog/Action/index.tsx @@ -0,0 +1,37 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { tagSchemaId } from '@openuidev/lang-core'; +import type { ActionEvent, ActionPlan } from '@openuidev/lang-core'; +import { z } from 'zod/v4'; + +/** Shared action prop schema — shows as `ActionExpression` in prompt signatures. */ +export interface LegacyActionConfig { + type?: ActionEvent['type']; + params?: Record; + url?: string; + context?: string; +} + +export type ActionLike = ActionPlan | LegacyActionConfig; + +const actionStepSchema = z.looseObject({ + type: z.string(), +}); + +const actionPlanSchema = z.object({ + steps: z.array(actionStepSchema), +}); + +const legacyActionSchema = z.object({ + type: z.string().optional(), + params: z.record(z.string(), z.any()).optional(), + url: z.string().optional(), + context: z.string().optional(), +}); + +export const actionPropSchema = z.union([ + actionPlanSchema, + legacyActionSchema, +]); +tagSchemaId(actionPropSchema, 'ActionExpression'); diff --git a/packages/genui/openui/src/catalog/Button/index.tsx b/packages/genui/openui/src/catalog/Button/index.tsx new file mode 100644 index 0000000000..1e5b0a3094 --- /dev/null +++ b/packages/genui/openui/src/catalog/Button/index.tsx @@ -0,0 +1,114 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { BuiltinActionType } from '@openuidev/lang-core'; +import { z } from 'zod/v4'; + +import type { ReactNode } from '@lynx-js/react'; + +import { + useFormName, + useIsStreaming, + useTriggerAction, +} from '../../core/context.js'; +import { useFormValidation } from '../../core/hooks/index.js'; +import { defineComponent } from '../../core/library.js'; +import { actionPropSchema } from '../Action/index.jsx'; +import { asArray } from '../utils.js'; + +const CONTINUE_CONVERSATION_ACTION = String( + BuiltinActionType.ContinueConversation, +); +const OPEN_URL_ACTION = String(BuiltinActionType.OpenUrl); + +const buttonPropsSchema = z.object({ + label: z.string(), + action: actionPropSchema, + variant: z.enum(['primary', 'secondary', 'tertiary']).optional(), + type: z.enum(['normal', 'destructive']).optional(), + size: z.enum(['extra-small', 'small', 'medium', 'large']).optional(), +}); + +type ButtonProps = z.infer; + +function ButtonRenderer({ props }: { props: ButtonProps }) { + const triggerAction = useTriggerAction(); + const formName = useFormName(); + const isStreaming = useIsStreaming(); + const formValidation = useFormValidation(); + + const label = String(props.label ?? ''); + const variant = props.variant ?? 'primary'; + const action = props.action; + + let className = 'OpenUIButton OpenUIButtonPrimary'; + if (variant === 'secondary') { + className = 'OpenUIButton OpenUIButtonSecondary'; + } else if (variant === 'tertiary') { + className = 'OpenUIButton OpenUIButtonGhost'; + } + + const onTap = () => { + const legacyAction = action && !('steps' in action) ? action : undefined; + const actionType = legacyAction?.type ?? CONTINUE_CONVERSATION_ACTION; + + if ( + formValidation && variant === 'primary' + && actionType === CONTINUE_CONVERSATION_ACTION + ) { + const valid = formValidation.validateForm(); + if (!valid) return; + } + + const actionParams = actionType === OPEN_URL_ACTION + ? { url: legacyAction?.url } + : { + ...(legacyAction?.params ?? {}), + ...(legacyAction?.context ? { context: legacyAction.context } : {}), + }; + + void triggerAction(label, formName, { + type: actionType, + params: actionParams, + }); + }; + + return ( + + {label} + + ); +} + +export const Button = defineComponent({ + name: 'Button', + props: buttonPropsSchema, + description: 'Clickable button', + component: ButtonRenderer, +}); + +function ButtonsRenderer( + { props, renderNode }: { + props: { buttons: unknown[] }; + renderNode: (v: unknown) => ReactNode; + }, +) { + return ( + + {renderNode(asArray(props.buttons))} + + ); +} + +export const Buttons = defineComponent({ + name: 'Buttons', + props: z.object({ + buttons: z.array(Button.ref), + }), + description: 'Button group', + component: ButtonsRenderer, +}); diff --git a/packages/genui/openui/src/catalog/Card/index.tsx b/packages/genui/openui/src/catalog/Card/index.tsx new file mode 100644 index 0000000000..1897e713c2 --- /dev/null +++ b/packages/genui/openui/src/catalog/Card/index.tsx @@ -0,0 +1,80 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; +import { GAP_CLASS, asArray } from '../utils.js'; + +function getAlignClass(align: 'start' | 'center' | 'end' | 'stretch'): string { + switch (align) { + case 'start': + return 'OpenUIAlignStart'; + case 'center': + return 'OpenUIAlignCenter'; + case 'end': + return 'OpenUIAlignEnd'; + default: + return 'OpenUIAlignStretch'; + } +} + +function getJustifyClass( + justify: 'start' | 'center' | 'end' | 'between', +): string { + switch (justify) { + case 'center': + return 'OpenUIJustifyCenter'; + case 'end': + return 'OpenUIJustifyEnd'; + case 'between': + return 'OpenUIJustifyBetween'; + default: + return 'OpenUIJustifyStart'; + } +} + +export const Card = defineComponent({ + name: 'Card', + props: z.object({ + children: z.array(z.any()), + variant: z.enum(['card', 'sunk', 'clear']).optional(), + direction: z.enum(['row', 'column']).optional(), + wrap: z.boolean().optional(), + gap: z.enum(['none', 'xs', 's', 'm', 'l', 'xl']).optional(), + align: z.enum(['start', 'center', 'end', 'stretch']).optional(), + justify: z.enum(['start', 'center', 'end', 'between']).optional(), + }), + description: + 'Styled container (card/sunk/clear). Accept Stack layout parameters.', + component: ({ props, renderNode }) => { + const variant = props.variant ?? 'card'; + let variantClass = 'OpenUICardVariantCard'; + if (variant === 'sunk') { + variantClass = 'OpenUICardVariantSunk'; + } else if (variant === 'clear') { + variantClass = 'OpenUICardVariantClear'; + } + + const direction = props.direction ?? 'column'; + const gap = props.gap ?? 'm'; + const align = props.align ?? 'stretch'; + const justify = props.justify ?? 'start'; + + const layoutClassName = [ + direction === 'row' ? 'OpenUIStackRow' : 'OpenUIStackColumn', + props.wrap ? 'OpenUIStackWrap' : '', + GAP_CLASS[gap] ?? GAP_CLASS['m'], + getAlignClass(align), + getJustifyClass(justify), + ] + .filter(Boolean) + .join(' '); + + return ( + + {renderNode(asArray(props.children))} + + ); + }, +}); diff --git a/packages/genui/openui/src/catalog/CardHeader/index.tsx b/packages/genui/openui/src/catalog/CardHeader/index.tsx new file mode 100644 index 0000000000..e1bd7571df --- /dev/null +++ b/packages/genui/openui/src/catalog/CardHeader/index.tsx @@ -0,0 +1,23 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; + +export const CardHeader = defineComponent({ + name: 'CardHeader', + props: z.object({ + title: z.string(), + subtitle: z.string().optional(), + }), + description: 'Card header with title and optional subtitle.', + component: ({ props }) => ( + + {props.title} + {props.subtitle + ? {props.subtitle} + : null} + + ), +}); diff --git a/packages/genui/openui/src/catalog/Separator/index.tsx b/packages/genui/openui/src/catalog/Separator/index.tsx new file mode 100644 index 0000000000..78cfed5fe1 --- /dev/null +++ b/packages/genui/openui/src/catalog/Separator/index.tsx @@ -0,0 +1,13 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; + +export const Separator = defineComponent({ + name: 'Separator', + props: z.object({}), + description: 'Separator', + component: () => , +}); diff --git a/packages/genui/openui/src/catalog/Stack/index.tsx b/packages/genui/openui/src/catalog/Stack/index.tsx new file mode 100644 index 0000000000..f85a758328 --- /dev/null +++ b/packages/genui/openui/src/catalog/Stack/index.tsx @@ -0,0 +1,69 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; +import { GAP_CLASS, asArray } from '../utils.js'; + +function getAlignClass(align: 'start' | 'center' | 'end' | 'stretch'): string { + switch (align) { + case 'start': + return 'OpenUIAlignStart'; + case 'center': + return 'OpenUIAlignCenter'; + case 'end': + return 'OpenUIAlignEnd'; + default: + return 'OpenUIAlignStretch'; + } +} + +function getJustifyClass( + justify: 'start' | 'center' | 'end' | 'between', +): string { + switch (justify) { + case 'center': + return 'OpenUIJustifyCenter'; + case 'end': + return 'OpenUIJustifyEnd'; + case 'between': + return 'OpenUIJustifyBetween'; + default: + return 'OpenUIJustifyStart'; + } +} + +export const Stack = defineComponent({ + name: 'Stack', + props: z.object({ + children: z.array(z.any()), + direction: z.enum(['row', 'column']).optional(), + wrap: z.boolean().optional(), + gap: z.enum(['none', 'xs', 's', 'm', 'l', 'xl']).optional(), + align: z.enum(['start', 'center', 'end', 'stretch']).optional(), + justify: z.enum(['start', 'center', 'end', 'between']).optional(), + }), + description: 'Flex layout container', + component: ({ props, renderNode }) => { + const direction = props.direction ?? 'column'; + const gap = props.gap ?? 'm'; + const align = props.align ?? 'stretch'; + const justify = props.justify ?? 'start'; + + const className = [ + 'OpenUIStack', + direction === 'row' ? 'OpenUIStackRow' : 'OpenUIStackColumn', + props.wrap ? 'OpenUIStackWrap' : '', + GAP_CLASS[gap] ?? GAP_CLASS['m'], + getAlignClass(align), + getJustifyClass(justify), + ] + .filter(Boolean) + .join(' '); + + return ( + {renderNode(asArray(props.children))} + ); + }, +}); diff --git a/packages/genui/openui/src/catalog/Tag/index.tsx b/packages/genui/openui/src/catalog/Tag/index.tsx new file mode 100644 index 0000000000..1e6a0f8884 --- /dev/null +++ b/packages/genui/openui/src/catalog/Tag/index.tsx @@ -0,0 +1,19 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; + +export const Tag = defineComponent({ + name: 'Tag', + props: z.object({ + text: z.string(), + }), + description: 'Tag', + component: ({ props }) => ( + + {props.text} + + ), +}); diff --git a/packages/genui/openui/src/catalog/TextContent/index.tsx b/packages/genui/openui/src/catalog/TextContent/index.tsx new file mode 100644 index 0000000000..77095319b1 --- /dev/null +++ b/packages/genui/openui/src/catalog/TextContent/index.tsx @@ -0,0 +1,35 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { z } from 'zod/v4'; + +import { defineComponent } from '../../core/library.js'; + +export const TextContent = defineComponent({ + name: 'TextContent', + props: z.object({ + text: z.union([z.string(), z.number(), z.boolean()]), + size: z.enum(['small', 'default', 'large', 'small-heavy', 'large-heavy']) + .optional(), + }), + description: 'Text content with optional size.', + component: ({ props }) => { + const size = props.size ?? 'default'; + let sizeClass = 'OpenUITextContentDefault'; + if (size === 'small') { + sizeClass = 'OpenUITextContentSmall'; + } else if (size === 'large') { + sizeClass = 'OpenUITextContentLarge'; + } else if (size === 'small-heavy') { + sizeClass = 'OpenUITextContentSmallHeavy'; + } else if (size === 'large-heavy') { + sizeClass = 'OpenUITextContentLargeHeavy'; + } + + return ( + + {String(props.text ?? '')} + + ); + }, +}); diff --git a/packages/genui/openui/src/catalog/index.ts b/packages/genui/openui/src/catalog/index.ts new file mode 100644 index 0000000000..3a1a40082a --- /dev/null +++ b/packages/genui/openui/src/catalog/index.ts @@ -0,0 +1,10 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export { Button, Buttons } from './Button/index.jsx'; +export { Card } from './Card/index.jsx'; +export { CardHeader } from './CardHeader/index.jsx'; +export { Separator } from './Separator/index.jsx'; +export { Stack } from './Stack/index.jsx'; +export { Tag } from './Tag/index.jsx'; +export { TextContent } from './TextContent/index.jsx'; diff --git a/packages/genui/openui/src/catalog/utils.ts b/packages/genui/openui/src/catalog/utils.ts new file mode 100644 index 0000000000..1c5aec63da --- /dev/null +++ b/packages/genui/openui/src/catalog/utils.ts @@ -0,0 +1,16 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export const GAP_CLASS: Record = { + none: 'OpenUIGapNone', + xs: 'OpenUIGapXs', + s: 'OpenUIGapS', + m: 'OpenUIGapM', + l: 'OpenUIGapL', + xl: 'OpenUIGapXl', +}; + +export function asArray(value: T | T[] | null | undefined): T[] { + if (value === null || value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} diff --git a/packages/genui/openui/src/core/context.tsx b/packages/genui/openui/src/core/context.tsx new file mode 100644 index 0000000000..7736baa190 --- /dev/null +++ b/packages/genui/openui/src/core/context.tsx @@ -0,0 +1,203 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { + ActionPlan, + EvaluationContext, + Library, + OpenUIError, + Store, +} from '@openuidev/lang-core'; + +import type { ReactNode } from '@lynx-js/react'; +import { createContext, useContext, useEffect } from '@lynx-js/react'; + +export interface OpenUIContextValue { + /** The active component library (schema + renderers). */ + library: Library; + + /** + * Render any value (element, array, primitive) into React nodes. + */ + renderNode: (value: unknown) => ReactNode; + + /** + * Trigger an action. Accepts either: + * - ActionPlan (v0.5): runs steps sequentially (Run, Set, ToAssistant, OpenUrl) + * - Legacy action config (v0.1): { type?: string, params?: Record } + * - Nothing: fires ContinueConversation with the label + */ + triggerAction: ( + userMessage: string, + formName?: string, + // biome-ignore lint/suspicious/noExplicitAny: form values are dynamically typed + action?: ActionPlan | { type?: string; params?: Record }, + ) => void | Promise; + + /** Whether the LLM is currently streaming content. */ + isStreaming: boolean; + + /** Get a field value. Top-level for $bindings, nested under formName for form fields. */ + // biome-ignore lint/suspicious/noExplicitAny: form values are dynamically typed + getFieldValue: (formName: string | undefined, name: string) => any; + + /** + * Set a form field value. + * + * @param formName The form's name prop + * @param componentType The component type (e.g. "Input", "Select") — optional + * @param name The field's name prop + * @param value The new value + * @param shouldTriggerSaveCallback When true, persists state via onStateUpdate. + * Text inputs should pass `false` on change and `true` on blur. + * Discrete inputs (Select, RadioGroup, etc.) should always pass `true`. + */ + setFieldValue: ( + formName: string | undefined, + componentType: string | undefined, + name: string, + value: unknown, + shouldTriggerSaveCallback?: boolean, + ) => void; + + /** Reactive binding store for $variables and form data. */ + store: Store; + + /** AST evaluation context used by runtime expression evaluation. */ + evaluationContext: EvaluationContext; + + /** Report a structured error (used internally by error boundary). */ + reportError?: (error: OpenUIError) => void; +} + +export const OpenUIContext = createContext(null); + +/** + * Access the full OpenUI context. Throws if used outside a . + */ +export function useOpenUI(): OpenUIContextValue { + const ctx = useContext(OpenUIContext); + if (!ctx) { + throw new Error('useOpenUI must be used within a component.'); + } + return ctx; +} + +/** + * Get the renderNode function for rendering nested component values. + */ +export function useRenderNode() { + return useOpenUI().renderNode; +} + +/** + * Get the triggerAction function for firing structured action events. + * + * @example + * ```tsx + * const triggerAction = useTriggerAction(); + *