Build a Web Surface with libui
The
shared-surface guide
shows the route descriptor that binds one presenter to both the
terminal and the browser. The browser still needs a body: cards and
grids to render the view, state that reacts when the user types in a
search box, and a router that survives a handler that throws.
@forwardimpact/libui ships all of it as small functions
you compose, so the web side of a capability is assembled, not
hand-built from raw DOM.
Prerequisites
- Node.js 22+
- Install libui:
npm install @forwardimpact/libui
This page assumes you have read the
shared-surface guide
and have a working createBoundRouter with at least one
defineRoute. Everything here renders into the page that
router displays.
Build the page from components
libui's component functions return plain DOM elements. You pass
a config object and get back a node you can hand to
render or nest inside another component. There are
fourteen-plus factories; these are the ones most pages reach for.
import { render, div } from "@forwardimpact/libui";
import {
createCard,
createStatCard,
createAutoGrid,
createDetailHeader,
} from "@forwardimpact/libui/components";
function renderCity(view) {
const header = createDetailHeader({
title: view.city,
description: `${view.condition}, ${view.wind}`,
backLink: "/",
backText: "← All cities",
});
const stats = createAutoGrid("xs", [
createStatCard({ value: view.temp, label: `Temp (${view.units})` }),
createStatCard({ value: view.wind, label: "Wind" }),
]);
const detail = createCard({
title: "Forecast",
description: view.condition,
});
render(div({ className: "page" }, header, stats, detail));
}
A card with an href becomes clickable and navigates by
setting the URL hash, so cards link into other routes without any
extra wiring. createAutoGrid takes a size
(xs, sm, md, lg)
that sets the minimum column width and reflows to fit the viewport.
The factory families group by purpose:
| Family | Functions | Use for |
|---|---|---|
| Cards |
createCard, createStatCard,
createBadge, createTag
|
Summary tiles and labels |
| Grids |
createAutoGrid, createStatsGrid,
createCardGrid, createDetailGrid
|
Responsive layout of any children |
| Lists |
createSearchBar, createCardList,
createGroupedList
|
Filterable collections |
| Detail views |
createDetailHeader,
createDetailSection,
createLinksList, createDetailItem
|
Single-record pages |
| Navigation |
createBreadcrumbs, createBackLink,
updateActiveNav
|
Wayfinding |
| Error pages |
createNotFound, createErrorMessage
|
Friendly dead ends |
React to input with local state
A search box needs to filter a list as the user types without
re-fetching data or re-registering the route.
createReactive holds a value, notifies subscribers when
it changes, and hands back an unsubscribe function.
import { createReactive, createComputed, render, div } from "@forwardimpact/libui";
import { createSearchBar, createCardList } from "@forwardimpact/libui/components";
function renderCityList(cities) {
const query = createReactive("");
const visible = createComputed(
() =>
cities.filter((c) =>
c.name.toLowerCase().includes(query.get().toLowerCase()),
),
[query],
);
const list = createCardList(visible.get(), (city) => ({
title: city.name,
description: city.condition,
href: `/forecast/${city.id}`,
}));
visible.subscribe((rows) => {
list.replaceWith(
createCardList(rows, (city) => ({
title: city.name,
href: `/forecast/${city.id}`,
})),
);
});
const search = createSearchBar({ onSearch: (value) => query.set(value) });
render(div({ className: "page" }, search, list));
}
createComputed derives a value from one or more
reactives and recomputes whenever any dependency changes.
bind is the third reactive helper: it ties a reactive
value directly to an element property, so
bind(count, badge, "textContent") keeps a
badge in sync without a manual subscriber.
Reactive state is local to one page render. For values that several routes read and write — a logged-in user, a loaded dataset, a theme — use the global store.
Share state across routes with a store
createStore holds one state object behind dot-notation
access. Any part of the app reads a path, writes a path, or
subscribes to every change.
import { createStore } from "@forwardimpact/libui";
const store = createStore({
user: null,
data: { cities: [] },
});
// Read
const cities = store.getStatePath("data.cities");
// Write — notifies every subscriber
store.updateState("user", { id: "ada", name: "Ada" });
// React anywhere
const unsubscribe = store.subscribe((state) => {
console.log("store changed", state.user);
});
A reactive is the right tool for state that lives and dies with a single page; a store is the right tool for state that outlives any one route.
Survive a handler that throws
A page handler that throws should not blank the entire application.
Wrap a render function with withErrorBoundary and a
thrown error renders a friendly message instead of an empty screen.
import { withErrorBoundary } from "@forwardimpact/libui";
const safePage = withErrorBoundary(renderCity, {
backPath: "/",
backText: "← Back to Home",
onError: (error) => console.error("page failed", error),
});
The bound router from the shared-surface guide already wraps every
registered page in an error boundary, so you get this
protection for free on routed pages. Reach for
withErrorBoundary directly only when you render outside
the router — for example, a one-off page mounted at startup. The
boundary recognises libui's NotFoundError and
InvalidCombinationError and renders the matching
message; any other error falls back to a generic notice.
Present a guided sequence with the slide router
Some surfaces are a linear walkthrough — an onboarding tour, a
generated report deck — rather than a tree of pages.
createSlideRouter extends the core router with an
ordered sequence and keyboard navigation.
import { createSlideRouter } from "@forwardimpact/libui";
const slides = createSlideRouter();
slides.on("/intro", () => render(introSlide()));
slides.on("/results", () => render(resultsSlide()));
slides.on("/next-steps", () => render(nextStepsSlide()));
slides.setSlideOrder(["/intro", "/results", "/next-steps"]);
slides.startKeyboardNav();
slides.start();
Once an order is set, arrow keys, space, and
PageUp/PageDown move between slides;
Home and Escape return to the first.
setSlideOrder accepts chapter boundaries as a second
argument, and ArrowUp/ArrowDown then jump
between chapters. Use the slide router for sequences and the bound
router for everything else; they are separate tools, not layered.
Emit a machine-readable channel alongside the page
The route descriptor carries three channels, not two.
page renders for people, cli shows the
terminal equivalent in the command bar, and graph emits
a machine-readable representation of the same route — a Turtle or
JSON-LD fragment an agent can consume without scraping the HTML.
import { defineRoute } from "@forwardimpact/libui";
router.register(defineRoute({
pattern: "/forecast/:city",
page: (ctx) => renderCity(presentForecast(ctx)),
cli: (ctx) => `weather forecast ${ctx.args.city}`,
graph: (ctx, vocabularyBase) => {
const view = presentForecast(ctx);
return `<${vocabularyBase}/city/${ctx.args.city}> a <${vocabularyBase}/Forecast> ;
<${vocabularyBase}/temp> ${view.temp} .`;
},
}));
All three channels call the same presenter, so the page a person
sees, the command an agent copies, and the graph fragment a crawler
reads never disagree. graph is optional — routes
without it simply offer no machine channel.
Verify
-
A
createCardwith anhrefnavigates to that hash when clicked. -
Typing in a
createSearchBarupdates the reactive and re-renders the filtered list. -
store.updateState(path, value)fires every subscriber registered withstore.subscribe. - A page handler that throws renders the error boundary's message instead of a blank screen.
-
Arrow keys move between
slides once
setSlideOrderandstartKeyboardNavare called. -
A route with a
graphfunction returns a fragment built from the same presenter as itspage.
What's next
Give Agents and Humans the Same Interface
Capabilities that work on every surface — one presenter, one contract, and one formatter shared between CLI and web, with no separate integrations.
Add a Capability to Both Surfaces
Ship a feature to terminal and browser at once — one presenter, one registration, both surfaces.