diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index 7e2aef79c..3941f3971 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -18,6 +18,7 @@ on: - react-email - openui-cli - svelte-lang + - solid-lang - vue-lang jobs: diff --git a/README.md b/README.md index d67cebb5c..6feceec50 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,12 @@ - OpenUI is a full-stack Generative UI framework — a compact streaming-first language, a React runtime with built-in component libraries, and ready-to-use chat interfaces — that is up to 67% more token-efficient than JSON. - - --- - - [Docs](https://openui.com) · [Playground](https://www.openui.com/playground) · [Sample Chat App](./examples/openui-chat) · [Discord](https://discord.com/invite/Pbv5PsqUSv) · [Contributing](./CONTRIBUTING.md) · [Code of Conduct](./CODE_OF_CONDUCT.md) · [Security](./SECURITY.md) · [License](./LICENSE) - --- ## What is OpenUI @@ -43,7 +37,6 @@ At the center of OpenUI is **OpenUI Lang**: a compact, streaming-first language - **Streaming renderer** — Parse and render model output progressively in React as tokens arrive. - **Chat and app surfaces** - Use the same foundation for assistants, copilots, and broader interactive product flows. - ## Quick Start ```bash @@ -62,8 +55,6 @@ What this gives you: - **Streaming support** - Update the UI progressively as output arrives. - **Working app foundation** - Start from a ready-to-run example instead of wiring everything manually. - - ## How it works Your components define what the model can generate. @@ -87,12 +78,13 @@ Try it yourself in the [Playground](https://www.openui.com/playground) — gener ## Packages -| Package | Description | -| :--- | :--- | -| [`@openuidev/react-lang`](./packages/react-lang) | Core runtime — component definitions, parser, renderer, prompt generation | -| [`@openuidev/react-headless`](./packages/react-headless) | Headless chat state, streaming adapters, message format converters | -| [`@openuidev/react-ui`](./packages/react-ui) | Prebuilt chat layouts and two built-in component libraries | -| [`@openuidev/cli`](./packages/openui-cli) | CLI for scaffolding apps and generating system prompts | +| Package | Description | +| :------------------------------------------------------- | :--------------------------------------------------------------------------- | +| [`@openuidev/react-lang`](./packages/react-lang) | Core runtime — component definitions, parser, renderer, prompt generation | +| [`@openuidev/solid-lang`](./packages/solid-lang) | SolidJS runtime — component definitions, parser, renderer, prompt generation | +| [`@openuidev/react-headless`](./packages/react-headless) | Headless chat state, streaming adapters, message format converters | +| [`@openuidev/react-ui`](./packages/react-ui) | Prebuilt chat layouts and two built-in component libraries | +| [`@openuidev/cli`](./packages/openui-cli) | CLI for scaffolding apps and generating system prompts | ```bash npm install @openuidev/react-lang @openuidev/react-ui @@ -133,14 +125,16 @@ Detailed documentation is available at [openui.com](https://openui.com). ``` openui/ ├── packages/ -│ ├── react-lang/ # Core runtime (parser, renderer, prompt generation) +│ ├── react-lang/ # React runtime (parser, renderer, prompt generation) +│ ├── solid-lang/ # SolidJS runtime (parser, renderer, prompt generation) │ ├── react-headless/ # Headless chat state & streaming adapters │ ├── react-ui/ # Prebuilt chat layouts & component libraries │ └── openui-cli/ # CLI for scaffolding & prompt generation ├── skills/ │ └── openui/ # Claude Code skill for AI-assisted development ├── examples/ -│ └── openui-chat/ # Full working example app (Next.js) +│ ├── openui-chat/ # Full working example app (Next.js) +│ └── solid-chat/ # SolidJS chat example with @openuidev/solid-lang ├── docs/ # Documentation site (openui.com) └── benchmarks/ # Token efficiency benchmarks ``` @@ -149,6 +143,7 @@ Good places to start: - [openui.com](https://openui.com) for the full docs - [`examples/openui-chat`](./examples/openui-chat) for a working app +- [`examples/solid-chat`](./examples/solid-chat) for SolidJS runtime usage - [`CONTRIBUTING.md`](./CONTRIBUTING.md) if you want to contribute ## Community @@ -156,25 +151,24 @@ Good places to start: - [Discord](https://discord.com/invite/Pbv5PsqUSv) — Ask questions, share what you're building - [GitHub Issues](https://github.com/thesysdev/openui/issues) — Report bugs or request features - ## Contributing Contributions are welcome. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for contribution guidelines and ways to get involved. ## Agent Skill - + OpenUI ships an [Agent Skill](https://agentskills.io) so AI coding assistants (Claude Code, Codex, Cursor, Copilot, etc.) can help you scaffold, build, and debug Generative UI apps using OpenUI Lang. - + ### Install - + ```bash # With the skills CLI (works across all agents) npx skills add thesysdev/openui --skill openui - + # Manual — copy into your project cp -r skills/openui .claude/skills/openui ``` - + The skill covers component library design, OpenUI Lang syntax, system prompt generation, the Renderer, SDK packages, and debugging malformed LLM output. ## License diff --git a/examples/solid-chat/.env.example b/examples/solid-chat/.env.example new file mode 100644 index 000000000..f73f4e388 --- /dev/null +++ b/examples/solid-chat/.env.example @@ -0,0 +1,3 @@ +OPENAI_BASE_URL=http://localhost:11434/v1 +OPENAI_MODEL=llama3.1:8b +OPENAI_API_KEY=ollama diff --git a/examples/solid-chat/.gitignore b/examples/solid-chat/.gitignore new file mode 100644 index 000000000..9c97bbd46 --- /dev/null +++ b/examples/solid-chat/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/examples/solid-chat/README.md b/examples/solid-chat/README.md new file mode 100644 index 000000000..6ccc3c93f --- /dev/null +++ b/examples/solid-chat/README.md @@ -0,0 +1,59 @@ +# OpenUI Solid Chat + +Example chat app for `@openuidev/solid-lang` using a real AI model (default: Ollama/OpenAI-compatible API), Ark UI Field input, and ECharts-based visualizations. + +## Goals + +- Show how to define OpenUI components for Solid. +- Show how to render OpenUI Lang responses with `Renderer`. +- Show the action loop from components (for example, `Button`) back into the chat flow. + +## Run + +From the monorepo root: + +```bash +pnpm install +cp examples/solid-chat/.env.example examples/solid-chat/.env +pnpm --filter solid-chat generate:prompt +pnpm --filter solid-chat dev +``` + +Then open `http://localhost:5174`. + +## Model Configuration + +- Default (`.env.example`) uses local Ollama: + +```bash +OPENAI_BASE_URL=http://localhost:11434/v1 +OPENAI_MODEL=llama3.1:8b +OPENAI_API_KEY=ollama +``` + +- To use OpenAI cloud, change to: + +```bash +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4.1 +OPENAI_API_KEY=sk-... +``` + +## Notes + +- The `/api/chat` endpoint is implemented directly in `vite.config.ts` via dev middleware (fast demo setup). +- OpenUI components live in `src/components` and are registered in `src/lib/library.tsx`. +- Chat input uses `@ark-ui/solid` (`Field`). +- The `Chart` component uses `echarts` (bar/line/pie/doughnut). +- Form-like rendering components are also available: `InputField`, `TextAreaField`, `SelectField`, `ToggleField`, and `Divider`. +- Form fields support `$state` binding (for example, `InputField("Email", "name@example.com", $email, "email")`) and will write values back into renderer form state. +- `Button` supports both simple action type strings and structured action objects from `Action(...)` expressions. +- The system prompt is generated into `examples/solid-chat/generated/system-prompt.txt` for easier review and iteration. + +## Example Prompts + +- `Build a weekly SaaS business dashboard with KPIs, revenue trend, and channel mix charts` +- `Create a support operations dashboard with ticket volume, SLA trend, and follow-up actions` +- `Build a release readiness view with milestones, blockers, and next actions` +- `Create an onboarding status page with progress timeline and action buttons` +- `Create an account settings form with profile inputs, notification toggles, and save actions` diff --git a/examples/solid-chat/generated/system-prompt.txt b/examples/solid-chat/generated/system-prompt.txt new file mode 100644 index 000000000..235e7cf5f --- /dev/null +++ b/examples/solid-chat/generated/system-prompt.txt @@ -0,0 +1,136 @@ +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. + +## Syntax Rules + +1. Each statement is on its own line: `identifier = Expression` +2. `root` is the entry point — every program must define `root = Stack(...)` +3. Expressions are: strings ("..."), numbers, booleans (true/false), null, arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...) +4. Use references for readability: define `name = ...` on one line, then use `name` later +5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array. +6. Arguments are POSITIONAL (order matters, not names). Write `Stack([children], "row", "l")` NOT `Stack([children], direction: "row", gap: "l")` — colon syntax is NOT supported and silently breaks +7. Optional arguments can be omitted from the end +- Strings use double quotes with backslash escaping + +## Component Signatures + +Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. +Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@ToAssistant, @OpenUrl). +Props marked `$binding` accept a `$variable` reference for two-way binding. + +TextContent(text: string, tone?: "normal" | "muted" | "strong" | "success" | "warning" | "danger" | "info") — Displays a block of text. +Button(label: string, action?: string | {type: "open_url", url: string} | {type: "continue_conversation", context?: string}, variant?: "primary" | "secondary" | "ghost") — A clickable button. +Chart(title: string, type: "bar" | "line" | "pie" | "doughnut", labels: string[], values: number[], datasetLabel?: string) — Renders a chart. +Badge(label: string, tone?: "neutral" | "success" | "warning" | "danger" | "info") — Small status pill. +KpiTile(label: string, value: string, delta?: string, trend?: "up" | "down" | "neutral") — Compact KPI tile with value and trend. +MetricList(title: string, items: {label: string, value: string}[]) — Two-column labeled metric list. +Timeline(title: string, items: {title: string, detail: string, status?: "done" | "active" | "next"}[]) — Progress timeline with status dots. +InputField(label: string, placeholder?: string, value?: string, type?: "text" | "email" | "password" | "number" | "url") — Single-line text input with label. +TextAreaField(label: string, placeholder?: string, value?: string, rows?: number) — Multi-line text input with label. +SelectField(label: string, options: string[], selected?: string) — Dropdown-style field with selectable options. +ToggleField(label: string, checked?: boolean) — On/off setting toggle with label. +Divider(label?: string) — Visual divider line with optional label. +Card(title: string, children: (TextContent | Button | Chart | Badge | KpiTile | MetricList | Timeline | InputField | TextAreaField | SelectField | ToggleField | Divider)[], subtitle?: string, variant?: "default" | "glass" | "accent", highlight?: string) — Card container with title and children. +Stack(children: (Card | TextContent | Button | Chart | Badge | KpiTile | MetricList | Timeline | InputField | TextAreaField | SelectField | ToggleField | Divider)[]) — Dashboard layout container. Use as root. + +## Built-in Functions + +Data functions prefixed with `@` to distinguish from components. These are the ONLY functions available — do NOT invent new ones. +Use @-prefixed built-in functions (@Count, @Sum, @Avg, @Min, @Max, @Round) on Query results — do NOT hardcode computed values. + +@Count(array) → number — Returns array length +@First(array) → element — Returns first element of array +@Last(array) → element — Returns last element of array +@Sum(numbers[]) → number — Sum of numeric array +@Avg(numbers[]) → number — Average of numeric array +@Min(numbers[]) → number — Minimum value in array +@Max(numbers[]) → number — Maximum value in array +@Sort(array, field, direction?) → sorted array — Sort array by field. Direction: "asc" (default) or "desc" +@Filter(array, field, operator: "==" | "!=" | ">" | "<" | ">=" | "<=" | "contains", value) → filtered array — Filter array by field value +@Round(number, decimals?) → number — Round to N decimal places (default 0) +@Abs(number) → number — Absolute value +@Floor(number) → number — Round down to nearest integer +@Ceil(number) → number — Round up to nearest integer +@Each(array, varName, template) — Evaluate template for each element. varName is the loop variable — use it ONLY inside the template expression (inline). Do NOT create a separate statement for the template. + +Builtins compose — output of one is input to the next: +`@Count(@Filter(data.rows, "field", "==", "val"))` for KPIs/chart values, `@Round(@Avg(data.rows.score), 1)`, `@Each(data.rows, "item", Comp(item.field))` for per-item rendering. +Array pluck: `data.rows.field` extracts a field from every row → use with @Sum, @Avg, charts, tables. + +IMPORTANT @Each rule: The loop variable (e.g. "item") is ONLY available inside the @Each template expression. Always inline the template — do NOT extract it to a separate statement. +CORRECT: `Col("Actions", @Each(rows, "t", Button("Edit", Action([@Set($id, t.id)]))))` +WRONG: `myBtn = Button("Edit", Action([@Set($id, t.id)]))` then `Col("Actions", @Each(rows, "t", myBtn))` — t is undefined in myBtn. + +## Action — Button Behavior + +Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order. +Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])). + +Available steps: +- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this") +- @OpenUrl("https://...") — Navigate to a URL + +Example — simple nav: +``` +viewBtn = Button("View", Action([@OpenUrl("https://example.com")])) +``` + +- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work + +## Hoisting & Streaming (CRITICAL) + +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. + +During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in. + +**Recommended statement order for optimal streaming:** +1. `root = Stack(...)` — UI shell appears immediately +2. $variable declarations — state ready for bindings +3. Query statements — defaults resolve immediately so components render with data +4. Component definitions — fill in with data already available +5. Data values — leaf content last + +Always write the root = Stack(...) statement first so the UI shell appears immediately, even before child data has streamed in. + +## Examples + +User: Build a weekly product status view + +root = Stack([title, summary, panel, action]) +title = TextContent("Product Delivery Overview", "strong") +summary = TextContent("Current sprint highlights, blockers, and next actions.", "muted") +state = Badge("On Track", "success") +velocity = Chart("Story Points", "bar", ["W1", "W2", "W3", "W4"], [28, 31, 35, 33], "pts") +burn = Chart("Defect Trend", "line", ["W1", "W2", "W3", "W4"], [14, 11, 9, 7], "count") +metrics = MetricList("Team Metrics", [{"label":"Cycle time", "value":"3.2d"}, {"label":"PR review", "value":"6.8h"}, {"label":"Escaped bugs", "value":"2"}]) +steps = Timeline("Release Steps", [{"title":"Scope lock", "detail":"Done", "status":"done"}, {"title":"UAT", "detail":"In progress", "status":"active"}, {"title":"Launch", "detail":"Scheduled Friday", "status":"next"}]) +panel = Card("Delivery Health", [state, velocity, burn, metrics, steps], "Sprint 18 status", "glass", "Updated") +action = Button("Generate rollback checklist", "continue-conversation", "primary") + + +## Important Rules +- When asked about data, generate realistic/plausible data +- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) + +## Final Verification +Before finishing, walk your output and verify: +1. root = Stack(...) is the FIRST line (for optimal streaming). +2. Every referenced name is defined. Every defined name (other than root) is reachable from root. + +- Always use Stack as the root component. +- The FIRST line must be exactly a root assignment: root = Stack(...). +- Do not output any statement before root = Stack(...). +- Use Stack only as root. Never place Stack inside Card children. +- Output openui-lang code only. No markdown fences, no explanations, no think blocks. +- All visible text must be English. +- Start directly with openui-lang code. Do not add preface text. +- Keep syntax valid while streaming: no TODO bullets, no pseudo-code, no planning sections. +- Match the requested UI type; do not force dashboard layouts for every request. +- Use references for readability and better streaming behavior. +- Prefer Card subtitle/highlight only when it improves hierarchy. +- Use Badge, MetricList, Timeline, Chart, and KpiTile when relevant. +- Use InputField, TextAreaField, SelectField, ToggleField, and Divider for form or settings UIs. +- Use state bindings in forms when needed (e.g. InputField("Email", "name@example.com", $email, "email")). +- Button action supports both simple action type strings and structured action objects. +- Use positional arguments only. +- Prefer inline composition in Stack/Card to reduce forward-reference errors. +- Never output reasoning or markdown. \ No newline at end of file diff --git a/examples/solid-chat/index.html b/examples/solid-chat/index.html new file mode 100644 index 000000000..3c3613d5d --- /dev/null +++ b/examples/solid-chat/index.html @@ -0,0 +1,12 @@ + + + + + + OpenUI Solid Chat + + +
+ + + diff --git a/examples/solid-chat/package.json b/examples/solid-chat/package.json new file mode 100644 index 000000000..a89f9832c --- /dev/null +++ b/examples/solid-chat/package.json @@ -0,0 +1,26 @@ +{ + "name": "solid-chat", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "generate:prompt": "node scripts/generate-prompt.mjs" + }, + "dependencies": { + "@ark-ui/solid": "^5.35.0", + "echarts": "^5.6.0", + "@openuidev/lang-core": "workspace:*", + "lucide-solid": "^0.542.0", + "@openuidev/solid-lang": "workspace:*", + "solid-js": "^1.9.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "jiti": "^2.6.1", + "typescript": "^5.9.2", + "vite": "^6.4.1", + "vite-plugin-solid": "^2.11.8" + } +} diff --git a/examples/solid-chat/scripts/generate-prompt.mjs b/examples/solid-chat/scripts/generate-prompt.mjs new file mode 100644 index 000000000..01ad58bac --- /dev/null +++ b/examples/solid-chat/scripts/generate-prompt.mjs @@ -0,0 +1,276 @@ +import { createJiti } from "jiti"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const jiti = createJiti(import.meta.url); + +const { createLibrary, defineComponent } = await jiti.import("@openuidev/lang-core"); +const { BuiltinActionType } = await jiti.import("@openuidev/lang-core"); +const { z } = await jiti.import("zod"); + +const continueConversationAction = z.object({ + type: z.literal(BuiltinActionType.ContinueConversation), + context: z.string().optional(), +}); + +const openUrlAction = z.object({ + type: z.literal(BuiltinActionType.OpenUrl), + url: z.string(), +}); + +const actionSchema = z.union([openUrlAction, continueConversationAction]); + +const TextContentDef = defineComponent({ + name: "TextContent", + props: z.object({ + text: z.string(), + tone: z.enum(["normal", "muted", "strong", "success", "warning", "danger", "info"]).optional(), + }), + description: "Displays a block of text.", + component: null, +}); + +const ButtonDef = defineComponent({ + name: "Button", + props: z.object({ + label: z.string(), + action: z.union([z.string(), actionSchema]).optional(), + variant: z.enum(["primary", "secondary", "ghost"]).optional(), + }), + description: "A clickable button.", + component: null, +}); + +const BadgeDef = defineComponent({ + name: "Badge", + props: z.object({ + label: z.string(), + tone: z.enum(["neutral", "success", "warning", "danger", "info"]).optional(), + }), + description: "Small status pill.", + component: null, +}); + +const KpiTileDef = defineComponent({ + name: "KpiTile", + props: z.object({ + label: z.string(), + value: z.string(), + delta: z.string().optional(), + trend: z.enum(["up", "down", "neutral"]).optional(), + }), + description: "Compact KPI tile with value and trend.", + component: null, +}); + +const MetricListDef = defineComponent({ + name: "MetricList", + props: z.object({ + title: z.string(), + items: z.array(z.object({ label: z.string(), value: z.string() })), + }), + description: "Two-column labeled metric list.", + component: null, +}); + +const TimelineDef = defineComponent({ + name: "Timeline", + props: z.object({ + title: z.string(), + items: z.array( + z.object({ + title: z.string(), + detail: z.string(), + status: z.enum(["done", "active", "next"]).optional(), + }), + ), + }), + description: "Progress timeline with status dots.", + component: null, +}); + +const ChartDef = defineComponent({ + name: "Chart", + props: z.object({ + title: z.string(), + type: z.enum(["bar", "line", "pie", "doughnut"]), + labels: z.array(z.string()), + values: z.array(z.number()), + datasetLabel: z.string().optional(), + }), + description: "Renders a chart.", + component: null, +}); + +const InputFieldDef = defineComponent({ + name: "InputField", + props: z.object({ + label: z.string(), + placeholder: z.string().optional(), + value: z.string().or(z.any()).optional(), + type: z.enum(["text", "email", "password", "number", "url"]).optional(), + }), + description: "Single-line text input with label.", + component: null, +}); + +const TextAreaFieldDef = defineComponent({ + name: "TextAreaField", + props: z.object({ + label: z.string(), + placeholder: z.string().optional(), + value: z.string().or(z.any()).optional(), + rows: z.number().optional(), + }), + description: "Multi-line text input with label.", + component: null, +}); + +const SelectFieldDef = defineComponent({ + name: "SelectField", + props: z.object({ + label: z.string(), + options: z.array(z.string()), + selected: z.string().or(z.any()).optional(), + }), + description: "Dropdown-style field with selectable options.", + component: null, +}); + +const ToggleFieldDef = defineComponent({ + name: "ToggleField", + props: z.object({ + label: z.string(), + checked: z.boolean().or(z.any()).optional(), + }), + description: "On/off setting toggle with label.", + component: null, +}); + +const DividerDef = defineComponent({ + name: "Divider", + props: z.object({ + label: z.string().optional(), + }), + description: "Visual divider line with optional label.", + component: null, +}); + +const CardDef = defineComponent({ + name: "Card", + props: z.object({ + title: z.string(), + children: z.array( + z.union([ + TextContentDef.ref, + ButtonDef.ref, + ChartDef.ref, + BadgeDef.ref, + KpiTileDef.ref, + MetricListDef.ref, + TimelineDef.ref, + InputFieldDef.ref, + TextAreaFieldDef.ref, + SelectFieldDef.ref, + ToggleFieldDef.ref, + DividerDef.ref, + ]), + ), + subtitle: z.string().optional(), + variant: z.enum(["default", "glass", "accent"]).optional(), + highlight: z.string().optional(), + }), + description: "Card container with title and children.", + component: null, +}); + +const StackDef = defineComponent({ + name: "Stack", + props: z.object({ + children: z.array( + z.union([ + CardDef.ref, + TextContentDef.ref, + ButtonDef.ref, + ChartDef.ref, + BadgeDef.ref, + KpiTileDef.ref, + MetricListDef.ref, + TimelineDef.ref, + InputFieldDef.ref, + TextAreaFieldDef.ref, + SelectFieldDef.ref, + ToggleFieldDef.ref, + DividerDef.ref, + ]), + ), + }), + description: "Dashboard layout container. Use as root.", + component: null, +}); + +const library = createLibrary({ + components: [ + TextContentDef, + ButtonDef, + ChartDef, + BadgeDef, + KpiTileDef, + MetricListDef, + TimelineDef, + InputFieldDef, + TextAreaFieldDef, + SelectFieldDef, + ToggleFieldDef, + DividerDef, + CardDef, + StackDef, + ], + root: "Stack", +}); + +const promptOptions = { + additionalRules: [ + "Always use Stack as the root component.", + "The FIRST line must be exactly a root assignment: root = Stack(...).", + "Do not output any statement before root = Stack(...).", + "Use Stack only as root. Never place Stack inside Card children.", + "Output openui-lang code only. No markdown fences, no explanations, no think blocks.", + "All visible text must be English.", + "Start directly with openui-lang code. Do not add preface text.", + "Keep syntax valid while streaming: no TODO bullets, no pseudo-code, no planning sections.", + "Match the requested UI type; do not force dashboard layouts for every request.", + "Use references for readability and better streaming behavior.", + "Prefer Card subtitle/highlight only when it improves hierarchy.", + "Use Badge, MetricList, Timeline, Chart, and KpiTile when relevant.", + "Use InputField, TextAreaField, SelectField, ToggleField, and Divider for form or settings UIs.", + 'Use state bindings in forms when needed (e.g. InputField("Email", "name@example.com", $email, "email")).', + "Button action supports both simple action type strings and structured action objects.", + "Use positional arguments only.", + "Prefer inline composition in Stack/Card to reduce forward-reference errors.", + "Never output reasoning or markdown.", + ], + examples: [ + `User: Build a weekly product status view + +root = Stack([title, summary, panel, action]) +title = TextContent("Product Delivery Overview", "strong") +summary = TextContent("Current sprint highlights, blockers, and next actions.", "muted") +state = Badge("On Track", "success") +velocity = Chart("Story Points", "bar", ["W1", "W2", "W3", "W4"], [28, 31, 35, 33], "pts") +burn = Chart("Defect Trend", "line", ["W1", "W2", "W3", "W4"], [14, 11, 9, 7], "count") +metrics = MetricList("Team Metrics", [{"label":"Cycle time", "value":"3.2d"}, {"label":"PR review", "value":"6.8h"}, {"label":"Escaped bugs", "value":"2"}]) +steps = Timeline("Release Steps", [{"title":"Scope lock", "detail":"Done", "status":"done"}, {"title":"UAT", "detail":"In progress", "status":"active"}, {"title":"Launch", "detail":"Scheduled Friday", "status":"next"}]) +panel = Card("Delivery Health", [state, velocity, burn, metrics, steps], "Sprint 18 status", "glass", "Updated") +action = Button("Generate rollback checklist", "continue-conversation", "primary") +`, + ], +}; + +const prompt = library.prompt(promptOptions); +const outPath = resolve(__dirname, "../generated/system-prompt.txt"); +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, prompt, "utf-8"); +console.log(`Generated system prompt (${prompt.length} chars) -> ${outPath}`); diff --git a/examples/solid-chat/src/App.tsx b/examples/solid-chat/src/App.tsx new file mode 100644 index 000000000..0e49955f4 --- /dev/null +++ b/examples/solid-chat/src/App.tsx @@ -0,0 +1,712 @@ +import { Field } from "@ark-ui/solid/field"; +import { + BuiltinActionType, + createParser, + Renderer, + type ActionEvent, + type ParseResult, +} from "@openuidev/solid-lang"; +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; +import { library } from "./lib/library"; + +type UserMessage = { role: "user"; text: string }; +type AssistantMessage = { + role: "assistant"; + raw: string; + response: string; + thinking: string; + reasoning: string; + thinkOpen: boolean; + sawThinkTag: boolean; + streaming: boolean; + started: boolean; +}; +type ChatMessage = UserMessage | AssistantMessage; + +const parser = createParser(library.toJSONSchema()); + +export default function App() { + const [messages, setMessages] = createSignal([]); + const [input, setInput] = createSignal(""); + const [isLoading, setIsLoading] = createSignal(false); + const [showRawPanel, setShowRawPanel] = createSignal(false); + const [rawTab, setRawTab] = createSignal<"raw" | "parsed" | "thinking">("raw"); + const [isCompact, setIsCompact] = createSignal(false); + let rawPaneRef: HTMLDivElement | undefined; + let previewPaneRef: HTMLDivElement | undefined; + + const promptStarters = [ + "Weather dashboard for Bandung", + "Pricing comparison cards for 4 plans", + "Kanban board for launch tasks", + "Login form with validation hints", + "Customer support operations view", + ]; + + onMount(() => { + const onResize = () => setIsCompact(window.innerWidth < 980); + onResize(); + window.addEventListener("resize", onResize); + onCleanup(() => window.removeEventListener("resize", onResize)); + }); + + const latestAssistant = createMemo(() => { + const list = messages(); + for (let i = list.length - 1; i >= 0; i -= 1) { + const msg = list[i]; + if (msg?.role === "assistant") return msg; + } + return undefined; + }); + + const parsedResult = createMemo(() => { + const assistant = latestAssistant(); + if (!assistant?.response) return null; + try { + return parser.parse(assistant.response); + } catch { + return null; + } + }); + + createEffect(() => { + const assistant = latestAssistant(); + if (!assistant || !assistant.streaming) return; + requestAnimationFrame(() => { + if (rawPaneRef) rawPaneRef.scrollTo({ top: rawPaneRef.scrollHeight }); + if (previewPaneRef) previewPaneRef.scrollTo({ top: previewPaneRef.scrollHeight }); + }); + }); + + async function sendMessage(text: string) { + const trimmed = text.trim(); + if (!trimmed || isLoading()) return; + + setIsLoading(true); + setMessages((prev) => [...prev, { role: "user", text: trimmed }]); + setInput(""); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + raw: "", + response: "", + thinking: "", + reasoning: "", + thinkOpen: false, + sawThinkTag: false, + streaming: true, + started: false, + }, + ]); + + const rootAssignRegex = /\broot\s*=\s*[A-Za-z_][A-Za-z0-9_]*\s*\(/; + const openUiStartRegex = /[A-Za-z_][A-Za-z0-9_]*\s*=\s*[A-Za-z_][A-Za-z0-9_]*\s*\(/; + + const sanitizeModelText = (value: string) => + value + .replace(/[\s\S]*?<\/think>/gi, "") + .replace(/<\/?think>/gi, "") + .replace(/```openui-lang/gi, "") + .replace(/```/g, ""); + + const joinThinking = (reasoning: string, prelude: string) => + [reasoning.trim(), prelude.trim()].filter((part) => part.length > 0).join("\n\n"); + + const extractLiveThinkFromRaw = (rawText: string) => { + const openTag = ""; + const closeTag = ""; + const lastOpen = rawText.lastIndexOf(openTag); + const lastClose = rawText.lastIndexOf(closeTag); + if (lastOpen < 0 || lastOpen < lastClose) return { thinkOpen: false, thinking: "" }; + const thinking = rawText.slice(lastOpen + openTag.length).trim(); + return { thinkOpen: true, thinking }; + }; + + const extractOpenUi = (rawText: string) => { + const sanitizedRaw = sanitizeModelText(rawText); + const rootIndex = sanitizedRaw.search(rootAssignRegex); + const anyAssignIndex = sanitizedRaw.search(openUiStartRegex); + const startIndex = + anyAssignIndex >= 0 && rootIndex >= 0 + ? Math.min(anyAssignIndex, rootIndex) + : anyAssignIndex >= 0 + ? anyAssignIndex + : rootIndex; + const prelude = startIndex >= 0 ? sanitizedRaw.slice(0, startIndex) : sanitizedRaw; + const response = startIndex >= 0 ? sanitizedRaw.slice(startIndex) : ""; + return { prelude, response, started: response.trim().length > 0 }; + }; + + const appendAssistantDelta = (delta: string) => { + if (!delta) return; + setMessages((prev) => { + const next = [...prev]; + for (let i = next.length - 1; i >= 0; i -= 1) { + const msg = next[i]; + if (msg?.role === "assistant" && msg.streaming) { + const raw = msg.raw + delta; + const extracted = extractOpenUi(raw); + const sawThinkTag = + msg.sawThinkTag || raw.includes("") || raw.includes(""); + const liveThink = extractLiveThinkFromRaw(raw); + const thinking = sawThinkTag + ? liveThink.thinkOpen + ? liveThink.thinking + : "" + : joinThinking(msg.reasoning, extracted.prelude); + next[i] = { + ...msg, + raw, + response: extracted.response, + thinking, + thinkOpen: sawThinkTag ? liveThink.thinkOpen : msg.reasoning.trim().length > 0, + sawThinkTag, + started: msg.started || extracted.started, + }; + break; + } + } + return next; + }); + }; + + const appendThinkingDelta = (delta: string) => { + if (!delta) return; + setMessages((prev) => { + const next = [...prev]; + for (let i = next.length - 1; i >= 0; i -= 1) { + const msg = next[i]; + if (msg?.role === "assistant" && msg.streaming) { + const reasoning = msg.reasoning + delta; + const extracted = extractOpenUi(msg.raw || ""); + const currentRaw = msg.raw || ""; + const sawThinkTag = + msg.sawThinkTag || currentRaw.includes("") || currentRaw.includes(""); + const liveThink = extractLiveThinkFromRaw(currentRaw); + const thinking = sawThinkTag + ? liveThink.thinkOpen + ? liveThink.thinking + : "" + : joinThinking(reasoning, extracted.prelude); + next[i] = { + ...msg, + reasoning, + thinking, + thinkOpen: sawThinkTag ? liveThink.thinkOpen : reasoning.trim().length > 0, + sawThinkTag, + }; + break; + } + } + return next; + }); + }; + + const finishAssistantStream = (fallbackError?: string) => { + setMessages((prev) => { + const next = [...prev]; + for (let i = next.length - 1; i >= 0; i -= 1) { + const msg = next[i]; + if (msg?.role === "assistant" && msg.streaming) { + const extracted = extractOpenUi(msg.response || msg.raw || ""); + const cleaned = extracted.response.trim(); + const response = + cleaned || + `t1 = TextContent("Error: ${(fallbackError || "Empty response").replaceAll('"', '\\"')}")\ncard = Card("LLM Stream Error", [t1])\nroot = Stack([card])`; + next[i] = { + ...msg, + response, + streaming: false, + thinking: "", + thinkOpen: false, + started: msg.started || cleaned.length > 0, + }; + break; + } + } + return next; + }); + }; + + try { + const response = await fetch("/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ message: trimmed }), + }); + + if (!response.ok || !response.body) { + finishAssistantStream(`HTTP ${response.status} from chat API`); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + + for (const part of parts) { + const lines = part.split("\n"); + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine.startsWith("data:")) continue; + const payload = trimmedLine.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + try { + const parsed = JSON.parse(payload) as { delta?: string; thinkingDelta?: string }; + if (parsed.delta) appendAssistantDelta(parsed.delta); + if (parsed.thinkingDelta) appendThinkingDelta(parsed.thinkingDelta); + } catch { + appendAssistantDelta(payload); + } + } + } + } + + finishAssistantStream(); + } catch (error) { + const message = error instanceof Error ? error.message : "Network error"; + finishAssistantStream(message); + } finally { + setIsLoading(false); + } + } + + function handleAction(event: ActionEvent) { + if (event.type === BuiltinActionType.ContinueConversation && event.humanFriendlyMessage) { + void sendMessage(event.humanFriendlyMessage); + } + } + + const panelColumns = () => { + if (isCompact()) return "minmax(0, 1fr)"; + if (!showRawPanel()) return "minmax(0, 1fr)"; + return "minmax(320px, 0.95fr) minmax(0, 1.4fr)"; + }; + + const paneHeight = () => { + if (isCompact()) return "56vh"; + return "520px"; + }; + + const statusLabel = createMemo(() => { + const assistant = latestAssistant(); + if (!assistant) return "Ready"; + if (assistant.streaming && !assistant.started) return "Streaming"; + if (assistant.streaming && assistant.started) return "Rendering"; + return "Ready"; + }); + + const statusColor = createMemo(() => { + if (statusLabel() === "Streaming" || statusLabel() === "Rendering") return "#2563eb"; + return "#16a34a"; + }); + + return ( +
+
+

+ What do you want to build? +

+ +
{ + event.preventDefault(); + void sendMessage(input()); + }} + style={{ + display: "grid", + gap: "10px", + padding: "12px", + margin: "0 auto", + "max-width": "760px", + border: "1px solid rgba(148,163,184,0.28)", + "border-radius": "14px", + background: "#ffffff", + }} + > + + + setInput(event.currentTarget.value) + } + placeholder="Describe the UI you want to generate..." + style={{ + width: "100%", + padding: "12px", + border: "1px solid #dbe2ea", + "border-radius": "10px", + background: "#ffffff", + "box-sizing": "border-box", + }} + /> + + +
+ + + +
+
+ +
+ + {(starter) => ( + + )} + +
+
+ +
+ +
+
+ + + +
+ +
+ Generated output will appear here.
+ } + > + {(assistant) => ( +
+                    {rawTab() === "raw"
+                      ? assistant().raw || "Waiting for model stream..."
+                      : rawTab() === "thinking"
+                        ? assistant().thinkOpen
+                          ? assistant().thinking || "Thinking..."
+                          : "No active thinking stream."
+                        : parsedResult()
+                          ? JSON.stringify(parsedResult(), null, 2)
+                          : "Parser has no stable result yet."}
+                  
+ )} + +
+ + + +
+
+ PREVIEW + + {statusLabel()} + +
+ +
+ Rendered UI will appear here.
} + > + {(assistant) => ( + 0 || !assistant().streaming} + fallback={ +
+
+
+
Generating UI...
+
+ } + > + 0}> +
+ + + Thinking + +
+                        {assistant().thinking}
+                      
+
+
+ + + )} + +
+
+
+ + + + ); +} + +function rawTabStyle(isActive: boolean) { + return { + padding: "5px 10px", + "font-size": "11px", + "font-weight": 700, + "letter-spacing": "0.04em", + border: isActive ? "1px solid #334155" : "1px solid transparent", + background: isActive ? "rgba(51,65,85,0.6)" : "transparent", + color: isActive ? "#f8fafc" : "#64748b", + "border-radius": "8px", + cursor: "pointer", + } as const; +} diff --git a/examples/solid-chat/src/components/Badge.tsx b/examples/solid-chat/src/components/Badge.tsx new file mode 100644 index 000000000..b2f58fd09 --- /dev/null +++ b/examples/solid-chat/src/components/Badge.tsx @@ -0,0 +1,37 @@ +interface BadgeProps { + label: string; + tone?: "neutral" | "success" | "warning" | "danger" | "info"; +} + +const toneStyles: Record< + NonNullable, + { bg: string; fg: string; border: string } +> = { + neutral: { bg: "#f1f5f9", fg: "#334155", border: "#cbd5e1" }, + success: { bg: "#dcfce7", fg: "#166534", border: "#86efac" }, + warning: { bg: "#fef3c7", fg: "#92400e", border: "#fde68a" }, + danger: { bg: "#fee2e2", fg: "#991b1b", border: "#fecaca" }, + info: { bg: "#dbeafe", fg: "#1e40af", border: "#93c5fd" }, +}; + +export function Badge(props: { props: BadgeProps }) { + const tone = props.props.tone || "neutral"; + const style = toneStyles[tone]; + + return ( + + {props.props.label} + + ); +} diff --git a/examples/solid-chat/src/components/Button.tsx b/examples/solid-chat/src/components/Button.tsx new file mode 100644 index 000000000..898e65486 --- /dev/null +++ b/examples/solid-chat/src/components/Button.tsx @@ -0,0 +1,124 @@ +import { evaluate } from "@openuidev/lang-core"; +import { + BuiltinActionType, + useFormName, + useGetFieldValue, + useTriggerAction, +} from "@openuidev/solid-lang"; +import { Send } from "lucide-solid"; + +interface ButtonProps { + label: string; + action?: unknown; + variant?: "primary" | "secondary" | "ghost"; +} + +interface ActionPlanStep { + type?: string; + message?: string; + context?: string; + url?: string; +} + +interface ActionPlanValue { + steps?: ActionPlanStep[]; +} + +function isActionPlan(value: unknown): value is ActionPlanValue { + return !!value && typeof value === "object" && Array.isArray((value as ActionPlanValue).steps); +} + +function normalizeActionType(type: string): string { + if (type === "continue-conversation") return BuiltinActionType.ContinueConversation; + if (type === "open-url") return BuiltinActionType.OpenUrl; + return type; +} + +export function Button(props: { props: ButtonProps }) { + const triggerAction = useTriggerAction(); + const formName = useFormName(); + const getFieldValue = useGetFieldValue(); + const variant = props.props.variant || "secondary"; + + const variantStyles: Record< + NonNullable, + { bg: string; fg: string; border: string } + > = { + primary: { bg: "#0f172a", fg: "#ffffff", border: "#0f172a" }, + secondary: { bg: "#f8fafc", fg: "#0f172a", border: "#cbd5e1" }, + ghost: { bg: "#ffffff", fg: "#1e40af", border: "#bfdbfe" }, + }; + const style = variantStyles[variant]; + + const resolveActionPlan = () => { + const action = props.props.action; + if (!action || typeof action !== "object") return null; + + try { + const evaluated = evaluate(action as any, { + getState: (name) => getFieldValue(formName?.(), name), + resolveRef: () => null, + }); + return isActionPlan(evaluated) ? evaluated : null; + } catch { + return null; + } + }; + + const handleClick = () => { + const label = props.props.label; + const form = formName?.(); + + if (typeof props.props.action === "string") { + triggerAction(label, form, { type: normalizeActionType(props.props.action) }); + return; + } + + const plan = resolveActionPlan(); + if (!plan?.steps?.length) { + triggerAction(label, form); + return; + } + + for (const step of plan.steps) { + if (step.type === BuiltinActionType.ContinueConversation) { + triggerAction(step.message || label, form, { + type: BuiltinActionType.ContinueConversation, + params: step.context ? { context: step.context } : undefined, + }); + return; + } + if (step.type === BuiltinActionType.OpenUrl && step.url) { + triggerAction(label, form, { + type: BuiltinActionType.OpenUrl, + params: { url: step.url }, + }); + return; + } + } + + triggerAction(label, form); + }; + + return ( + + ); +} diff --git a/examples/solid-chat/src/components/Card.tsx b/examples/solid-chat/src/components/Card.tsx new file mode 100644 index 000000000..cf699d9b1 --- /dev/null +++ b/examples/solid-chat/src/components/Card.tsx @@ -0,0 +1,105 @@ +import { BarChart3 } from "lucide-solid"; +import type { JSX } from "solid-js"; + +interface CardProps { + title: string; + subtitle?: string; + variant?: "default" | "glass" | "accent"; + highlight?: string; + children: unknown[]; +} + +function countCharts(children: unknown[]): number { + return children.filter( + (child) => + child && typeof child === "object" && (child as { typeName?: string }).typeName === "Chart", + ).length; +} + +function isRenderableNode(child: unknown): child is { typeName: string } { + return Boolean( + child && + typeof child === "object" && + typeof (child as { typeName?: unknown }).typeName === "string", + ); +} + +export function Card(props: { props: CardProps; renderNode: (value: unknown) => JSX.Element }) { + const safeChildren = props.props.children.filter(isRenderableNode); + const chartCount = countCharts(safeChildren); + const hasChart = chartCount > 0; + const variant = props.props.variant || "default"; + + const backgroundByVariant: Record, string> = { + default: "linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.96) 100%)", + glass: "linear-gradient(160deg, rgba(239,246,255,0.86) 0%, rgba(255,255,255,0.8) 100%)", + accent: "linear-gradient(160deg, #eff6ff 0%, #f8fafc 100%)", + }; + + return ( +
+
+
+ +
+

+ {props.props.title} +

+ {props.props.subtitle ? ( +

+ {props.props.subtitle} +

+ ) : null} +
+
+ {props.props.highlight ? ( + + {props.props.highlight} + + ) : null} +
+
+ {safeChildren.map((child) => ( +
{props.renderNode(child)}
+ ))} +
+
+ ); +} diff --git a/examples/solid-chat/src/components/Chart.tsx b/examples/solid-chat/src/components/Chart.tsx new file mode 100644 index 000000000..869b65331 --- /dev/null +++ b/examples/solid-chat/src/components/Chart.tsx @@ -0,0 +1,101 @@ +import type { ECharts } from "echarts"; +import * as echarts from "echarts"; +import { createEffect, onCleanup, onMount } from "solid-js"; + +interface ChartProps { + title: string; + type: "bar" | "line" | "pie" | "doughnut"; + labels: string[]; + values: number[]; + datasetLabel?: string; +} + +export function Chart(props: { props: ChartProps }) { + let chartEl: HTMLDivElement | undefined; + let chart: ECharts | undefined; + + function buildOption() { + const label = props.props.datasetLabel || "Value"; + if (props.props.type === "pie" || props.props.type === "doughnut") { + return { + color: ["#2563eb", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4"], + tooltip: { trigger: "item" }, + series: [ + { + type: "pie", + radius: props.props.type === "doughnut" ? ["45%", "70%"] : "70%", + label: { color: "#334155" }, + data: props.props.labels.map((name, i) => ({ + name, + value: props.props.values[i] ?? 0, + })), + }, + ], + }; + } + + return { + color: ["#2563eb", "#22c55e", "#8b5cf6"], + tooltip: { trigger: "axis" }, + xAxis: { + type: "category", + data: props.props.labels, + axisLabel: { color: "#475569" }, + axisLine: { lineStyle: { color: "#cbd5e1" } }, + }, + yAxis: { + type: "value", + name: label, + axisLabel: { color: "#475569" }, + splitLine: { lineStyle: { color: "rgba(148,163,184,0.25)" } }, + }, + series: [ + { + type: props.props.type, + data: props.props.values, + smooth: props.props.type === "line", + areaStyle: props.props.type === "line" ? { opacity: 0.12 } : undefined, + }, + ], + grid: { left: 56, right: 20, top: 24, bottom: 28, containLabel: true }, + }; + } + + onMount(() => { + if (!chartEl) return; + chart = echarts.init(chartEl); + chart.setOption(buildOption()); + const resize = () => chart?.resize(); + window.addEventListener("resize", resize); + onCleanup(() => { + window.removeEventListener("resize", resize); + chart?.dispose(); + }); + }); + + createEffect(() => { + if (!chart) return; + chart.setOption(buildOption(), true); + }); + + return ( +
+
+ {props.props.title} ({props.props.type}) +
+
+
+ ); +} diff --git a/examples/solid-chat/src/components/Divider.tsx b/examples/solid-chat/src/components/Divider.tsx new file mode 100644 index 000000000..814b0f46b --- /dev/null +++ b/examples/solid-chat/src/components/Divider.tsx @@ -0,0 +1,36 @@ +interface DividerProps { + label?: string; +} + +export function Divider(props: { props: DividerProps }) { + if (!props.props.label) { + return ( +
+ ); + } + + return ( +
+ + + {props.props.label} + + +
+ ); +} diff --git a/examples/solid-chat/src/components/InputField.tsx b/examples/solid-chat/src/components/InputField.tsx new file mode 100644 index 000000000..8e42a899a --- /dev/null +++ b/examples/solid-chat/src/components/InputField.tsx @@ -0,0 +1,56 @@ +import { useFormName, useGetFieldValue, useSetFieldValue } from "@openuidev/solid-lang"; +import { createMemo } from "solid-js"; + +interface InputFieldProps { + label: string; + placeholder?: string; + value?: unknown; + type?: "text" | "email" | "password" | "number" | "url"; +} + +function getStateRefName(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const node = value as { k?: string; n?: string }; + return node.k === "StateRef" && typeof node.n === "string" ? node.n : null; +} + +export function InputField(props: { props: InputFieldProps }) { + const formName = useFormName(); + const getFieldValue = useGetFieldValue(); + const setFieldValue = useSetFieldValue(); + + const bindingName = createMemo(() => getStateRefName(props.props.value)); + const fieldValue = createMemo(() => { + const name = bindingName(); + if (name) return (getFieldValue(formName?.(), name) ?? "") as string; + return (props.props.value as string | undefined) ?? ""; + }); + + return ( + + ); +} diff --git a/examples/solid-chat/src/components/KpiTile.tsx b/examples/solid-chat/src/components/KpiTile.tsx new file mode 100644 index 000000000..cff426e41 --- /dev/null +++ b/examples/solid-chat/src/components/KpiTile.tsx @@ -0,0 +1,40 @@ +interface KpiTileProps { + label: string; + value: string; + delta?: string; + trend?: "up" | "down" | "neutral"; +} + +const trendColor: Record, string> = { + up: "#15803d", + down: "#b91c1c", + neutral: "#334155", +}; + +export function KpiTile(props: { props: KpiTileProps }) { + const trend = props.props.trend || "neutral"; + return ( +
+
+ {props.props.label} +
+
+ {props.props.value} +
+ {props.props.delta ? ( +
+ {props.props.delta} +
+ ) : null} +
+ ); +} diff --git a/examples/solid-chat/src/components/MetricList.tsx b/examples/solid-chat/src/components/MetricList.tsx new file mode 100644 index 000000000..30d72ba33 --- /dev/null +++ b/examples/solid-chat/src/components/MetricList.tsx @@ -0,0 +1,61 @@ +interface MetricItem { + label: string; + value: string; +} + +interface MetricListProps { + title: string; + items: MetricItem[]; +} + +export function MetricList(props: { props: MetricListProps }) { + return ( +
+
+ {props.props.title} +
+
+ {props.props.items.map((item, idx) => ( +
+ {item.label} + + {item.value} + +
+ ))} +
+
+ ); +} diff --git a/examples/solid-chat/src/components/SelectField.tsx b/examples/solid-chat/src/components/SelectField.tsx new file mode 100644 index 000000000..cdaef9399 --- /dev/null +++ b/examples/solid-chat/src/components/SelectField.tsx @@ -0,0 +1,57 @@ +import { useFormName, useGetFieldValue, useSetFieldValue } from "@openuidev/solid-lang"; +import { createMemo } from "solid-js"; + +interface SelectFieldProps { + label: string; + options: string[]; + selected?: unknown; +} + +function getStateRefName(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const node = value as { k?: string; n?: string }; + return node.k === "StateRef" && typeof node.n === "string" ? node.n : null; +} + +export function SelectField(props: { props: SelectFieldProps }) { + const formName = useFormName(); + const getFieldValue = useGetFieldValue(); + const setFieldValue = useSetFieldValue(); + + const bindingName = createMemo(() => getStateRefName(props.props.selected)); + const selectedValue = createMemo(() => { + const name = bindingName(); + if (name) return (getFieldValue(formName?.(), name) ?? props.props.options[0] ?? "") as string; + return (props.props.selected as string | undefined) ?? props.props.options[0] ?? ""; + }); + + return ( + + ); +} diff --git a/examples/solid-chat/src/components/Stack.tsx b/examples/solid-chat/src/components/Stack.tsx new file mode 100644 index 000000000..7cce03b55 --- /dev/null +++ b/examples/solid-chat/src/components/Stack.tsx @@ -0,0 +1,72 @@ +import { createSignal, onCleanup, onMount, type JSX } from "solid-js"; + +interface StackProps { + children: unknown[]; +} + +function getTypeName(value: unknown): string | undefined { + if (!value || typeof value !== "object") return undefined; + return (value as { typeName?: string }).typeName; +} + +export function Stack(props: { props: StackProps; renderNode: (value: unknown) => JSX.Element }) { + const [isCompact, setIsCompact] = createSignal(false); + const childTypes = props.props.children.map(getTypeName); + const isActionRow = childTypes.length > 0 && childTypes.every((name) => name === "Button"); + + const spanByType: Record = { + TextContent: "1 / -1", + Stack: "1 / -1", + Card: "span 6", + Chart: "span 6", + KpiTile: "span 3", + MetricList: "span 6", + Timeline: "span 6", + InputField: "span 6", + TextAreaField: "span 6", + SelectField: "span 4", + ToggleField: "span 4", + Divider: "1 / -1", + Badge: "span 2", + Button: "span 3", + }; + + onMount(() => { + const onResize = () => setIsCompact(window.innerWidth < 900); + onResize(); + window.addEventListener("resize", onResize); + onCleanup(() => window.removeEventListener("resize", onResize)); + }); + + return ( +
+ {props.props.children.map((child) => ( +
+ {props.renderNode(child)} +
+ ))} +
+ ); +} diff --git a/examples/solid-chat/src/components/TextAreaField.tsx b/examples/solid-chat/src/components/TextAreaField.tsx new file mode 100644 index 000000000..2907fa472 --- /dev/null +++ b/examples/solid-chat/src/components/TextAreaField.tsx @@ -0,0 +1,58 @@ +import { useFormName, useGetFieldValue, useSetFieldValue } from "@openuidev/solid-lang"; +import { createMemo } from "solid-js"; + +interface TextAreaFieldProps { + label: string; + placeholder?: string; + value?: unknown; + rows?: number; +} + +function getStateRefName(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const node = value as { k?: string; n?: string }; + return node.k === "StateRef" && typeof node.n === "string" ? node.n : null; +} + +export function TextAreaField(props: { props: TextAreaFieldProps }) { + const formName = useFormName(); + const getFieldValue = useGetFieldValue(); + const setFieldValue = useSetFieldValue(); + + const bindingName = createMemo(() => getStateRefName(props.props.value)); + const fieldValue = createMemo(() => { + const name = bindingName(); + if (name) return (getFieldValue(formName?.(), name) ?? "") as string; + return (props.props.value as string | undefined) ?? ""; + }); + + return ( +