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. It needs
cards and grids to render the view. It needs state that reacts when
the user types in a search box. It needs a router that survives a
handler that throws. @forwardimpact/libui ships all of
it as small functions you compose. You assemble the web side of a
capability. You do not hand-build it from raw DOM.
Prerequisites
- Node.js 22+
- Install libui:
npm install @forwardimpact/libui
This page assumes you read the
shared-surface guide.
It also assumes you have a createBoundRouter that
works, 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 more
than fourteen 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. It sets the URL
hash to navigate. So cards link into other routes and you add no
extra code. createAutoGrid takes a size
(xs, sm, md,
lg). The size sets the minimum column width. The grid
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 must filter a list as the user types. It must not
re-fetch data. It must not re-register 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. Several routes read and write some values. Examples are a logged-in user, a loaded dataset, and a theme. Use the global store for those values.
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. A thrown
error then 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. An example is a one-off page mounted at startup. The
boundary recognises libui's NotFoundError and
InvalidCombinationError. It renders the message that
matches. Any other error falls back to a generic notice.
Present a guided sequence with the slide router
Some surfaces are a linear walkthrough. They are not a tree of
pages. Examples are an onboarding tour and a generated report deck.
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();
After you set an order, arrow keys, space, and
PageUp/PageDown move between slides.
Home and Escape return to the first slide.
setSlideOrder accepts chapter boundaries as a second
argument. ArrowUp/ArrowDown then jump
between chapters. Use the slide router for sequences. Use the bound
router for everything else. They are separate tools. You do not
layer them.
Emit a machine-readable channel alongside the page
The route descriptor carries three channels. It does not carry two.
page renders for people. cli shows the
terminal equivalent in the command bar. graph emits a
machine-readable representation of the same route. That
representation is a Turtle or JSON-LD fragment. An agent can consume
it and does not scrape 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 you click it. -
When you type in a
createSearchBar, the reactive updates and the filtered list re-renders. -
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 after you call
setSlideOrderandstartKeyboardNav. -
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 serve both the CLI and the web. You build no separate integrations.
Add a Capability to Both Surfaces
Ship a feature to terminal and browser at once. One presenter and one registration cover both surfaces.