@opentf/micro-ui
v0.11.0
Published
A small functional JavaScript UI library for AI agents to generate lightweight, interactive micro-apps. — no compiler, no signals, no build step
Maintainers
Readme
Micro-UI
An Open Tech Foundation project
A small runtime for AI-generated micro-apps
A small functional JavaScript UI library for AI agents to generate lightweight, interactive micro-apps.
Features
- Lightweight — zero dependencies, no build step. ~7.1 KB gzipped for the
core; ~7.8 KB with the
store, which nothing in the core imports, so a build that never touches it never pays for it. - Simple state management — use plain variables or the built-in
storefor shared state. - Smooth updates — changes are batched and applied efficiently, no flicker or jank.
- Form-friendly — inputs, video, canvas, focus, and scroll position all survive re-renders.
- Stable lists — reorder, add, or remove items without losing element state.
- Dynamic attributes — bind classes, styles, and props with simple template expressions.
- Secure by default — interpolated content is inserted as text, never parsed as HTML.
- Fault tolerant — one broken component won't crash the rest of your app.
- AI-ready — minimal API surface that agents can generate without a toolchain.
How it works
Micro-UI parses each html template once and caches the resulting static
description on the template literal itself, so re-renders only re-evaluate the
${...} bindings rather than rebuilding a description of the whole tree.
Rendering produces a small virtual tree, which is diffed against the previous one and applied to the DOM. It is a virtual DOM, deliberately a minimal one: no compiler, no signals, no fiber or scheduler, and one reconciliation root per custom element rather than a single app-wide tree.
Installation
pnpm add @opentf/micro-ui
# or
npm i @opentf/micro-ui
# or
yarn add @opentf/micro-ui
# or
bun add @opentf/micro-uiImport
CDN
jsDelivr
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@opentf/micro-ui/dist/styles.min.css">
<script type="module">
import { define, html, update } from
"https://cdn.jsdelivr.net/npm/@opentf/micro-ui/dist/index.min.js";
</script>unpkg
<link rel="stylesheet"
href="https://unpkg.com/@opentf/micro-ui/dist/styles.min.css">
<script type="module">
import { define, html, update } from
"https://unpkg.com/@opentf/micro-ui/dist/index.min.js";
</script>esm.sh
import { define, html, update } from
"https://esm.sh/@opentf/micro-ui?min";npm
import { define, html, update } from "@opentf/micro-ui";Quick Start
<script type="module">
import { define, html, update } from "https://esm.sh/@opentf/micro-ui?min";
define("x-counter", (el, props) => {
let count = Number(props.count || 0);
return () => html`
<button onclick=${() => { count++; update(el); }}>
Count: ${count}
</button>
`;
});
</script>
<x-counter count="0"></x-counter>Usage
Lifecycle
define("x-demo", (el, props) => {
let data = [];
// Post-render setup & cleanup
onReady(() => {
const interval = setInterval(() => update(el), 1000);
return () => clearInterval(interval); // cleanup on disconnect
});
// Error handling
onError((target, err, phase) => {
console.error(`Failed in ${phase}:`, err);
});
return () => html`<p>${data.length} items</p>`;
});State & updates
define("x-counter", (el) => {
let count = 0;
return () => html`
<button onclick=${() => { count++; update(el); }}>
${count}
</button>
`;
});Composition
define("x-child", (el, props) => {
return () => html`<span>${props.name}</span>`;
});
define("x-parent", (el) => {
let name = "World";
return () => html`<x-child name=${name}></x-child>`;
});API
define(tag, setup)
Registers a custom element. setup(el, props) runs once on connect and must return a render function.
define("x-greeting", (el, props) => {
let name = props.name || "World";
return () => html`<h2>Hello, ${name}</h2>`;
});Attributes and props
props holds the element's attributes as strings. It is the same object for the
lifetime of the component, and it is refreshed from the DOM on every render — so
read it inside the render function to always see current values:
define("x-greeting", (el, props) => {
const initial = props.name; // captured once, never updates
return () => html`<h2>Hello, ${props.name}</h2>`; // read per render, always current
});Micro-UI has no automatic reactivity. Changing an attribute does not re-render,
exactly like changing a variable or a store value does not. Call update(el):
el.setAttribute("name", "Ada");
update(el); // now it re-renders, with name = "Ada"Passing objects, arrays and callbacks
props mirrors attributes, and attributes are strings. A binding whose value is
not a string is set as a DOM property on the child instead, which is how the
platform itself separates the two:
define("x-row", (el) => {
// el.item is the object the parent passed; el.onsave is the callback
return () => html`<li>${el.item.label}</li>`;
});
define("x-list", () => () =>
html`<ul>${rows.map((r) =>
html`<x-row item=${r} save=${() => remove(r.id)}></x-row>`)}</ul>`);The value is set before the child's setup runs, so it is available
immediately. Such bindings never appear in props.
An object or array binding is compared by identity: hand the child a new object and it re-renders, hand it the same one and it does not. So replace the object when its contents change, rather than mutating it in place.
A callback is read when it is called, not when the child renders, so a new closure each render — the normal case — updates the property without re-rendering the child. Calling it always runs the latest one.
Attribute names starting with
onare event bindings, not props. Name a callback prop something else (save, notonsave) if you want the child to call it rather than the DOM to fire it.
There is no observedAttributes or attributeChangedCallback — an attribute
changed from outside is picked up by the next update(), not observed as it
happens. Attributes bound from a parent template are the exception: the parent's
own re-render already patches and updates the child for you.
define("x-parent", () => () => html`<x-child label=${label}></x-child>`);
// updating the parent updates x-child too — no explicit update(child) neededhtml
Tagged template that produces a renderable tree. Supports text, attributes, events, keyed lists, conditionals, and nesting. Text interpolations are inserted as text nodes, never parsed as markup.
html`<button onclick=${handler} class="btn ${active}">Click</button>`Two restrictions are worth knowing up front, because both fail in ways that do not look like your mistake:
An on* attribute must be an interpolated function, never a string of code.
onclick="doThing()" throws with a message telling you so — including inside
html.raw, where an inline handler in markup is exactly the injection this
library is built to refuse.
html`<button onclick=${() => doThing()}>ok</button>` // a handler
html`<button onclick="doThing()">no</button>` // throwsA tag name cannot be interpolated. Each template is parsed once and cached
on the literal, so a ${...} where a tag name belongs is text, not a tag — it
renders as escaped characters rather than raising. Write the tag literally, or
build the element with document.createElement.
html`<${tag}>hi</${tag}>` // renders the literal text "<x-foo>hi</x-foo>"html.raw
Trusted HTML opt-in — bypasses escaping. Never pass user input.
html.raw`<div>${trustedMarkup}</div>`cx(...) / sx(...)
Compose a class or style string. A binding that is not a string becomes a DOM
property, and el.class and el.style are not writable that way — so
class=${["a", "b"]} sets nothing at all. These turn the shapes you reach for
into the string the DOM wants.
import { cx, sx } from "@opentf/micro-ui";
html`<button class=${cx("ui-btn", active && "is-active", { "is-busy": busy })}>`
// class="ui-btn is-active"
html`<div style=${sx({ color: "red", marginTop: "8px" }, hidden && { display: "none" })}>`
// style="color: red; margin-top: 8px; display: none"cx takes strings, arrays and { name: on } objects, nested to any depth, and
drops everything falsy — so a conditional class is just cond && "is-on".
sx takes strings, arrays and { prop: value } objects. camelCase keys become
kebab-case (marginTop → margin-top), a custom property is left alone, and a
nullish, false or empty value drops that declaration while 0 survives.
Numbers are written as they are: there is no automatic px, because guessing
which properties take a unit is a table this library would rather not carry.
With mount(..., { dev: true }), handing class or style a raw array or
object warns and names the helper you wanted.
onReady(callback)
Registers a callback to run once the component has rendered and is in the
document. Must be called synchronously inside setup. Returning a function
from it registers a cleanup, run when the element is genuinely removed — a
move or a re-parent does not count.
"A move" means the element is back in the document by the next microtask —
parent.appendChild(node) and anything else that removes and re-inserts
synchronously. A gap wider than that, an await between the two, is a removal
followed by a fresh mount: cleanups run, setup runs again, and the
component's state starts over. Virtualised lists and animated re-parenting
should move the node in one go.
define("x-clock", (el) => {
let now = new Date();
onReady(() => {
const id = setInterval(() => { now = new Date(); update(el); }, 1000);
return () => clearInterval(id);
});
return () => html`<time>${now.toLocaleTimeString()}</time>`;
});Each callback is isolated: one that throws is reported through onError with
the "ready" phase and does not stop the others, or cost you their cleanups.
onError(handler)
Registers an error handler for this component. Must be called synchronously
inside setup. Receives the element, the Error, and the phase it happened
in — "setup", "ready", "render" or "reconcile".
define("x-risky", (el) => {
onError((target, err, phase) => report(`${phase}: ${err.message}`));
return () => html`<p>${mightThrow()}</p>`;
});Errors do not travel to a parent component — every component is its own
boundary. Throws from an event handler are not caught here; they reach
window.onerror like any other listener.
update(el)
Triggers a re-render. Multiple calls are batched into a single update.
A component that has failed is retried: update() clears the error box and
renders again, on the assumption that whatever threw has been fixed. If it
throws again, the error box comes back. A failure in setup is the exception —
setup runs once per element and cannot be re-run, so the same error is
reported again rather than the component being blanked.
flush()
Immediately processes pending updates synchronously.
update(el);
flush(); // DOM is now up to datemount(el, tag, options?)
Clears el, creates <tag> inside it, and returns the new element.
const app = mount(document.getElementById("app"), "x-app");options.dev turns on developer diagnostics for the whole app:
mount(document.getElementById("app"), "x-app", { dev: true });With dev off — the default — a component that throws renders a neutral
message rather than the error text:
<div data-micro-ui-error><pre>Something went wrong.</pre></div>A thrown error can carry a URL, a token or an internal path, and the error box renders wherever the failing component sits — which may be somewhere a user is looking. So the message is withheld from the page unless you ask for it.
Nothing is lost either way: the full error always goes to console.error, and
onError handlers always receive the real Error object. Turn dev on while
developing to see the message in the page as well.
dev also turns on the warnings for mistakes that otherwise have no symptom at
all:
| Warning | What it catches |
| --- | --- |
| class=${...} was given an array… | An array or object handed to class or style, which the DOM ignores — the attribute is silently left unset. Points at cx / sx. |
| <x-row> was removed with 1 live store subscription… | A component torn down while a store.subscribe it made is still live. Nothing re-renders, because the instance is gone — but the listener keeps the element alive, so a page that mounts and unmounts these accumulates detached DOM. Return the unsubscribe from onReady. |
Both are dev-only: with dev off nothing is tracked and nothing is logged. A
store listener that throws is reported either way — it used to be swallowed,
which left the component simply not updating.
store.get(key) / store.set(key, value)
Simple key-value store. Get with store.get, set with store.set. Does not trigger DOM updates — connect to components via store.subscribe.
store.set("counter", 0);
store.get("counter"); // 0
store.set("counter", 1);store.set(key, value, { path })
Set a nested value by dot-separated path. Immutably clones the object tree along the path — the value you passed in is never mutated.
store.set("form", { name: "", email: "" });
store.set("form", "Ada", { path: "name" });
store.get("form", { path: "name" }); // "Ada"Arrays are cloned as arrays, so numeric segments address list items and the container stays a list:
store.set("todos", { items: ["a", "b", "c"] });
store.set("todos", "z", { path: "items.0" });
store.get("todos").items; // ["z", "b", "c"] — still an arraystore.subscribe(key, fn)
Listen for changes to a store key. Returns an unsubscribe function. Call inside onReady to connect store changes to component re-renders.
define("x-counter", (el) => {
onReady(() => store.subscribe("counter", () => update(el)));
return () => html`<span>${store.get("counter")}</span>`;
});store.del(key) / store.del(key, { path })
Delete a store key (resets to undefined) or remove a nested key from an object value. Notifies subscribers.
store.del("counter"); // full key deleted
store.del("form", { path: "name" }); // only removes "name", rest intact
store.del("todos", { path: "items.1" }); // splices index 1 out of the arraystore.clear()
Reset every key to undefined and notify all subscribers. Existing
subscriptions stay live — a component subscribed before clear() still
receives later set() calls for its key.
Security
Text is never parsed as HTML
Every ${value} text interpolation is inserted as a DOM text node. No HTML
parser runs on that path, so injected markup cannot become elements and
scripts cannot execute:
const userInput = '<script>alert("xss")</script>';
html`<p>${userInput}</p>` // renders as literal text, not markupA boolean is a condition, never content: both true and false render
nothing, so ${a > b} does not print the word "true" into the page and
${cond && html...} reads the way it looks. Use ${String(flag)} when the
word is what you meant. 0, "" and NaN are values, and render.
Values are displayed exactly as written — they are not entity-encoded, so
&, <, >, " and ' render as themselves:
html`<p>${"Tom & Jerry"}</p>` // shows: Tom & Jerry
html`<p>${"5 < 10"}</p>` // shows: 5 < 10URL attributes are refused when they would run script
Text is the boundary for content, but an attribute the browser navigates to
is a second door. href=${untrusted} used to be written verbatim, so a
javascript: URL out of a database ran on the next click, with the page's
origin.
An href, src, action, formaction, ping, srcdoc or xlink:href
whose value would execute is not set at all, and the refusal is reported to the
console:
html`<a href=${"javascript:alert(1)"}>click</a>` // no href is set
html`<a href=${"/search?q=javascript:alert(1)"}>` // fine — it is a pathThe scheme is read the way the parser reads it, so JaVaScRiPt:, a leading
space and java\tscript: are all the same URL. mailto:, tel:, blob: and
data: images are left alone; only javascript:, vbscript: and
data:text/html are refused.
If you meant it — a javascript: URL of your own — use an onclick handler
instead. There is no way to opt back in, because there is no version of this
that is safe with a value you did not write.
Trusted HTML opt-in (html.raw)
When you need to render trusted markup, use html.raw:
html.raw`<b>${boldText}</b>` // parsed as real HTMLhtml.raw still stringifies interpolated values, but the entire result is treated as markup and parsed via innerHTML. Never pass user-controlled input to html.raw.
Error isolation
Every component is its own boundary. A throwing setup, render, or
reconcile is caught where it happens: that component is replaced with an
error box and its own onError handlers run, while everything around it keeps
working.
Errors do not travel up. A parent cannot catch or render a fallback for a
failing child — the child shows its own error box in place, and the parent's
onError is not called. There is no ancestor error boundary.
Errors thrown from event handlers are outside this: they are ordinary DOM
event callbacks and reach window.onerror, not onError.
A throwing onReady callback is isolated too: the remaining callbacks still
run, the rendered UI is left alone (the component itself rendered fine), and
the failure is reported to onError with phase "ready".
- The failed element is replaced with a small inline error box (
<div data-micro-ui-error>). - The instance is marked
errored— furtherupdate()calls are no-ops. - The host page and all sibling components continue running normally.
- Recovery requires removing the element from the DOM and creating a fresh one.
define("x-safe", (el) => {
onError((target, err, phase) => {
console.error(`Error in ${phase}:`, err);
});
return () => {
if (Math.random() > 0.5) throw new Error("boom");
return html`<p>Works</p>`;
};
});Micro-UI Utils
A small, semantic, framework-free CSS utility and component layer for building Micro-UI apps.
No Tailwind. No Bootstrap. No build step required.
import "@opentf/micro-ui/styles.css"; // full bundle (tokens + base + components)Split imports — pay only for what you use:
import "@opentf/micro-ui/styles/tokens.css"; // vars only
import "@opentf/micro-ui/styles/base.css"; // reset + layout
import "@opentf/micro-ui/styles/components.css"; // buttons, forms, cards...Or via CDN:
<link rel="stylesheet" href="https://unpkg.com/@opentf/micro-ui/dist/styles.min.css">Class Reference
| Category | Classes |
|----------|---------|
| Layout | ui-container, ui-stack, ui-row, ui-grid, ui-grid-2/3/4, ui-wrap, ui-center, ui-between, ui-grow |
| Spacing | ui-gap-{1,2,3,4,5,6,8,10,12}, ui-p-{0,2,3,4,5,6,8}, ui-px-{4,6}, ui-py-{2,4,6}, ui-mt-{2,4,6,8}, ui-mb-{2,4,6,8} |
| Typography | ui-title, ui-heading, ui-text, ui-muted, ui-label, ui-caption, ui-error-text, ui-link |
| Buttons | ui-btn, ui-btn-primary, ui-btn-secondary, ui-btn-ghost, ui-btn-danger, ui-btn-success, ui-btn-sm, ui-btn-lg, ui-btn-icon |
| Forms | ui-field, ui-input, ui-textarea, ui-select, ui-checkbox, ui-radio, ui-switch |
| Surfaces | ui-card, ui-card-flat, ui-card-hover, ui-panel, ui-section |
| Badges | ui-badge, ui-badge-primary/success/warning/danger/info |
| Alerts | ui-alert, ui-alert-info/success/warning/danger |
| Progress | ui-progress, ui-progress-bar, ui-progress-success/danger |
| Loading | ui-spinner, ui-spinner-sm/lg |
| Avatar | ui-avatar, ui-avatar-sm/lg |
| Lists | ui-list, ui-list-item, ui-list-item-hover |
| Tables | ui-table-wrap, ui-table, ui-table-hover |
| Navigation | ui-tabs, ui-tab, ui-breadcrumbs, ui-pagination |
| Menus | ui-menu, ui-menu-item, ui-menu-divider |
| Dialogs | ui-modal, ui-dialog, ui-dialog-header/body/footer |
| Drawer | ui-drawer, ui-drawer-left/right, ui-drawer-header/body |
| Tooltip | ui-tooltip, ui-tooltip-content |
| Empty State | ui-empty, ui-empty-icon |
| Skeleton | ui-skeleton |
| Status | ui-status, ui-status-success/warning/danger/info |
| Drag & Drop | ui-draggable, ui-dropzone, ui-dragging |
| Utilities | ui-hidden, ui-visible, ui-w-full, ui-rounded-*, ui-shadow-*, ui-relative/absolute/fixed/sticky |
| States | is-active, is-disabled, is-loading, is-invalid, is-dragover, is-dragging |
Theming
Override CSS custom properties for theming:
:root {
--ui-primary: #2563eb;
--ui-primary-hover: #1d4ed8;
}Dark mode — automatic via @media (prefers-color-scheme: dark) in tokens. Override it on any element with data-theme="dark" or data-theme="light"; an explicit attribute always wins over the system preference (and you can theme individual containers).
Layers — all rules are in @layer micro-ui.* so your app CSS wins without !important:
@layer micro-ui, app; /* app layer after micro-ui */Opt-in CSS — package.json has "sideEffects": ["*.css"] so JS tree-shakes, import CSS only where needed.
Development
This repo is a pnpm workspace driven by tsr. Every
task — build, test, lint, demo — is declared in tasks.toml at the
root and can be run from any directory in the repo. Run things through tsr;
reaching past it to the underlying tool is the usual way to lose an afternoon (see
Gotchas).
Prerequisites
| Tool | Needed for | Install |
|------|-----------|---------|
| Node 22+ | the toolchain | nodejs.org, or nvm install 22 |
| pnpm | workspace dependencies | corepack enable |
| tsr | every task below | curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/tsr/main/install.sh \| bash |
| esdev | build and demo | curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/ES-Runtime/main/install.sh \| bash |
| bun | the test suite | curl -fsSL https://bun.sh/install \| bash |
| chromium (optional) | tsr test:browser, which skips without it | your package manager, or set CHROME_BIN |
tsr and esdev are standalone binaries, not npm dependencies — they land in
~/.tsr/bin and ~/.es-runtime/bin, and both need to be on your PATH.
.github/workflows/ci.yml does exactly this and is
the shortest complete description of a working environment.
Setup
git clone https://github.com/Open-Tech-Foundation/Micro-UI.git
cd Micro-UI
pnpm install
tsr check # typecheck + lint + format + the full test suiteTasks
tsr --list prints them all; tsr <task> --dry-run shows the command a task would
run without running it.
| Task | What it does |
|------|--------------|
| tsr check | The gate: typecheck + lint + fmt:check + test + test:browser. Run it before every commit. |
| tsr test | The jsdom suite — bun test, 572 tests across 24 files today. |
| tsr test:browser | test.html in a real browser, over the DevTools protocol. Skips if no browser is installed. |
| tsr typecheck | tsc --noEmit over packages/*. |
| tsr lint | Biome lint. |
| tsr fmt / tsr fmt:check | Biome format — fmt rewrites files, fmt:check only reports. |
| tsr build | Types, dist/index.js, dist/index.min.js, both CSS bundles, and the package README. |
| tsr demo | The demo workbench on http://localhost:5173. |
| tsr ci | check then build — what GitHub Actions runs on every PR. |
Repo layout
packages/micro-ui/
src/ the library — ~1,700 lines, zero dependencies
test/jsdom/ the entire test suite
docs/ CSS utility reference
dist/ build output (git-ignored, not committed)
demo/src/ demo apps, one file per feature area
tasks.toml task definitions for tsr
biome.json lint + format rules
CHANGELOG.md curated by hand; release notes are generated from itThe source, module by module
| Module | Role |
|--------|------|
| html.ts | The html tag. Looks up the parsed template for this literal, builds a vnode tree from the current values. |
| template.ts | Parses each template once into a static description, with a \ue000 marker wherever a ${...} sits. Cached on the template literal itself. |
| vdom.ts | Clones that description into a vnode tree, substituting values and flattening nested fragments and arrays. |
| reconcile.ts | Diffs the previous vnode tree against the new one and patches the DOM: attributes, events, child components' props, and keyed/unkeyed child lists. |
| dom.ts | DOM primitives — element creation, setProp's attribute-vs-property rules, namespace correction. |
| define.ts | Wraps a setup function in a Custom Element. One reconciliation root, and one error boundary, per element. |
| update.ts | update() and flush() — microtask batching, plus render-loop detection. |
| lifecycle.ts | onReady / onError, collected during setup. |
| state.ts | The WeakMaps: instance records, template cache, the pending-render set. |
| store.ts | The optional shared store — get/set/subscribe/del/clear, including path writes. |
| ns.ts | SVG namespace resolution and the camelCase SVG tag names createElementNS will not canonicalize for you. |
| raw.ts | html.raw, the explicit trusted-HTML escape hatch. |
| error.ts | The per-component error boundary and its on-page box. |
| mount.ts | mount(el, tag, options). |
Tests
tsr test # everything
cd packages/micro-ui
bun test test/jsdom/keyed-lis.test.mjs # one file
bun test test/jsdom -t "swapping two rows" # one test, by nameEvery test file imports ./setup.mjs first: it builds a jsdom window and puts
document, customElements, HTMLElement and friends on globalThis before the
library is loaded, because the library reaches for them at module scope. Each file
then imports the library with a cache-busting query
(../../src/index.ts?keyed-lis-${Date.now()}) and generates random tag names — a
custom element name can only be defined once per registry, and tests must not
collide.
The cache-busting query does not buy you a fresh module instance. Under the
bun in use, every src/index.ts?whatever resolves to the same module, so
everything at module scope — devMode above all — is shared by the whole run. A
test that depends on dev mode must call mount(host, tag, { dev: true }) itself,
and one that depends on it being off must pass { dev: false }. Assuming the
default is a bet on which file ran first: template.adversarial.test.mjs made
that bet, passed for months in the full suite, and failed the moment its file was
run on its own.
Write the test so it fails against the bug. Asserting the final DOM often passes
either way: keyed-lis.test.mjs counts insertBefore calls, and
lifecycle.coverage.test.mjs counts how many times setup ran, because that is where
the respective bugs actually lived.
Gotchas
- Don't reach for
npx.npx esdev build src/index.ts --out=dist/index.min.js --minifyresolves a different esdev out of the npx cache and fails withENOENT: … /build/, then with a bogusSyntaxError: Export 'define' is not defined. Nothing is wrong with the repo —tsr buildworks. The same applies to Biome andtsc: usetsr lint,tsr fmt,tsr typecheck. dist/is not committed. It is a build artifact, produced by the release workflow. Never include it in a PR; measure bundle size by building locally (tsr build:js:min, thengzip -c packages/micro-ui/dist/index.min.js | wc -c). That figure is the core and the store. For the core alone — what a tree-shaking build pays when it never importsstore— build an entry with thestoreline removed fromsrc/index.ts; nothing in the core imports it.- There are two READMEs.
packages/micro-ui/README.mdis generated from this file bytsr build:readmeand published to npm. Edit this one.
Before you open a PR
tsr checkis green.- The change has a test. Unit tests for logic, a jsdom test under
packages/micro-ui/test/jsdom/for anything user-facing, plus the edge and error cases. CHANGELOG.mdhas an entry under## [Unreleased], saying what broke and why — the release notes are generated from it verbatim.- Commits follow Conventional Commits
(
fix(reconcile): …). See AGENTS.md for the full contributor rules.
Releases are automated: a merge to main runs
release.yml, which builds with tsr build,
publishes @opentf/micro-ui to npm with provenance, tags v{version}, and cuts a
GitHub Release from the curated changelog.
What This Is Not
Micro-UI renders and updates the DOM. Everything else you use the platform for:
- No router — one reconciliation root per custom element, so islands drop into whatever page or framework already routes.
- No data-fetching layer — call
fetchinsetupand put the result in astore; there is no query cache, retry, or invalidation to learn. - No SSR or hydration — client islands only.
- No compiler, build step, or devtools — templates are parsed at runtime.
For a large app with deep routing and server rendering, reach for React, Vue, or Angular instead.
License
MIT
