A framework-agnostic web component that renders LLM-generated UI from
Streaming UI Script — a compact, declarative language designed for chat
assistants. Drop one <script> tag and one <streaming-ui-script> tag into
any HTML page — React, Vue, Angular, Svelte, plain HTML, or no framework at
all — and you have a streaming, interactive renderer for an LLM's response.
- Live docs: https://asfand-dev.github.io/streaming-ui-script/
- Live examples catalog (chat, dashboards, commerce, inbox, CRM, pricing, routing, status, checkout, files, calendar, docs…): https://asfand-dev.github.io/streaming-ui-script/live-examples.html
- CDN bundle (ESM): https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js
- System prompt (full): https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txt
- System prompt (chat): https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt_chat.txt
The library bundles everything needed at runtime:
- A Streaming UI Script parser (line-oriented, streaming-first, error-tolerant) with single-, double-, and backtick-quoted strings.
- An evaluator with reactive state, queries, mutations, actions, and 20+ built-in functions (
@Count,@Filter,@Sort,@Push,@Concat,@Each,@Sum,@Avg,@Min,@Max,@Round,@Floor,@Ceil,@Abs, …) plus array shortcuts (.length,.first,.last, and field pluck like$rows.title). - A React-like DOM reconciler that diffs each re-render against the live DOM — text-input value, selection, IME state, scroll positions,
<details>.open, and stateful primitives likeTabsare all preserved across renders. Components that need to hold UI state get ahelpers.useInstanceState(...)slot keyed by their position in the tree. - A rich component library of 100+ components — layout, content, forms (including
Slider,NumberInput,DatePicker,FileUpload,Combobox), tables, charts, feedback & media (Avatar,Progress,Tooltip,HoverCard,Popover,Toast,Toasts,Rating,ProgressRing,ChatBubble, …), navigation (Breadcrumb,Pagination,Navbar,NavbarItem), menus (DropdownMenu,MenuItem,MenuSeparator,MenuLabel), hierarchical data (Tree,TreeNode), chat composites, high-level pattern composites (Hero,Cover,PageHeader,MetricGrid,Toolbar,EmptyState,Timeline,KanbanBoard,Testimonial,PricingTable,MediaCard, …) and app-shell composites (AppShell,Sidebar,SplitView) that render a full SaaS layout in one statement. - A built-in JavaScript layer —
Script(...)(lifecycle-managed,useEffect-style) and@Js(body, args?)(one-shot click handlers with per-item arg capture). Always available. - A built-in routing layer —
Routes(...),Route(path, content),NavLink(label, to),@Navigate("/path"), and reactive$route+params. Hash-based, framework-agnostic, always on. - Seven built-in themes (
light,dark,neon,pastel,glass,brutalist,skyline) plus full custom-token support via CSS custom properties. - A system prompt generator that emits a clean, ordered prompt teaching the LLM exactly which components, builtins, and tools are available. The build emits two flavours:
system_prompt.txt(full — every feature) andsystem_prompt_chat.txt(compact — only the surfaces a chat reply needs).
Everything lives inside a Shadow DOM, so the renderer's styles never leak into your application — and your application's styles never leak into the renderer.
LLMs are great at writing structured text, and a small DSL lets them describe a full UI in 60–70% fewer tokens than JSON. This project ships that idea as a single web component, so any framework — or no framework at all — can render generative UI without extra wiring.
<script type="module" src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js"></script>For non-module setups use the IIFE build:
<script src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.iife.js" defer></script>The CSS is bundled inside the JS and injected into each instance's shadow root, so you do not need a separate stylesheet.
<streaming-ui-script id="reply" theme="light"></streaming-ui-script>There are three equivalent ways to set the program text:
<!-- as an attribute -->
<streaming-ui-script response='root = Card([CardHeader("Hi")])'></streaming-ui-script>
<!-- as inner text -->
<streaming-ui-script>
root = Card([CardHeader("Hi")])
</streaming-ui-script>
<!-- as a property / method -->
<script>
const el = document.querySelector("streaming-ui-script");
el.setResponse(`root = Stack([greeting])
greeting = Card([CardHeader("Hello", "Generative UI in plain HTML")])`);
</script>const response = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ system: systemPrompt, messages }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
el.streaming = true;
el.clear();
while (true) {
const { value, done } = await reader.read();
if (done) break;
el.appendChunk(decoder.decode(value, { stream: true }));
}
el.streaming = false;Either fetch the auto-generated system_prompt.txt from the CDN:
const systemPrompt = await fetch(
"https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txt",
).then((r) => r.text());…or build a richer prompt programmatically (with custom rules, tool descriptions, examples, etc.):
const prompt = el.getSystemPrompt({
preamble: "You are an analytics assistant.",
additionalRules: ["Always end with a FollowUpBlock of 2 prompts."],
tools: [{ name: "list_orders", description: "Return recent orders.", argsExample: { limit: 10 } }],
});el.setTools({
list_orders: async ({ limit }) => fetch(`/api/orders?limit=${limit}`).then(r => r.json()),
update_order: async ({ id, status }) => fetch(`/api/orders/${id}`, { method: "PATCH", body: JSON.stringify({ status }) }).then(r => r.json()),
});el.addEventListener("assistant-message", (event) => {
appendUserMessageToChat(event.detail.message);
});All members live on the <streaming-ui-script> element.
| Attribute | Values | Description |
|---|---|---|
theme |
light, dark, or a JSON object literal |
Switches the theme. JSON objects are merged with the default light token map. |
streaming |
true / unset |
Hint that text is still being appended. Useful for status indicators in your app. |
response |
Streaming UI Script text | Sets the program declaratively. Re-renders whenever the attribute changes. |
showerrors |
true / unset |
If present and true, displays parse errors in the rendered UI. Defaults to off. |
Script(...) / @Js(...) and hash-based routing (Routes, Route, NavLink, @Navigate) are always on. To skip them in the generated prompt, build the chat-flavoured prompt via getSystemPrompt({ mode: "chat" }).
| Property | Type | Description |
|---|---|---|
response |
string |
Equivalent to setResponse. |
tools |
Record<string, Function> |
Getter returns the currently-registered tools; setter is equivalent to setTools(...). |
streaming |
boolean |
Reflects the streaming attribute. |
showErrors |
boolean |
Reflects the showerrors attribute. |
route |
string (read-only) |
Current path tracked by the router (e.g. "/users/42"). |
| Method | Description |
|---|---|
setResponse(text) |
Replace the program (one-shot rendering). Resets state and queries. |
appendChunk(chunk) |
Append a streaming chunk and re-render. |
clear() |
Reset state, queries, and the rendered output. |
setTheme(name | tokens) |
Apply a built-in theme by name or a partial token map. |
setTools(tools) |
Register tools used by Query() and Mutation(). |
registerComponents(specs, root?) |
Extend the built-in library with your own components. |
getSystemPrompt(options?) |
Build a system prompt that matches the current library and tools. |
navigate(path) |
Programmatically navigate when routing is enabled (updates window.location.hash). |
| Event | Detail | When it fires |
|---|---|---|
assistant-message |
{ message: string } |
When @ToAssistant("...") runs (e.g. a follow-up button). |
error |
{ errors: ParseError[] } |
After each render whose source had parse errors. |
route-change |
{ path, previousPath, params, pattern } |
When the current hash path changes. |
The error event always fires regardless of showerrors, so host apps can
log or report errors even when the in-page banner is suppressed.
Seven themes are built in. Pick one with theme="..." or pass a custom token map.
| Theme | Vibe |
|---|---|
light |
Crisp default, indigo accent. |
dark |
Standard dark surface, indigo accent. |
neon |
Cyberpunk-inspired dark mode with magenta/cyan glow, monospace headings, sharp corners. |
pastel |
Soft, friendly, light & rounded. Lavender + mint palette, generous radii, gentle shadows. |
glass |
Modern glassmorphism — vivid gradient backdrop, frosted translucent surfaces, indigo→cyan accent. |
brutalist |
Neo-brutalism — hard 2px black borders, chunky offset shadows, loud primary, zero gradients. |
skyline |
Enterprise cloud-console aesthetic — deep navy primary, cyan accents, calm pale blue bg. |
Custom token maps:
el.setTheme({
colorPrimary: "#16a34a",
colorPrimaryHover: "#15803d",
colorBg: "#f0fdf4",
radiusMd: "14px",
});You can also style the host element from outside:
streaming-ui-script {
--rui-color-primary: #16a34a;
--rui-radius-md: 14px;
}A full list of tokens lives in docs/themes.html and src/theme/index.ts.
The runtime auto-loads Font Awesome 6.7.2
from the public CDN — once into document.head and once into each instance's
shadow root. Host apps do not need to add a stylesheet.
- Icon strings are Font Awesome names without the
fa-prefix:"house","chart-line","star","cart-shopping","circle-check","triangle-exclamation","sack-dollar". - Optional variant prefix:
"regular:star","brands:github". The default variant issolid. - Use the dedicated
Icon(name, variant?, size?)component to render a standalone glyph (size∈xs,sm,md,lg,xl). - Every component prop named
icon—NavLink,SidebarItem,Banner,Notification,FeatureItem,Tag,StatCard,ListItem,TimelineItem,DescriptionItem,Tile,EmptyState, … — now expects a Font Awesome name (or avariant:namestring). ProgressRing(value, max?, label?, …)renderslabelas an icon when it resolves to a Font Awesome name (e.g."circle-check"), and as plain text otherwise — perfect for completion rings.- Invisible Unicode glyph modifiers (variation selectors, ZWJ) are stripped
silently so
"triangle-exclamation\uFE0F"(a legacy emoji leftover) still resolves to the proper icon instead of falling back to inline text.
brandIcon = Icon("rocket", "solid", "lg")
homeIcon = Icon("house")
profileTab = NavLink("Profile", "/profile", "ghost", false, "user")
kpis = MetricGrid([
StatCard("Revenue", "$48k", "up", "+12%", "sack-dollar"),
StatCard("Orders", "1,284","up", "+8%", "cart-shopping"),
StatCard("Refunds", "12", "down", "-3", "rotate-left")
])
The CDN URL and a tiny ensureFontAwesomeLoaded(shadow) helper live in
src/icons/index.ts and are re-exported as
FONT_AWESOME_CDN_URL. The custom element calls the helper from its
connectedCallback (idempotent — safe to mount multiple instances).
$days = "7"
data = Query("get_metrics", {days: $days}, {events: 0, daily: []})
filter = FormControl("Range", Select("days", [SelectItem("7","7d"), SelectItem("30","30d")], null, null, $days))
kpi = StatCard("Events", "" + data.events, "up")
chart = LineChart(data.daily.day, [Series("Events", data.daily.events)])
root = Stack([CardHeader("Analytics"), filter, kpi, chart])
Highlights:
- One statement per line:
name = Expression. $variablesare reactive — passing one to an Input or Select two-way-binds.- Strings come in three flavours:
"double",'single', and`backtick`(multi-line, no escaping required — perfect for JS bodies). - Comments are stripped silently:
// line,# line(shell-style), and/* block */. Query("tool", {args}, {defaults}, refreshSec?)runs immediately and re-runs when its$variableargs change.Mutation("tool", {...})only runs from@Run(name)inside anAction([...]).@Each(arr, "row", template)iterates inline. The loop variable is scoped strictly totemplate.@Filter,@Sort,@Count,@Sum,@Avg,@Round,@Push,@Concatand more are available.- Array shortcuts:
$rows.length,$rows.first,$rows.last, plus pluck ($rows.title→[title1, title2, …]). - Forward references are allowed — list
root = Stack([...])first and let the children stream in beneath it.
$todos = [{id: 1, text: "Welcome — try editing", done: false}]
$draft = ""
addBtn = Button("Add", Action([
@Set($todos, @Push($todos, {id: $todos.length + 1, text: $draft, done: false})),
@Reset($draft)
]), "primary")
row = Card([Stack([
TextContent(t.text),
Button("Delete", Action([@Set($todos, @Filter($todos, "id", "!=", t.id))]), "ghost")
])])
list = @Each($todos, "t", row)
root = Stack([Input("draft-input", "What needs doing?", "text", null, $draft), addBtn, list])
The full reference is on the docs site (docs/language.html).
Script(...) and @Js(...) ship with every renderer — no attribute to flip.
The LLM can author two surfaces whenever the full prompt is in use:
Script("id", body, deps?)— behaviour-only node that runs on mount and re-runs whenever any listed$variablechanges. Lifecycle matchesuseEffect: cleanup before re-run, disposal on unmount, AbortSignal exposed asctx.signal.@Js(body, args?)— action step you drop insideAction([...]). The optionalargsobject is evaluated at render time and exposed inside the body asctx.args. This is the canonical way to feed per-row data from an@Eachloop into a click handler.
<streaming-ui-script></streaming-ui-script>list = @Each($todos, "t", row)
row = Card([Stack([
TextContent(t.text),
Button("Toggle", Action([
@Js(`
const todos = ctx.state.get('todos') || [];
ctx.state.set('todos', todos.map(x => x.id === ctx.args.id ? Object.assign({}, x, {done: !x.done}) : x));
`, {id: t.id})
]))
])])
body is a regular string. Use double quotes for one-liners (escape inner
" as \" and newlines as \n) or backticks for multi-line code (real
newlines and unescaped " are fine). The default (full) system prompt
teaches the LLM about these features; for chat-style replies where you want
to keep the LLM purely declarative, build the prompt via
getSystemPrompt({ mode: "chat" }) — the chat flavour omits the JS section.
See the JavaScript interactions guide
or the deeper coding-gen-skill.md for a full
end-to-end app walkthrough.
Hash-based routing is built into the runtime. The LLM may emit routes that
stay in sync with the URL (#/dashboard, #/users/42). Browser
back/forward, bookmarks, and deep links all work — and the host page never
reloads.
<streaming-ui-script></streaming-ui-script>root = Stack([nav, main])
nav = Stack([
NavLink("Home", "/", "ghost", true),
NavLink("Dashboard","/dashboard", "ghost"),
NavLink("Users", "/users", "ghost")
], "row", "s")
main = Routes([
Route("/", homePage),
Route("/dashboard", dashboardPage),
Route("/users/:id", userPage),
Route("*", notFoundPage)
], "/")
homePage = Card([CardHeader("Welcome")])
dashboardPage = Card([CardHeader("Dashboard")])
userPage = Card([CardHeader("User " + params.id)])
notFoundPage = Callout("warning", "Not found", "We couldn't find " + $route + ".")
Routes(items, default?)picks the matchingRoutebased on the current hash path. First match wins;defaultis the fallback when nothing else matches.Route(path, content)supports literal segments ("/about"), parameter segments ("/users/:id"→params.id), and trailing wildcards ("/docs/*"→params._).NavLink(label, to, variant?, exact?, icon?)is a router-aware anchor that intercepts clicks and reflectsdata-active="true"for the current path.@Navigate("/path")is an action step you can drop into anyAction([...])for programmatic navigation. From JS, callel.navigate("/path").- The current path is exposed reactively as
$route. Inside a matched route's content, the captured params land in theparamsloop variable.
The default (full) system prompt teaches the LLM about routing. To skip the
routing section (e.g. for chat-style replies where you don't want the model
inventing URLs), build the prompt via getSystemPrompt({ mode: "chat" }).
See the routing guide and the live routing demo for a full end-to-end walkthrough.
| Group | Components |
|---|---|
| Layout | Stack, Grid, Section, Container, Spacer, Card, CardHeader, CardBody, CardFooter, Divider, Separator, Tabs, TabItem, Accordion, AccordionItem, Modal, Sheet, Steps, StepsItem, AspectRatio, ScrollArea |
| Content | TextContent, Header, Image, Icon, Link, Badge, Tag, TagBlock, Alert, Callout, Note, Quote, CodeBlock, Skeleton, Markdown, Kbd |
| Forms | Form, FormControl, Input, TextArea, Select, SelectItem, Checkbox, CheckBoxGroup, CheckBoxItem, Radio, Switch, Toggle, ToggleGroup, Button, Buttons, SearchBar, Slider, NumberInput, DatePicker, FileUpload, Combobox |
| Data | Table, Col, List, ListItem, StatCard, Stats, Tile, Progress, ProgressRing, Pagination, Tree, TreeNode |
| Charts | BarChart, LineChart, PieChart, Series |
| Feedback & Media | Avatar, AvatarGroup, PersonChip, Tooltip, HoverCard, Popover, Rating, Toast, Toasts |
| Navigation | Breadcrumb, BreadcrumbItem, Navbar, NavbarItem |
| Menus | DropdownMenu, MenuItem, MenuSeparator, MenuLabel |
| Chat | SectionBlock, ListBlock, FollowUpBlock, FollowUpItem, ActionLink, ChatBubble |
| Patterns | Hero, Cover, PageHeader, SectionHeader, MetricGrid, Toolbar, EmptyState, Timeline, TimelineItem, FeatureGrid, FeatureItem, MediaCard, Testimonial, ProfileCard, Comment, Banner, Notification, KanbanBoard, KanbanColumn, KanbanCard, DescriptionList, DescriptionItem, StatusDot, PricingTable, PricingCard |
| App shell | AppShell, Sidebar, SidebarSection, SidebarItem, SplitView |
| Scripting | Script (always available; chat prompt mode omits it) |
| Routing | Routes, Route, NavLink (always available; chat prompt mode omits them) |
The Patterns group is the secret sauce for getting beautiful, dense UI out
of an LLM with minimal tokens. Each composite packs an entire idiom (hero,
page header, KPI strip, empty state, timeline, kanban, …) into one positional
call. Reach for these before composing the equivalent layout from
Card + Stack:
root = Stack([dashHeader, kpis, board, follow])
dashHeader = PageHeader("Engineering Q3", "12 active · 4 at risk", ["Workspace", "Engineering"], dashActions, Badge("On track", "success"))
dashActions = [Button("Export", Action([@Run(export_q3)]), "secondary"), Button("New project", Action([@Run(new_project)]), "primary")]
kpis = MetricGrid([StatCard("Active", "12", "flat"), StatCard("At risk", "4", "up", "+2"), StatCard("Shipped", "8", "up", "+3"), StatCard("On-time", "87%", "down", "-3%")])
board = KanbanBoard([
KanbanColumn("To do", [KanbanCard("Migrate auth", "Roll out new SDK.", ["auth"], "Asha")]),
KanbanColumn("Doing", [KanbanCard("Streaming UI v2", "20 new components.", ["frontend"], "Alex", "primary")]),
KanbanColumn("Review", [KanbanCard("Mobile onboarding", "Awaiting design.", ["mobile"], "Wren", "warning")]),
KanbanColumn("Done", [KanbanCard("Activity timeline", "Shipped to 100%.", ["shipped"], "Mira", "success")])
])
follow = FollowUpBlock(["Show at-risk projects", "Compare to Q2", "Who needs help?"])
The generated system prompt teaches the LLM about every component, and the
## Design principles + ## Composition recipes sections steer the model
toward dashboard / landing / detail-page layouts that look like a shadcn site
out of the box.
Add your own with registerComponents:
const ProductCard = {
name: "ProductCard",
description: "Product tile with title and price.",
props: [
{ name: "title", type: "string" },
{ name: "price", type: "number" },
],
render: (_node, props) => {
const div = document.createElement("div");
div.textContent = `${props.title} — $${props.price}`;
return div;
},
};
el.registerComponents([ProductCard]);The next call to getSystemPrompt() automatically includes the new component.
If you're driving the renderer from an agentic LLM (Cursor, Claude Code, etc.) one companion document is kept in sync with the bundle:
coding-gen-skill.md— an extensive knowledge base for building complete applications in Streaming UI Script: mental model, every component group, state management, queries/mutations, actions, loops, JavaScript interactions, routing, app patterns (todo list, dashboard, wizard, chat, settings, real-time, status page, checkout flow, file manager, calendar, docs portal), and anti-patterns. Treat it as the "deep dive" the model can read once and then author full apps unaided. It also opens with a short "When to reach for this library" section so agents can decide quickly whether<streaming-ui-script>is the right tool for the job.
The docs/ folder is the source for the live documentation site published at
https://asfand-dev.github.io/streaming-ui-script/. Every page is a static
HTML file that loads the same bundle the rest of the world consumes from the
CDN.
| Page | What's on it |
|---|---|
index.html |
Overview, drop-in install, live theme picker. |
get-started.html |
Step-by-step integration walkthrough. |
frameworks.html |
Integration recipes for React, Next.js, Vue, Angular, Svelte, plain HTML. |
language.html |
Full Streaming UI Script language reference. |
components.html |
Every built-in component with a live preview, positional signatures, prop tables, and enum values. |
javascript-interactions.html |
Script(...) + @Js(...) guide — always available at runtime. |
routing.html |
Hash-based routing guide — always available at runtime. |
themes.html |
Built-in themes gallery, live picker, side-by-side compare, and the token customization studio (formerly theme-customization.html, now redirected). |
examples.html |
Curated showcase of real-world block UIs (auth, products, FAQ, cart, todos, …). |
playground.html |
CodeMirror 6 editor with custom Streaming UI Script highlighting + autocomplete, live preview, share links, persistent layout modes (drag the splitter, collapse the docs sidebar), hover-over component info, signature/argument tooltips with allowed enum values, and an inspection mode that maps rendered DOM back to the source. Powered by src/language/. |
live-examples.html |
Catalog page linking out to every standalone demo below. |
Every standalone demo is a single HTML page that you can open directly. They
double as integration recipes — view source on any of them to see how a real
host page wires setResponse, appendChunk, setTools, and setTheme.
| Demo page | Highlights |
|---|---|
chat-bot.html |
OpenAI-powered chat that streams replies into the renderer. Toggle between the chat-flavoured and full system prompt live. |
tools-example.html |
Read / write / poll patterns wired to in-page setTools() handlers. |
external-data-example.html |
Live GitHub repository explorer powered by a single tool function. |
support-agent.html |
AI triage workspace: pick a ticket, the agent suggests priority + draft reply. |
analytics-assistant.html |
Natural-language → charts/KPIs/breakdown. |
javascript-todo-app.html |
Reactive todo list with filters + localStorage persistence via one Script(...). |
javascript-pomodoro.html |
Pomodoro timer with phases, audio chime, and notifications. |
javascript-stopwatch.html |
Sub-second stopwatch + laps using requestAnimationFrame and proper teardown. |
routing-demo.html |
A four-page app driven by Routes + NavLink + @Navigate, deep links, browser back/forward. |
app-workspace.html |
Full SaaS workspace: sticky Sidebar, topbar, MetricGrid, timeline, DescriptionList. |
project-dashboard.html |
Engineering program dashboard: banner, PageHeader, KPIs, kanban board, activity timeline. |
marketing-landing.html |
Hero, feature grid, pricing tiers, testimonials, team line-up, FAQ. |
team-directory.html |
Profile cards, avatar groups, search-with-pagination, empty state, slide-in Sheet. |
settings-app.html |
Tabs, Switch, ToggleGroup, Progress, Kbd, danger-zone confirmation Sheet. |
ecommerce-product.html |
Product page with Cover hero, MediaCard related items, Rating, ProgressRing, Stats, reviews. |
inbox-app.html |
Focused mail/chat inbox using SplitView, SearchBar, PersonChip, Notification, ChatBubble. |
pricing-page.html |
Pricing surface: Cover, Stats trust strip, PricingTable, FeatureGrid, Quote, FAQ, CTA. |
crm-contacts.html |
Directory: SearchBar, segmented filters, Tile quick stats, paginated cards, detail Sheet. |
status-page.html |
Public SRE status: incident Banner, latency LineChart, services with StatusDot, incident Timeline. |
checkout-flow.html |
Four-step checkout wizard: Steps, SplitView cart, promo codes, address + payment + review + confirmation. |
file-manager.html |
Cloud file browser: Tree sidebar, Toolbar, files Table, preview Sheet, storage ProgressRing. |
calendar-app.html |
Calendar & scheduler: DatePicker, category chips, busy-hours ring, agenda Timeline, event detail Sheet. |
docs-portal.html |
Help center / knowledge base: SearchBar, Tree categories, Markdown article, Rating, FAQ Accordion. |
The full catalog with tag filters lives at
docs/live-examples.html.
.
├── src/ # Library source
│ ├── parser/ # Lexer, parser, AST types
│ ├── runtime/ # Evaluator, reactive state, queries, actions,
│ │ ├── builtins.ts # builtin @-functions and action-step markers
│ │ ├── evaluator.ts # program planner + binding resolver
│ │ ├── state.ts # reactive store (subscribers, two-way binds)
│ │ ├── queries.ts # Query / Mutation registry + auto-refresh
│ │ ├── actions.ts # ActionRunner (@Run, @Set, @Reset, …)
│ │ ├── scripts.ts # ScriptRunner — useEffect-style Script(...)
│ │ └── router.ts # Hash-based router for Routes / NavLink
│ ├── library/ # Component specs and registry
│ │ └── components/ # layout / content / forms / data / charts /
│ │ # chat / feedback / navigation / menu /
│ │ # patterns / scripts / router
│ ├── renderer/ # Tree → DOM
│ │ ├── renderer.ts # walks the tree, calls component renderers,
│ │ │ # tracks per-instance state by tree path
│ │ └── morph.ts # React-like DOM reconciler — keeps focus,
│ │ # selection, scroll, and <details>.open
│ ├── theme/ # Token system + injected stylesheet
│ │ ├── index.ts # Seven built-in themes + custom token merge
│ │ └── styles.ts # Shadow-DOM stylesheet (theme-aware)
│ ├── prompt/ # System prompt generator
│ ├── language/ # Reusable language-support module
│ │ ├── grammar.ts # Pure-data tokens + CodeMirror StreamParser
│ │ ├── components.ts # Component catalog (derived from library)
│ │ ├── builtins.ts # @-builtin catalog (sourced from runtime)
│ │ ├── snippets.ts # Ready-to-insert templates for composites
│ │ └── index.ts # `getLanguageSpec()` barrel for editors
│ ├── element.ts # The custom element
│ └── index.ts # Public entry point
├── docs/ # Static documentation site (HTML + CSS + JS)
├── _docs/ # Internal design notes and inspirations (not shipped)
├── scripts/
│ ├── emit-prompt.mjs # Writes dist/system_prompt*.txt from the bundle
│ └── build-docs.mjs # Assembles ./site/ from docs/ + dist/
├── tests/ # Vitest unit + element regression tests
├── dist/ # Built artifacts (created by `npm run build`)
├── site/ # Deployable static docs (created by `npm run build:docs`)
├── .github/workflows/ # GitHub Pages deploy pipeline
├── README.md # This file
└── coding-gen-skill.md # Deep knowledge base for building full apps
Requirements: Node ≥ 18 and npm ≥ 9 (or pnpm/yarn — examples use npm).
git clone https://github.com/asfand-dev/streaming-ui-script.git
cd streaming-ui-script
npm installnpm run buildProduces:
dist/streaming-ui-script.js # ESM bundle
dist/streaming-ui-script.umd.cjs # UMD bundle for older bundlers
dist/streaming-ui-script.iife.js # IIFE for non-module <script> tags
dist/system_prompt.txt # Full prompt — every component, JS, routing
dist/system_prompt_chat.txt # Compact chat-focused prompt (lighter, OpenUI-Lang style)
The two prompt variants exist so host apps can pick the right flavour up
front: system_prompt.txt (or getSystemPrompt() with no options) for rich
generative UI surfaces that need JS and routing, and system_prompt_chat.txt
(or getSystemPrompt({ mode: "chat" })) for chat assistants whose replies
should stay purely declarative. Both are kept in lock-step with the library by
the build script.
npm testIncludes:
- Parser / lexer correctness (
tests/parser.test.ts). - Runtime evaluator + reactive state (
tests/runtime.test.ts). - Built-in function semantics (covered across runtime + library tests).
- JavaScript interactions:
Script(...)lifecycle +@Js(...)(tests/javascript-integration.test.ts). - Hash-based router and
Routes/Route/NavLink(tests/router.test.ts). - Theme resolution and token application (
tests/theme.test.ts). - Component library smoke tests (
tests/library.test.ts). - Element-level integration tests — Custom Elements + Shadow DOM via happy-dom (
tests/element.test.ts). - System prompt generator output (
tests/prompt.test.ts). - End-to-end demo programs from the docs (
tests/demos.test.ts). - Language support spec for editor / tooling integrations (
tests/language.test.ts).
npm run build:docsAssembles ./site/ from ./docs/ + ./dist/. Serve it with anything static:
npx http-server site -p 4321
# or
npx serve siteThen open http://localhost:4321/index.html.
The library treats every LLM-supplied attribute as untrusted and runs it through a small set of sanitisers before it lands on the DOM:
| Sink | Helper | Effect |
|---|---|---|
Anchor href (Link, BreadcrumbItem, NavbarItem, Markdown links) |
sanitiseHref |
Allow-lists http(s):, mailto:, tel:, fragments, root-relative paths. Rejects javascript:, vbscript:, data:text/html, control-char bypasses (java\tscript:), protocol-relative //host/.... Unsafe URLs collapse to #. |
Image src (Image, Avatar, MediaCard, Hero, Testimonial, ChatBubble) |
sanitiseImageSrc |
Allow-lists http(s):, data:image/*, blob:, plus relative paths. Anything else falls back to an empty string so callers render a placeholder. |
Inline style lengths (Container.maxWidth, Skeleton.height, …) |
sanitiseCssLength |
Restricts the alphabet so semicolons / quotes cannot inject extra declarations. |
background-image: url(...) (Cover.imageSrc) |
sanitiseCssUrl |
Drops characters that would close the url() literal. |
@OpenUrl("...") action step |
sanitiseHref (runtime) |
The action runner sanitises the URL before calling window.open (or any consumer onOpenUrl override). External windows are opened with noopener,noreferrer. |
External links rendered by Link, NavbarItem, and the Markdown renderer
get rel="noopener noreferrer" so the destination cannot read the opener's
document.referrer.
If you embed <streaming-ui-script> behind a CSP, the bundle does not use
eval. Script(...) bodies are evaluated with new (Async)Function(...)
which requires 'unsafe-eval' if you want scripting to work; omit the
attribute (and @Js action steps) if you cannot relax CSP.
This repository ships its own copy of the bundle on GitHub Pages, so most users do not need to host anything themselves:
<script type="module" src="https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js"></script>
<streaming-ui-script theme="dark"></streaming-ui-script>…and a fetch of system_prompt.txt server-side to build LLM messages:
curl https://asfand-dev.github.io/streaming-ui-script/dist/system_prompt.txtTo ship your own copy, run npm run build and serve the dist/ folder from
any static host — every artifact in dist/ is self-contained.
GitHub Pages deployment for this repo is automated via
.github/workflows/deploy-pages.yml. Push
to main and the workflow will build, test, assemble site/, and publish.
Contributions are very welcome. The fastest path is:
- Fork and clone the repo.
npm install && npm test— make sure the suite is green onmainfirst.- Make your change in a focused branch (e.g.
feat/inline-charts). - Add or update tests in
tests/. Aim for good edge-case coverage. - Run
npm run buildto confirm the bundle and the system prompt still build. - Open a pull request describing the motivation and any user-visible changes.
Issues, design discussions, and bug reports are tracked at https://github.com/asfand-dev/streaming-ui-script/issues.
By contributing you agree that your work will be released under the project's MIT license.
MIT — see LICENSE.