aktion-runtime
v0.5.15
Published
Aktion is a single web component that turns a compact, streaming-first DSL into a rich, interactive UI inside its shadow DOM. Works in React, Vue, Angular, Svelte, plain HTML — or no framework at all.
Maintainers
Readme
aktion
A framework-agnostic web component that renders LLM-generated UI from
Aktion — a reactive language whose surface syntax is a strict subset of
JavaScript, designed for chat assistants. Drop one <script> tag and one
<aktion-app> tag into any HTML page and you have a streaming, interactive
renderer for an LLM's response.
<script type="module" src="https://asfand-dev.github.io/aktion/dist/aktion.js"></script>
<aktion-app theme="light">
$app(Card([
CardHeader("Hello", { subtitle: "Generative UI in plain HTML" }),
Markdown("This card was streamed in as **plain text**.")
]))
</aktion-app>That is the whole integration. Works in React, Vue, Angular, Svelte, plain HTML, or no framework at all.
- Docs site: https://asfand-dev.github.io/aktion/
- Live demos: https://asfand-dev.github.io/aktion/live-demos.html
- CDN bundle (ESM): https://asfand-dev.github.io/aktion/dist/aktion.js
- System prompt (full): https://asfand-dev.github.io/aktion/dist/system_prompt.txt
- System prompt (chat): https://asfand-dev.github.io/aktion/dist/system_prompt_chat.txt
- Deep authoring guide:
coding-gen-skill.md
Table of contents
- What's in the box
- Quick start
- Public API
- Aktion — the language
- Component library
- Themes
- Icons
- Routing
- Built-in globals (
$storage,$console) - Internationalization (
$i18n) - System prompt generator
- Tooling
- Build-time compiler & multi-file modules
- Documentation site
- Live demos
- Project layout
- Run it locally
- Security
- Contributing
- License
What's in the box
Everything you need at runtime ships in a single bundle:
- A streaming-first parser. Line-oriented, error-tolerant. Each
statement commits to the DOM as soon as it arrives. The surface syntax
is a strict subset of JavaScript —
functiondeclarations,for...of,if/else,switch/case, template literals with${expression}interpolation, arrow functions, default parameters, destructuring, spread, optional chaining (a?.b), nullish coalescing (a ?? b), and object-literal named arguments. Every Aktion program is valid JavaScript. - One reactive atom kind. Declare any reactive state with
$name = valueand read or write it with$name. The$prefix is the only thing that makes a binding reactive —let/const/varkeywords are optional and have no effect on reactivity. The runtime tracks dependencies automatically — and at path granularity: reading$user.namesubscribes touser.namealone, so a write to$user.rolenever re-renders, recomputes, or re-fires an effect that only readname(see Fine-grained reactivity below). Automatic two-way binding via direct state refs (and member chains rooted at one —value: $form.email), and a$utilruntime namespace of pure helpers ($util.filter,$util.sort,$util.find,$util.groupBy,$util.format,$util.formatDate,$util.plural,$util.case,$util.range,$util.pick,$util.omit,$util.merge,$util.cloneDeep,$util.chunk,$util.partition,$util.keyBy, …) callable from Aktion expressions and ordinary JavaScript alike. - One component-call shape. Every call follows the trailing-object
rule —
Component(positionalArg, { prop: value, … }). At most one positional argument; every other argument goes in a trailing{ }object literal. - One HTTP primitive.
$http({ url, method, headers, body, query, ... })is the only network call. Each call is self-contained (pass a full absoluteurl;GETis the default; no host-wide defaults). It returns a reactive resource bag exposingdata | error | status | loading | headers | lastUpdated, plus the callablesrefetch()andcancel(). Re-run a request viarefetch()or by wrapping it in an$effect(..., [$dep]). $storageand$consoleglobals. Always in scope, no import, lowercase.$storage.set/get(localStorage by default),$storage.session.*,$storage.cookies.*with object-literal options, andconsole.log/error/warn/info/debug.$toast— imperative notifications. A reserved namespace that owns the toast lifecycle so you never hand-manage a$toasts = [...]array.$toast.show(message, { tone?, title?, duration? })(auto-dismisses afterdurationms, default4000;0keeps it), plus shortcuts$toast.success/.error/.info/.warning,$toast.dismiss(id), and$toast.clear(). Toasts render themselves (stacked top-right) — noToasts(...)to wire into$app. For custom placement, render the reactive$toast.itemslist yourself withToasts/Toast(which opts out of the auto-rendered layer).$form({ values, rules, onSubmit })— the form engine. Managed reactive form with per-field$util.rulesvalidators (including async ones via$util.rules.asyncCustom— think server-side uniqueness checks), touched/dirty tracking, andform.submit()(aliashandleSubmit()) that validates — awaiting async rules — then callsonSubmit. Accessform.values.field(two-way bindable),form.errors.field,form.touched.field,form.dirty(flips on the first edit, even via two-way binding; clears on reset),form.valid,form.submitting(staystrueuntil an asynconSubmitsettles),form.validating(async rules in flight), and callform.setField(),form.touch(),form.reset().Input/TextArea/Select/NumberInputacceptonBlur/onFocus, so validate-on-blur is one prop:Input("email", { value: form.values.email, onBlur: () => form.touch("email") }).$storepersistence + undo/redo. Addpersist: "key"to mirror the store tolocalStorage(orpersistIn: "session"forsessionStorage) — hydrates on mount. Addhistory: true(or a depth number) for full undo/redo:store.undo()/store.redo()/store.canUndo/store.canRedo.- Universal
sx/animatestyling channel. Every component accepts a token-awaresxobject — spacing (p px py pt…, logicalps pe ms me;px/mxemitpadding-inline/margin-inlineso RTL apps mirror automatically), sizing (w h minW maxW…), color (bg color borderColor, gradient refs like"gradient.brand"), surface (border radius shadow opacity backdrop), background imagery (bgImage+bgOverlaywash +bgSize), typography (fontSize weight textDecoration textAlign), flex/grid (display direction align justify wrap grow shrink basis columns), position/layering (position top right bottom left inset zIndex— layer tokens resolve through themeable--rui-z-*vars), and interactionstates: { hover|focus|active|disabled|… }compiled to scoped CSS rules — plusanimate: "fade-up"motion presets. Any value accepts a{ base, sm, md, lg, xl }map that resolves to real@mediabreakpoints. No stylesheet required. - 60+ new components since v0.4: marketing bands (
Section,Split,Bento), motion (Reveal,Transition,FlipList,Parallax), accessibility primitives (VisuallyHidden,SkipLink,LiveRegion,FocusTrap), realtime (TypingIndicator,PresenceAvatars,ReactionPicker,LiveCursor), e-commerce (Cart,ProductCard,OrderSummary), canvas (DrawingCanvas,SignaturePad), scheduling (Calendar), virtualization (VirtualGrid), and many more. - RTL + logical layout. Set
dir="rtl"on<aktion-app>and the whole tree flips (text direction, flex order, logical spacing). Programs need no code change. - SSR / SSG.
renderToString(program, { path, initialState })→{ html, state }for server-side rendering.renderToStaticMarkupfor static pages. - DX tooling.
tailwindToSx(classString)maps Tailwind classes tosx;htmlToAktion(html)imports common HTML/JSX;componentSchema()emits a stable JSON schema for editor autocomplete;buildGallery()generates a self-contained component explorer;suggestComponent("Buttn")returns typo candidates. - Testing utilities.
within(node)for scoped queries,axe(node)for a11y audits from theaktion-runtime/testentry. - A React-like DOM reconciler. 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 275 components spanning layout, forms, charts, data, feedback, navigation, patterns, app-shell composites, editors, advanced UI, motion, marketing, e-commerce, accessibility, realtime, and standard helpers. See Component library.
- Declarative side effects.
$effect(() => { body }, [...deps])for background work — anonymous blocks where the dependency list mixes state triggers ($atom), lifecycle triggers ("mount","unmount","every(N)"), and rate-limit modifiers ("debounce(N)","throttle(N)").$effect(() => { … })with no dependency array is equivalent to$effect(() => { … }, ["mount"]). Declare an effect at the top level for program-wide work, or inside a component function body to scope it to a single instance — timers, watched atoms, andcleanup(fn)registrations tear down when the component leaves the tree.function name(args) { … }(camelCase) declares an action — click-driven mutations that may optionallyreturna value. - Outbound events.
$emit("name", { detail })dispatches aCustomEventon the host element from inside any action / effect / lambda body. The host listens withel.addEventListener("name", …). - A built-in router.
pages = $router({ "/path": Component(), "/users/:id": UserPage({ id: params.id }), default: NotFound() })plusNavLink(label, { to })and a reservedroutehandle that exposesroute.path,route.params,route.query,route.pattern, androute.navigate("/path"). Hash-based, framework-agnostic, always wired up. - Six built-in themes (
light,dark,corporate,soft,glass,modern) plus full custom-token support via CSS custom properties. 80+ design tokens organised intocolors,radius,font,spacing,shadows,gradients(referenced as"gradient.brand"fromsx/GradientText),zIndex(layer tokens feedingsx.zIndex), andmotiongroups — plusfonts(Google-Fonts shorthand import) andicons(custom inline-SVG registration). Brand the UI from inside the script with a$theme({...})statement. $i18nfactory.const { t, setCurrentLanguage, getCurrentLanguage } = $i18n({ defaultLanguage, currentLanguage, translations })builds a translation bundle keyed by language, with{name}placeholder interpolation.- Font Awesome 6.7.2 auto-loaded — every
iconprop accepts a Free Font Awesome name (nofa-prefix). UseIcon(name, { variant?, size? })for standalone glyphs. Variant prefixes supported:"regular:star","brands:github". - Markup escape hatches.
HTMLTag(tag, { attributes?, children? })andStyles(css)are the last-resort raw-HTML / raw-CSS injectors when no standard component captures the design. - Third-party widget interop.
Mount({ setup, update?, cleanup?, props? })hosts an imperative library (chart, map, editor, payment element) with a managed lifecycle;WebComponent(tag, { attributes?, properties?, on? })bridges native custom elements;$script({ src, global? })loads an external SDK once; and the$domnamespace gives auto-disposed resize / intersection / mutation observers +$dom.measure(node). - Document head & SEO.
$head({ title, meta, og, twitter, link, jsonLd })is a reactive head manager — per-route titles / meta / Open Graph / JSON-LD that also feedrenderToString(head+headAttrs) for crawlable SSR. - A system prompt generator. Emits a clean, ordered prompt teaching
the LLM exactly which components, builtins, and tools are available.
Two flavours ship:
system_prompt.txt(full — every feature) andsystem_prompt_chat.txt(compact — read-only UI conversion). - Host-side tooling. A canonical formatter, structured-edit delta
protocol, AST inspector, and LSP-ready language service all exported
from
aktion/tooling.
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.
Quick start
1. Load the bundle
Use the CDN build (no install, just a script tag):
<script type="module" src="https://asfand-dev.github.io/aktion/dist/aktion.js"></script>For non-module setups (older bundlers, embedded contexts) use the IIFE build:
<script src="https://asfand-dev.github.io/aktion/dist/aktion.iife.js" defer></script>…or install from npm and import once from your client-side entry point:
npm install aktion-runtime
# yarn add aktion-runtime
# pnpm add aktion-runtimeimport "aktion-runtime";The package is published as
aktion-runtime. The
npm tarball ships only the compiled dist/ output (ESM + CJS + UMD +
IIFE bundles, type declarations, the stylesheet, and the two
system_prompt*.txt files), so installs stay small. Subpath imports
are available for convenience:
import "aktion-runtime/style.css";
const SYSTEM_PROMPT = await fetch(
new URL("aktion-runtime/system_prompt.txt", import.meta.url),
).then((r) => r.text());The CSS is bundled inside the JS and injected into each instance's shadow root, so you do not need a separate stylesheet.
2. Mount the tag
<aktion-app id="reply" theme="light"></aktion-app>3. Render a response
Three equivalent ways:
<!-- as an attribute -->
<aktion-app response='$app(Card([CardHeader("Hi")]))'></aktion-app>
<!-- as inner text (rendered on connect) -->
<aktion-app>
$app(Card([CardHeader("Hi")]))
</aktion-app>
<!-- from an external file (linked with its imports) -->
<aktion-app src="./app.aktion"></aktion-app>
<!-- as a property/method -->
<script>
const el = document.querySelector("aktion-app");
el.setResponse(`
$app(Column([greeting]))
greeting = Card([CardHeader("Hello", { subtitle: "Generative UI in plain HTML" })])
`);
</script>4. Stream from your LLM
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;5. Send the system prompt
Either fetch the auto-generated system_prompt.txt from the CDN:
const systemPrompt = await fetch(
"https://asfand-dev.github.io/aktion/dist/system_prompt.txt",
).then((r) => r.text());…or build a richer prompt programmatically:
const prompt = el.getSystemPrompt({
mode: "full", // or "chat" for the compact read-only prompt
preamble: "You are an analytics assistant.",
additionalRules: ["Always end with a FollowUpBlock of 2 prompts."],
});Network calls are issued by the LLM-authored code itself via the
$http({ url, method, body, ... })primitive. The host is not involved. Installel.registerHttpInterceptors(...)if you need to attach auth headers, retry on 401, or log every request.
6. (Optional) Listen for assistant messages
Wire LLM-driven follow-ups back into your chat loop:
el.addEventListener("assistant-message", (event) => {
appendUserMessageToChat(event.detail.message);
});Public API
All members live on the <aktion-app> element.
Attributes
| Attribute | Values | Description |
| --------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| theme | Theme name or JSON token map | Switches the theme. JSON objects are merged on top of the default light tokens. |
| streaming | true / unset | Hint that text is still being appended. The error banner is suppressed while set. |
| response | Aktion text | Sets the program declaratively. Re-renders whenever the attribute changes. |
| src | URL to an .aktion file | Loads the program from an external file resolved relative to the document. The file is linked through the in-browser project linker, so an entry that imports other modules resolves and fetches its whole graph. response (and any inner text) takes precedence; changing src reloads. |
| showerrors | true / unset | If present and true, displays parse errors in the rendered UI. Defaults to off. |
| strict | true / unset | Dev/strict mode. Surfaces silent failures as console.warns — unknown identifiers that would resolve to null, and trailing {...} objects passed to a user component whose keys match no parameter (the silent named→positional flip). Off by default; enable while developing. |
| router-mode | hash (default) / history | URL strategy. history uses the History API for clean /about URLs (needs an index.html fallback on the server); hash works on any static host. |
| router-base | path string (e.g. /app) | Sub-directory the SPA is served under, stripped from / prepended to URLs in history mode. |
| dir | ltr / rtl / auto | Writing direction. Reflects onto the render root so logical CSS properties, flex order, and text direction flip automatically. Programs need no code change. |
| scroll-restoration | auto / top | Opt-in scroll restoration. auto restores per-path scroll on back/forward and jumps to top on fresh navigation; top always jumps to top. |
Routing and JavaScript execution inside effect / action bodies are
always available — no host attribute, no allow-list. To omit those
surfaces from the generated prompt, build it via
getSystemPrompt({ mode: "chat" }).
Properties
| Property | Type | Description |
| ------------- | ----------------------------- | -------------------------------------------------------------------------------------- |
| response | string | Get or set the current program text. Setter is equivalent to setResponse(text). |
| src | string \| null | Reflects the src attribute. Setting it loads (or reloads) the program from that URL. |
| 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"). |
Methods
| 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. |
| registerComponents(specs, root?) | Extend the built-in library with your own components. |
| getSystemPrompt(options?) | Build a system prompt that matches the current library. Pass { mode: "chat" } for the compact variant. |
| navigate(path) | Programmatically navigate. Updates window.location.hash. |
| registerHttpInterceptors({ onRequest?, onResponse?, onError? }) | Install interceptors for the $http({...}) layer. onResponse receives a retry() one-shot for e.g. 401 refresh flows. |
| serializeState() | Return every reactive atom as a plain JSON-friendly object (for SSR / resumption). |
| hydrateState(snapshot) | Apply a snapshot to the live store and schedule a re-render. Atoms not in the snapshot are untouched. |
| loadSnapshot({ programText, state }) | Atomic program + state load. The next render plans the program with the hydrated state already in place. |
| applyDelta(ops) | Apply a structured delta (patch / replace / append / new / delete). User $state is preserved across the diff. |
Module exports
Beyond the element, aktion-runtime exports a set of standalone utilities importable from subpaths:
import { renderToString, renderToStaticMarkup } from "aktion-runtime";
// → { html, state } for SSR; renderToStaticMarkup for SSG
import { htmlToAktion, tailwindToSx, componentSchema, buildGallery, suggestComponent } from "aktion-runtime";
// htmlToAktion(html) → Aktion program string from common HTML/JSX
// tailwindToSx("p-4 bg-white") → sx object ({ p: "m", bg: "surface", _unmapped: [...] })
// componentSchema() → stable JSON schema for editor tooling
// buildGallery() → self-contained HTML component explorer
// suggestComponent("Buttn") → ["Button", ...] typo suggestions
import { render, within, axe, cleanup } from "aktion-runtime/test";
// render(program, opts) → Screen with Testing-Library-style queries + interactions
// within(node) → scoped query set
// axe(node) → a11y audit (returns array of violations)
import { getDiagnostics, getCompletions, formatProgram } from "aktion-runtime/language";
// DOM-free language service for editor integrationsEvents
| Event | Detail | When it fires |
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------ |
| assistant-message | { message: string } | When an action or lambda calls $emit("assistant-message", { message: "..." }). |
| error | { errors: ParseError[] } | After each render whose source had parse errors. |
| route-change | { path, previousPath, source } | When the current hash path changes. source is "init" \| "hashchange" \| "navigate" \| "external". |
| <custom-name> | User-defined { ... } | When script calls $emit("name", { ... }) inside an action / effect body. |
The error event always fires regardless of showerrors, so host apps
can log or report errors even when the in-page banner is suppressed.
Runtime safety limits
Every program is evaluated under a per-render runtime budget that bounds three independent dimensions so a partial / accidentally recursive program (typed live in the playground, mid-stream LLM token, …) can't freeze the browser:
| Dimension | Default | Triggers on |
| ------------------- | ------------ | -------------------------------------------------------- |
| componentDepth | 150 levels | function Foo() { return Foo() } and other recursive trees |
| iterations | 250 000 / render | unbounded for/while loops inside function bodies |
| arrayLength | 100 000 entries | $util.range(0, 1e9), $util.repeat(value, 1e9) |
When a limit trips, the runtime aborts the render, emits an error
event whose detail is shaped like a parse error ({ line: 0, column:
0, message }), and leaves the previous tick's DOM intact so the user
still sees something useful. The defaults comfortably fit any
realistic app; tighten or relax them by constructing a custom budget
via createRuntimeBudget({ … }) and passing it through createContext
(or pass null to disable enforcement entirely in trusted offline
pipelines).
Aktion — the language
Aktion's surface syntax is a strict subset of JavaScript. Every
declaration uses standard JS constructs — function, for...of,
if/else, switch/case, arrow functions, object literals — so any
developer reading the output immediately knows what it does. The renderer
commits each line as soon as it streams in, so the user sees the page
shell before the leaves arrive.
$count = 0
$theme = "dark"
function Counter(label = "Count") {
return Stack([
SectionHeader(label),
Button("Inc", { onClick: () => $count = $count + 1 }),
Text(`Current: ${$count}`)
])
}
function loadOrders() {
$orders = $http({ url: "https://api.example.com/orders", method: "GET" })
}
$effect(() => {
$save = $http({ url: "https://api.example.com/draft", method: "PUT", body: $draft })
}, [$draft, "debounce(500)"])
$orders = $http({
url: "https://api.example.com/users/42/orders",
method: "GET",
query: { limit: 5 }
})
pages = $router({
"/": Counter(),
"/orders": Async($orders, { loading: Spinner(), data: OrderTable($orders.data) }),
default: NotFound()
})
$app(pages)Key constructs
$app(…)— the reserved entry point. Every program renders from it. It accepts a single root node, an array of nodes (rendered as siblings), or variadic nodes.$name = value— reactive state. One kind. Read or write with the same sigil. Inside action / effect / lambda bodies, assignment operators (= += -= *= /= ??= ++ --) are all allowed.let/const/varare optional and do not affect reactivity — only the$prefix makes a value reactive.Component-call shapes — pick ONE per call:
Component(positionalArg, { prop: value, … }) // canonical Component(arg1, arg2, arg3) // all-positional, signature order Component({ prop: value, … }) // all-named single objectThe canonical form passes the prop tagged
(positional)bare and every other prop in a trailing{ }object. All-positional calls bind arguments to the signature's props in listed order — mind that order:Button("Save", "primary")puts"primary"in the second slot (onClick), notvariant, so prefer the trailing object for non-adjacent props. A single{ }argument whose keys are prop names is an all-named call; when the component's positional prop is itself object-typed, a lone object is that prop's payload instead. One object is never split between the two roles.function Name(p = default) { return Expression }— PascalCase name means it's a component. Parameters use standard JS defaults (=). Inside the body,$x = expris a declaration: the initializer runs once when the instance first mounts, and re-renders preserve whatever value the user (or an action / effect) has written. Always end with an explicitreturn. Components do not have apropsobject — every parameter is a real JS parameter. A custom component may NOT reuse a built-in component's name (the validator flags it) — unless its body calls that same name, the supported wrapper pattern: inside its own body the name resolves to the BUILT-IN, sofunction Badge(l) { return Badge(l, { tone: "success" }) }extends the library Badge instead of recursing.function name(args) { body }— camelCase name means it's an action. Callable effects with optionalreturn. Used as event handlers (onClick: save) or as expressions ($result = greet("Ada")). Wrap optimistic writes in$optimistic(() => { … })— it snapshots reactive state, runs the callback, and automatically rolls back if the callback throws (or the promise it returns rejects). An ordinary$-prefixed builtin call, so it works anywhere an expression does.$effect(() => { body }, [...deps])— declarative, anonymous side effects. The dependency array mixes state triggers ($atom), lifecycle / interval triggers ("mount","unmount","every(N)"), and rate-limit modifiers ("debounce(N)","throttle(N)").$effect(() => { … })(no second argument) is equivalent to$effect(() => { … }, ["mount"]). Declare at the program top level for global work, or inside a component function body to scope the effect to that instance — the runtime mounts it on first render and tears down its timers / subscriptions /cleanup(fn)handlers when the instance leaves the tree.$emit("name", { detail })— dispatch an outboundCustomEventon the host element. Call from any action / effect / lambda body whenever the surrounding host page needs to react to a user interaction.Standard JS control flow —
if,switch,for,while,do…while,tryare statements, exactly as in JavaScript. They run inside any imperative body (function, lambda with{ … },effect) but cannot appear on the right-hand side of an assignment. Use ternaries and.map/.filterfor value-producing expressions. Bodies may be either a block or a single statement (if (!ok) return):banner = $error ? ErrorAlert($error) : Notice("All good") rows = $items.map(item => Row(item)) // Multi-way dispatch: wrap a `switch` in a function and `return` per arm. function viewFor(tab) { switch (tab) { case "list": return ListView($items) case "grid": return GridView($items) default: return EmptyState("Pick a view") } } view = viewFor($tab)Full operator set — arithmetic (
+,-,*,/,%,**), comparison (==/!=/===/!==,</>/<=/>=), logical (&&,||,??), bitwise / shift (&,|,^,~,<<,>>,>>>), and the relational keywordsinstanceof/in. Every compound-assignment form is supported too (+=,-=,*=,/=,%=,**=,&&=,||=,??=,&=,|=,^=,<<=,>>=,>>>=).Line continuations — any expression operator (
.,?.,?,:,&&,||,??,==/!=/===/!==,</>/<=/>=,instanceof,+,-,*,/,%,**) may appear at the start of the next line and the parser keeps building the same expression — matches JavaScript's ASI rules. Use this to split long method chains, ternaries, and logical expressions across lines.$http({ url, method, headers, body, query, ... })— the only network primitive (absoluteurl;GETdefault; no host-wide defaults). Returns a reactive resource with.data,.error,.status,.loading,.headers,.lastUpdated,.refetch(),.cancel(), and a settable.onDonecallback that fires each time the request settles (handy for$todos.refetch()after a write).$query({ url, key?, ttl?, refetchInterval?, refetchOnFocus?, refetchOnReconnect? })— a cached, deduplicated read built on$http. Identical queries share one bag. Optionalttlauto-refetches stale data;refetchInterval(ms) polls a live feed;refetchOnFocus/refetchOnReconnectrefresh on tab focus / reconnect. Addinfinite: { param, limit, mode, select }for a paginated list —$feed.loadMore()appends the next page while$feed.hasMoreis true. Passgql(+ optionalvariables) to POST a GraphQL document and unwrap.dataautomatically.$mutation({ url, method?, optimistic?, invalidates? })— a deferred write that fires only when you call.mutate(overrides?)(not on render;methoddefaults toPOST).optimistic: (overrides) => { … }runs synchronously before the request and auto-rolls-back if it fails;invalidates: ["key"]refetches matching cached queries after success. The bag exposes.loading/.error/.data, plus.reset()and a settable.onDone. Use$util.invalidate(keys)to manually trigger cache invalidation from anywhere.$socket({ url, protocols?, bufferSize?, reconnect? })— reactive WebSocket. Read.status("connecting" | "open" | "closed"),.connected,.last,.messages,.attempts; call.send(data)(queues while connecting, flushes on open) or.close()(stops for good).reconnect: true(or a max-attempt number) retries dropped connections with exponential backoff. Auto-tears-down on re-plan.$sse({ url, event?, withCredentials?, bufferSize? })— reactive Server-Sent Events stream with the same.status/.connected/.last/.messages/.close()surface (EventSource reconnects natively).pages = $router({ "/path": Component(), default: NotFound() })— function-call router. The reservedroutehandle exposes the reactive surface (route.path,route.params,route.query,route.pattern) and aroute.navigate("/path")method. Supports nested layout routes ("/app": { layout: AppShell, routes: {...} }— the shell stays mounted while only theoutletswaps), navigation guards ($util.onNavigate(({ to, from }) => …)— returnfalseto block or a path to redirect), query-param state ($util.url.setQuery("tab","v")), lazy routes (Lazy(() => import(…))), and scroll restoration (setscroll-restoration="auto"on<aktion-app>).Two-way binding is implicit: pass a
$variable(or a member chain rooted at one —value: $form.email) as an input prop and the runtime wires it both ways.Lambdas — every JavaScript arrow form is supported:
() => expr,x => expr(single param, no parens),(x) => expr,(x, y) => expr,(x = 0) => x + 1(defaults),(...args) => sum(args)(rest),({ a, b }) => a + b/([x, y]) => x(destructured params),(args) => { … }(multi-statement, mayreturn). The body can wrap onto the next line (x =>\n expr).Destructured parameters — both
functiondeclarations and lambdas accept array / object patterns (function Card({ title, tone = "info" }),function head([first, ...rest])), with the same defaults / renames / rest support aslet-destructuring.JS expression niceties — array / object spread (
[...xs, y],{ ...base, k: v },fn(...args)), array / object destructuring inlet/const/var(let [a, b, ...rest] = arr,let { name, age = 0 } = user) including nested patterns (let { data: { items: [first] } } = resp), destructuring infor-ofheads (for (const [k, v] of Object.entries(obj)),for (const { id } of rows)), computed property keys ({ [$dynamic]: value }), prefix and postfix++/--(with JS-accurate return semantics),new Constructor(...)with trailing member / call chains (new Date(0).getTime()), trailing commas in function params / call args / literal lists.async functionis accepted as a no-op modifier;awaitis allowed in both statement and expression position inside action / effect bodies.Equality & comparison match JavaScript —
==/!=use abstract-equality coercion (sox == nullmatchesnullandundefined,1 == "1",0 == false), while===/!==stay strict. Relational</>compare alphabetic strings lexicographically (alphabetical.sortcomparators work) and coerceDateoperands viavalueOf; two numeric strings still compare numerically ("5" < "10").Top-level imperative statements —
if/for/while/tryand bare expression statements written at the program top level run once per plan (e.g. building a$statearray with awhileloop). Inside a render they behave like a module init block; prefer pure expressions (.map,$util.range) where you can.$utilruntime namespace — pure, side-effect-free helpers for data shaping, formatting, dates, math, and strings ($util.filter,$util.sort,$util.groupBy,$util.format,$util.formatDate,$util.plural,$util.range,$util.addDays,$util.pick,$util.omit,$util.merge,$util.cloneDeep,$util.chunk,$util.partition,$util.keyBy,$util.zip,$util.flatten,$util.count,$util.slugify,$util.truncate,$util.initials,$util.currency,$util.bytes,$util.relativeTime,$util.uuid,$util.copy(async — resolvestrueonly when the clipboard write succeeds),$util.sleep(ms),$util.debounceFn,$util.throttleFn(leading + trailing edge), …). Never carry hidden state — safe to call anywhere. Also exposes reactive env getters ($util.scroll,$util.viewport,$util.breakpoint,$util.media,$util.mouse,$util.url— lazy listeners, re-render on change;$util.url.setQuery/.removeQuerywrite query params), styling/validation sub-namespaces ($util.style.cx,.gradient,.alpha,.clamp,.token,.toStyle;$util.rules.required(),.email(),.min(),.pattern(),.custom(),.asyncCustom()— awaited by$form—,.validate(),.validateAll()), computed helper ($util.derived(fn)), side-effect hooks ($util.onError,$util.onNavigate,$util.onRequest,$util.onResponse,$util.invalidate), and device/platform helpers ($util.vibrate,.share,.readClipboard,.geolocate,.isOnline,.deviceType,.worker(pureFn),.registerServiceWorker,.webManifest,.nativeShell,.isNativeApp).Escape hatches —
HTMLTag(tag, { attributes?, children? })for raw HTML elements andStyles(css)for raw CSS injected into the shadow root. Use only when the standard component library cannot express the design.Hoisting & streaming — references resolve from the entire top-level scope, not source order. Always call
$app(…)first so the reconciler has the page shell to attach streamed leaves to.Comments:
//line comments and/* block */comments — standard JS style.
Built-in globals at a glance
| Global | Purpose |
| --------- | -------------------------------------------------------------------- |
| $storage | Browser persistence — $storage.set/get, $storage.session.*, $storage.cookies.*. |
| $console | Forwards to the host console — log / error / warn / info / debug. |
| $toast | Imperative notifications — $toast.show/.success/.error/.info/.warning, .dismiss(id), .clear(), reactive .items. |
| route | Reactive router handle — path, params, query, pattern, navigate(path). |
| JS stdlib | The JS standard library — Math, JSON, Object, Array, Number, String, Boolean, Date, Map, Set, RegExp, Promise, plus parseInt / parseFloat / isNaN / isFinite / encodeURIComponent / … Use directly (Math.max(a, b), JSON.stringify(x), Object.keys(o)) or with new (new Date(), new Map()). |
| timers | setTimeout / setInterval / clearTimeout / clearInterval — like their JS counterparts, but tracked by the runtime and cleared automatically on re-plan/disconnect. Use inside an effect and clear in cleanup. |
| full JS globals | The entire JavaScript global surface is available — dialogs (alert, confirm, prompt), Web APIs (fetch, URL, URLSearchParams, Blob, FormData, crypto, navigator, localStorage, atob/btoa, Intl, BigInt, Reflect, …), and window / document themselves. Any globalThis member resolves by name. |
Both $storage and $console are lowercase; the route handle is
reserved (never declare a state slot named route). Author declarations
and built-in components always win over a same-named global (a library
Text / Map component is never shadowed by the DOM Text / Map), so the
global passthrough only resolves names you haven't otherwise defined. For
reactive data prefer $http({...}) over raw fetch, and timers/listeners
belong inside an $effect(...) so they're cleaned up on unmount.
The 60-second pitch
$days = "7"
$data = $http({ url: "https://api.example.com/metrics", method: "GET", query: { days: $days } })
filter = FormControl("Range", { control: Select("days", {
items: [SelectItem("7", "7d"), SelectItem("30", "30d")],
value: $days
}) })
kpi = StatCard("Events", { value: `${$data.data?.events ?? 0}`, trend: "up" })
chart = LineChart({
labels: $data.data?.daily?.day ?? [],
series: [Series("Events", $data.data?.daily?.events ?? [])]
})
$app(Column([CardHeader("Analytics"), filter, kpi, chart]))Highlights:
- One statement per line.
- Three string flavours:
"double",'single', and`backtick`with${expression}interpolation. - Optional chaining (
obj?.prop) and nullish coalescing (a ?? b). - Spread in arrays (
[...$pinned, ...$todos]) and objects ({...$current, status: "done"}). - Array shortcuts:
$rows.length,$rows.first,$rows.last, plus pluck ($rows.title→[title1, title2, …]). - Responsive prop maps on layout components:
Grid(items, { columns: { sm: 1, md: 2, lg: 4 }, gap: "lg" }). - Forward references are allowed — call
$app(Column([...]))first and let the children stream in beneath it.
Declarative todo app
$todos = [{ id: 1, text: "Welcome — try editing", done: false }]
$draft = ""
function add() {
$todos = [...$todos, { id: $todos.length + 1, text: $draft, done: false }]
$draft = ""
}
function remove(id) {
$todos = $util.filter($todos, "id", "!=", id)
}
row = t => Card([Stack([
Text(t.text),
Button("Delete", { onClick: () => remove(t.id), variant: "ghost" })
])])
list = $todos.map(t => row(t))
$app(Stack([
Input("draft-input", { placeholder: "What needs doing?", value: $draft }),
Button("Add", { onClick: add, variant: "primary" }),
list
]))Fine-grained reactivity
Dependencies are tracked at the path you read, not the whole atom.
Reading $user.name subscribes to user.name — so a write to a
sibling field leaves that reader untouched, while replacing the whole
atom (an ancestor) or writing a descendant still wakes it. This is the
auto-tracking of MobX, the path-granularity of Solid, and the
"recompute-only-on-input-change" of Redux selectors — with no selectors
or special syntax. You just read the path.
$user = { name: "Ada", role: "Engineer" }
// Reads `user.name` → depends on `user.name` only.
greeting = Text(`Hi ${$user.name}`)
// Writing the sibling `role` does NOT re-render `greeting`, recompute a
// `$user.name`-derived value, or fire a `[$user.name]` effect.
function promote() { $user.role = "Manager" } // greeting stays put
function rename(next) { $user.name = next } // greeting updatesThe rule that keeps it predictable:
| You read… | You depend on… |
| --- | --- |
| $user | user (the whole atom) |
| $user.name | user.name |
| $user.address.city | user.address.city |
| $rows[i] / $rows.name (array index / pluck) | rows (the array) |
| $obj[$key] (dynamic key) | obj + whatever $key reads |
A change to path C wakes a dependency on path D exactly when one
is a prefix of the other (equal, ancestor, or descendant) — sibling paths
never interfere. Object fields are tracked field-by-field; reading into an
array (or through a dynamic key) subscribes at the array/container, so
mutating any element re-renders the list. The same model powers the four
places work is triggered: render scheduling (the app re-renders only
when a changed path overlaps what it displayed), computed values
($total = $util.sum($cart.lines) recomputes only when cart.lines
changes), effects ($effect(..., [$user.name])), and per-component
re-rendering (below).
Per-component re-rendering
A component only re-executes when its own inputs change — its args
(props) or a $state path its body read. This is the granularity of
React.memo / Solid, but automatic: no memo() wrapper. If $user.age
changes, a ShowName($user.name) that only read name is skipped (its
body — and any console.log in it — doesn't run); only the components that
actually depend on age re-execute.
function App() {
$user = { name: "Ada", age: 30 }
return [ShowName($user.name), ShowAge($user.age)] // siblings, independent
}
// Changing $user.age re-runs ShowAge only; ShowName is reused (memoized).Args are compared shallowly (Object.is), so — exactly as in React —
passing a fresh inline lambda each render (onClick: () => …) makes the
receiving component re-render every time; hoist the handler to a stable
binding if you want it skipped. State changes the path-tracker can't see
(hook setters, timers, HTTP, effects) fall back to a full re-render.
Granularity is at both the subscription and component level. When a component does re-execute, Aktion rebuilds its render tree and the morph reconciler patches only the DOM that actually differs.
[!IMPORTANT] Path-tracking applies to
$name = valueatoms only. The other state sources — the$state/$memo/$ref/$reducerhook setters,$http/$query/$mutationlifecycle changes,setTimeout/setIntervalticks,$effectwrites, and$emit— cannot be path-tracked, so each of them triggers a full re-render (the morph reconciler still patches only the changed DOM, but every component body re-executes). This is the single most important performance characteristic to internalise: a hook-heavy component tree loses the fine-grained skipping you get from plain atoms. Prefer top-level$name = valueatoms for app state on the hot path, and reach for hooks when you specifically need per-instance isolation, accepting the full-re-render cost. See the "Reactivity" section ofcoding-gen-skill.mdfor the full model.
Per-instance state & content-addressed identity
function Counter(label) {
$n = 0
return Stack([
Text(`${label}: ${$n}`),
Button("inc", { onClick: () => $n = $n + 1 })
])
}
// Two independent counters — each holds its own atom.
$app(Stack([Counter("A"), Counter("B")]))Every call site accepts a universal key named argument. The renderer
uses it as the instance suffix instead of source location, so reordering
siblings keeps per-instance state attached to the right element:
function TaskRow(task) {
return Stack([Text(task.title)], { key: task.id })
}Hooks — $state, $memo, $ref, $reducer, $id, and custom $name
A function whose name starts with $ is a hook, mirroring React's
use* convention. Hooks are the composable way to manage per-instance
state. The built-in hooks mirror their React counterparts one-to-one:
| Hook | React equivalent | Returns |
| --- | --- | --- |
| $state(initial) | useState | [value, setValue] |
| $memo(() => v, [deps]) | useMemo | cached value |
| $ref(initial) | useRef | stable { current } box (writes don't re-render) |
| $reducer((state, action) => next, initial) | useReducer | [state, dispatch] |
| $id(prefix?) | useId | stable unique id per instance |
$state and $memo are the everyday pair:
function Counter() {
const [count, setCount] = $state(0)
const label = $memo(() => `Count: ${count}`, [count])
return Stack([
Text(label),
Button("+1", { onClick: () => setCount(c => c + 1) })
])
}
$app(Counter())$state(initial)returns a[value, setValue]pair.setValue(next)replaces the value;setValue(prev => next)derives it from the previous value. The initializer is evaluated once, on first render.$memo(() => compute, [deps])returns a cached value and recomputes only when a dependency changes (shallowObject.iscompare). Omit the deps array to recompute every render.$ref(initial)returns a stable mutable{ current }box whose identity persists across renders. Writingref.current = …does not schedule a re-render — the escape hatch for holding a DOM node, a timer id, or a previous value. Pair it withOnMount(child, { onMount: node => ref.current = node })to grab a rendered DOM node.$reducer((state, action) => next, initial)returns[state, dispatch].dispatch(action)runs the reducer and re-renders when the result changes — the clean way to manage many related state transitions.$id(prefix?)returns a stable, unique string id for the instance's lifetime — for wiringfor/id/aria-labelledbypairs without hard-coding ids that collide across multiple instances.
Declare your own hooks with function $name(...). A custom hook's
body runs inline in the calling component's hook scope, so its
$state / $memo calls attach to that component — exactly how a React
custom hook shares its caller's slots:
function $useCounter(start) {
const [count, setCount] = $state(start)
return { count: count, increment: () => setCount(c => c + 1) }
}
function Counter(label) {
const c = $useCounter(0)
return Stack([Text(`${label}: ${c.count}`), Button("+1", { onClick: c.increment })])
}Two rules, both inherited from React: call hooks unconditionally and in
a stable order at the top level of a component / hook body (slots are
matched by call order across renders), and remember that hook state
resets when the instance leaves the tree — a remounted component
starts again from its initial value. $state, $memo, $ref,
$reducer, and $id are reserved
names. The lighter $name = value per-instance form above remains
available when an atom is written directly by the component's actions.
Global stores — $store({...})
For state shared across components — the role Redux / Zustand / Pinia play
elsewhere — declare a store. Non-function entries are reactive state;
function entries are methods that receive the store handle s first. Read
state with store.field (fine-grained), call methods with
store.method(args), and mutate inside a method with s.field = …. The
handle is an app-global singleton with reference-stable methods, so any
component reads it or calls its actions directly — no prop drilling.
cart = $store({
items: [], // state
count: (s) => s.items.length, // getter → cart.count()
total: (s) => $util.sum(s.items.map(i => i.price)), // getter → cart.total()
add: (s, item) => { s.items = [...s.items, item] }, // action → cart.add(item)
clear: (s) => { s.items = [] },
})
// Siblings with no relationship both talk to the same cart.
function AddLatte() { return Button("Add", { onClick: () => cart.add({ price: 4.5 }) }) }
function MiniCart() { return Text(`${cart.count()} items — ${$util.format(cart.total(), "currency")}`) }
$app(Column([AddLatte(), MiniCart()]))Reads are fine-grained and per-component (changing cart.items re-renders
only components that read it), and store fields support two-way binding
(Input(value: form.draft)). Use a $store for shared state; use a
component's local $state / $name = value for state one component owns.
See the Global state guide.
Persistence. Add persist: "key" and the store's data round-trips to localStorage on every change and hydrates on mount. Use persistIn: "session" for sessionStorage. persist and persistIn are config-only — they're never exposed as state fields.
Undo/redo. Add history: true (or a depth cap) and the store records per-mutation snapshots. store.undo() / store.redo() / store.clearHistory() plus reactive store.canUndo / store.canRedo for wiring button disabled states.
doc = $store({
persist: "my-doc", // survives reload
history: 25, // undo/redo up to 25 steps
title: "Untitled",
setTitle: (s, v) => { s.title = v }
})
// doc.undo() / doc.redo() / doc.canUndo / doc.canRedoComponent-scoped effects
$effect(() => { … }, [...deps]) blocks can live at the program top level
or inside a component function body. Inside a component body the
runtime mounts the effect when the instance first renders and tears it
down (clearing timers, unsubscribing watched atoms, firing every
registered cleanup(fn)) the moment the instance disappears from the
tree. Two LiveClock() calls produce two independent intervals — and
removing one stops only that one:
$app(Stack([LiveClock("UTC"), LiveClock("Local")]))
function LiveClock(label) {
$now = $util.now()
$effect(() => {
$now = $util.now()
}, ["every(1000)"])
return Stack([Text(label), Text($util.formatDate($now, "time"))])
}Use a top-level $effect(() => { … }, [...]) for global work (analytics,
app-wide keyboard shortcuts, hydration of shared atoms); use a
component-local effect whenever the background work logically belongs
to the UI it serves.
Schema-as-truth diagnostics
validateProgramSchema(program, library) (exported from
src/library/index.js) emits hard errors
for:
- Closed-token enum mismatches (
Button("Save", { variant: "magic" })). - Unknown named args (
Stack({ junk: 1 })). - One-positional-max violations (
Button("Save", "primary", true)→ "use{ variant: "primary", loading: true }").
The host element merges these into program.errors so the on-screen
banner surfaces every violation.
Anticipatory skeletons
A reference to a component that hasn't been declared yet (and isn't in
the library) renders a Skeleton placeholder instead of
[unknown component: …]. Mid-stream forward references just shimmer
until the next render pass picks the declaration up.
For the complete language reference see
docs/language.html or, for full apps, the
deep authoring guide coding-gen-skill.md.
Component library
The bundle ships 275 components grouped by domain. Reach for pattern composites
(Hero, PageHeader, Stats, Toolbar, EmptyState, Timeline,
KanbanBoard, DescriptionList, PricingTable, …) before hand-rolling
the equivalent with Card + Stack — they're tuned to produce dense,
production-quality SaaS UI in a single line.
| Group | Components |
| ------------------ | ---------- |
| Layout | Column, Row, Center, Stack, StackItem, Grid, GridItem, Container, Box, Spacer, Card, CardHeader, CardFooter, Separator, Tabs, TabItem, Accordion, AccordionItem, Modal, Drawer, Steps, AspectRatio, ScrollArea, Sticky (with a data-stuck pinned hook), ResizablePanels, MasonryGrid, Section (page band with eyebrow/title/subtitle), Split (sticky two-pane), Bento/BentoCell (asymmetric grid), Overlay/OverlayItem (anchored layering), Fragment |
| Content | Text, Image, Icon, Badge, BadgeList, Callout, Quote, CodeBlock, Skeleton, Spinner, Markdown, Kbd |
| Forms | Form, FormControl, FormSection, FieldSet, ValidationSummary, Input, TextArea, PasswordInput, MaskedInput, MentionInput, TagInput, Select, SelectItem, Combobox, MultiSelect, Checkbox, CheckBoxGroup, CheckBoxItem, Radio, Switch, ToggleGroup, Button, Buttons, SearchBar, Slider, NumberInput, ColorPicker, DatePicker, DateRangePicker, TimePicker, DateTimePicker, FileUpload, PinInput, MultiStepForm |
| Data | Table, Col, DataGrid, List, ListItem, StatCard, Stats, Sparkline, Tile, Progress, ProgressRing, Pagination, Tree, TreeNode, CalendarView, ComparisonTable, InfiniteList |
| Charts | BarChart, LineChart, PieChart, RadarChart, ScatterChart, Histogram, Heatmap, Gauge, Series |
| Feedback & Media | Avatar, AvatarGroup, PersonChip, Tooltip, HoverCard, Popover, Rating, Toast, VideoPlayer, AudioPlayer, Carousel, Gallery, Lightbox, Map |
| Navigation | Breadcrumb, BreadcrumbItem, Navbar, NavbarItem, TopBar, NavLink (router-aware) |
| Menus | DropdownMenu, MenuItem, MenuSeparator, MenuLabel, ContextMenu |
| Editors | RichTextEditor, CodeEditor |
| Chat | SectionBlock, ListBlock, FollowUpBlock, FollowUpItem, ActionLink, ChatBubble |
| Patterns | Hero, PageHeader, SectionHeader, Toolbar, EmptyState, Timeline, TimelineItem, ActivityLog, FeatureGrid, FeatureItem, MediaCard, Testimonial, ProfileCard, Comment, Banner, Notification, InboxPanel, OnboardingChecklist, KanbanBoard, KanbanColumn, KanbanCard, DescriptionList, DescriptionItem, StatusDot, PricingTable, PricingCard, LoadingState, ErrorState, SuccessState, Tour, Spotlight |
| App shell | AppShell, Sidebar, SidebarSection, SidebarItem (supports to for router navigation), SplitView |
| Advanced UI | IconButton, CommandPalette, FilterChips, FieldRepeater, VirtualList, QueryBuilder, DiffViewer, JsonTree, Gantt, Truncate, InlineEdit, NotificationBell |
| Marketing | NavBar (sticky/blur + mobile burger menu), Brand, Footer, FooterColumn, LogoCloud, LogoChip, Display, Heading, Eyebrow, GradientText, CountUp, Metric, MetricStrip, CodeWindow, BrowserFrame, Terminal, Backdrop (grid/blobs/particles), ThemeToggle, Swatch, Prose |
| E-commerce | ProductCard, PriceTag, QuantityStepper, VariantSelector, OrderSummary, Cart |
| Motion & gestures | Reveal (scroll-triggered), Transition (enter/exit), FlipList (FLIP reorder), RouteView (route transitions), Parallax, OnGesture (swipe/pan/longPress/doubleTap), Sortable, Draggable, DropZone, Confetti, Lottie |
| Overlays | Sheet, BottomSheet, ConfirmDialog — all with Escape-to-close, a Tab focus trap, and focus restore |
| Content & docs | TableOfContents, ReadingProgress, ScrollSpy, AuthorByline, ShareButtons, RelativeTime, CopyButton, KbdShortcut, QRCode, Svg (sanitised inline SVG) |
| Realtime & social | TypingIndicator, PresenceAvatars, ReactionPicker, LiveCursor, TabBar (mobile bottom nav) |
| Scheduling | Calendar (month grid with arrow-key navigation, event chips/dots), CountdownTimer |
| Canvas | DrawingCanvas, SignaturePad |
| Accessibility | VisuallyHidden, SkipLink, LiveRegion, FocusTrap |
| Utility | SegmentedControl, FloatingActionButton, SpeedDial, BackToTop, VirtualGrid (windowed 2-D grid) |
| Helpers | Async, Show, Portal, Redirect, Lazy, ErrorBoundary |
| Behaviour wrappers | OnClick, OnMouse, OnKeyboard, OnFocus, OnIntersect, OnMount, Css, Link — attach click / mouse / keyboard / focus / intersection / lifecycle listeners or raw class / style to ANY component without it needing a dedicated prop. OnMount(child, { onMount, onUnmount }) is the DOM-ref escape hatch — onMount(node) fires once after attach so you can measure, focus, or hand the node to an imperative library. Link(label_or_child, { to?, href?, external? }) wraps either a string or a component as a router-aware anchor. |
| Interop | Mount, WebComponent — host an imperative / third-party widget (chart, map, editor, payment element) that owns its own DOM. Mount({ setup, update?, cleanup?, props?, tag?, sx? }) gives a managed setup → update → cleanup lifecycle; WebComponent(tag, { attributes?, properties?, on? }) renders + hydrates any native custom element with reactive attributes / events. Pair with the $script({ src, global? }) loader and the $dom observer namespace — see interop.html. |
| Escape hatches | HTMLTag, Styles (last-resort raw HTML / CSS — see language.html) |
| Theming | $theme |
| Routing | $router({ … }), NavLink |
Form onChange callback
Every input component accepts an optional onChange(value) callable
that fires with the freshly-read value on every change. Use it alongside
(or instead of) $variable two-way binding when you need to react
beyond a state write (debounce a search, persist a setting, kick off
a fetch).
Input("query", { onChange: q => $results = $http({ url: `https://api.example.com/search?q=${q}` }) })
Slider("vol", { min: 0, max: 100, value: $vol, onChange: v => $storage.set("volume", v) })
Switch("dark", { value: $theme == "dark", onChange: on => $theme = on ? "dark" : "light" })Behaviour wrappers
Six tiny wrappers attach behaviour to any component:
// Clickable / tappable card
OnClick(Card([Text("View order")]), { onClick: () => route.navigate("/orders/4821") })
// Lazy-load sentinel — fires once when the placeholder scrolls into view
OnIntersect(Skeleton({ variant: "card" }), { onEnter: $items.refetch, once: true })
// Drop zone — uses standard HTML5 drag-and-drop
OnMouse(Card([Text("Drop files here")]), {
dragOver: e => e.preventDefault(),
drop: e => { e.preventDefault(); $files = e.dataTransfer.files }
})
// Apply a custom class / style without breaking out of the component
Css(Card([Text("Highlighted")]), { class: "highlight", style: "border-color: #f59e0b;" })OnClick / OnMouse / OnKeyboard / OnFocus render the child via a
transparent wrapper (display: contents) so the visual tree is
unchanged — only events bubble through the wrapper. OnIntersect uses
IntersectionObserver and disposes cleanly when the component leaves
the tree. Css merges classes / inline styles directly onto the
rendered child element. Link is the same wrapper applied as an
<a> anchor with optional router-aware navigation (to).
The full catalog with positional signatures, prop tables, enum values, and
live previews is at
docs/components.html.
Rich pattern composites
function export_q3() { $exp = $http({ url: "https://api.example.com/exports/q3", method: "POST" }) }
function new_project() { route.navigate("/projects/new") }
dashHeader = PageHeader("Engineering Q3", { subtitle: "12 active · 4 at risk", breadcrumbs: ["Workspace", "Engineering"], actions: dashActions, status: Badge("On track", "success") })
dashActions = [Button("Export", { onClick: export_q3, variant: "secondary" }), Button("New project", { onClick: new_project, variant: "primary" })]
kpis = Stats([
StatCard("Active", { value: "12", trend: "flat" }),
StatCard("At risk", { value: "4", trend: "up", delta: "+2" }),
StatCard("Shipped", { value: "8", trend: "up", delta: "+3" }),
StatCard("On-time", { value: "87%", trend: "down", delta: "-3%" })
])
board = KanbanBoard([
KanbanColumn("To do", { items: [KanbanCard("Migrat