Adding a web page
The reference frontend (front/web, package @ulams/web) is Astro with server rendering on every
request. Pages read API data on the server through @ulams/sdk, describe their sections as a
document of catalogue components from @ulams/ui, and render it with <Render>. No client
JavaScript ships unless a component is interactive. Background:
Reference frontend and Generative UI.
The example is the events listing, front/web/src/pages/events/index.astro.
1. The page
Section titled “1. The page”A file in front/web/src/pages becomes a route (events/index.astro is /events,
events/[kind]/[id].astro is /events/webinar/1). The middleware (src/middleware.ts) has
already resolved the tenant from the Host header into Astro.locals.tenant; on /learn,
/account and /bff it also ensures a session and puts the token in Astro.locals.token.
---import Render from "@ulams/ui/Render.astro";import type { UiNode } from "@ulams/ui/render-core";import Base from "../../layouts/Base.astro";import { apiFor, getSettings, getSiteModel, publicData } from "../../lib/data.ts";import { chromeFor, withSessionLink } from "../../lib/docs.ts";import { themeFor } from "../../lib/theme.ts";import { consultationModels } from "../../lib/view-model.ts";import { SESSION_COOKIE } from "../../lib/session.ts";
const tenant = Astro.locals.tenant;if (!tenant) return Astro.redirect("/");const [site, settings, consultations] = await Promise.all([ getSiteModel(tenant), getSettings(tenant).catch(() => null), publicData(tenant, "consultations", () => apiFor(tenant).consultations.list({ per_page: 12 })).catch(() => []),]);const theme = themeFor(settings?.theme?.theme, tenant.slug);const chrome = chromeFor(theme);// ...const doc: UiNode = { component: "Stack", props: { gap: "sm" }, children: [ ...section("webinars", "Live online", "Webinars", webinars), ...section("in-person", "Meet in person", "Events", inPerson), ...section("consultations", "One to one", "Consultations", oneToOne), ],};---
<Base theme={theme} accent={settings?.theme?.accent} title={`Live sessions and events — ${site.tenant.name}`} demo={{ adminUrl: tenant.adminUrl, slug: tenant.slug }}> {chrome.header && <Render doc={withSessionLink(chrome.header, Astro.cookies.has(SESSION_COOKIE))} data={site} />} <main id="main" tabindex="-1"> <header class="u-container u-events-head"> <h1>Live sessions and events</h1> </header> <Render doc={doc} data={site} /> </main> {chrome.footer && <Render doc={chrome.footer} data={site} />}</Base>Conventions this shows:
- Data on the server.
apiFor(tenant, token?)is acreateClient(...)pointed at the tenant API. Wrap public data inpublicData(tenant, name, fetcher)so it goes through the stale-while-revalidate cache. Learner data is cached per session under a key derived from the token, with short lifetimes (getProfile10 minutes,getAllProgress20 seconds), and progress is dropped after writes through the BFF. Catch failures of optional data so the page still renders. - No invented facts. Map API responses to the view model in
src/lib/view-model.tsand bind to it; do not hard-code numbers, ratings or quotes. - Layout and landmarks.
Basesets the theme (--ulams-*variables, accent adjusted for AA contrast), the skip link and the demo badge. Keep one<main id="main">and one<h1>. - Styles. Scoped
<style>in the page or component, usingvar(--ulams-*)tokens only.
2. SDK calls
Section titled “2. SDK calls”If the API call is not in the SDK yet, add it to createClient in front/sdk/src/client.ts. Paths
are typed against the generated OpenAPI paths (ApiPath), so the endpoint must be documented and
openapi.ts regenerated (OpenAPI and the SDK):
consultations: { list: (query: { per_page?: number } = {}) => request<Consultation[]>("GET", "/api/consultations", { query }), get: (id: number) => request<Consultation>("GET", "/api/consultations/{id}", { params: { id } }),},Response types are written by hand in front/sdk/src/types.ts. Add a Vitest case in
front/sdk/tests/client.test.ts.
If the browser has to call the API (an interactive component), it goes through the BFF at
/bff/api/..., which adds the httpOnly session token. Only calls in BFF_RULES
(front/web/src/lib/bff.ts) are forwarded; add a rule for the new call and a test in
tests/unit/server.test.ts.
3. A new catalogue component (when needed)
Section titled “3. A new catalogue component (when needed)”Prefer existing components: the list with props is in the UI catalogue
(or corepack yarn workspace @ulams/ui catalogue for JSON). A new one needs three things in
front/ui, and the registry test checks all of them:
-
Registry entry in
src/registry.ts: a description written for the model, a category (structure,section,course,learning), whether it is interactive (ships JS) and takes children, a JSON Schema of flat, explicit props (enums with defaults), and a plain-text fallback.Callout: {description: "Highlighted note inside a lesson: a tip, a warning or a key idea.",category: "learning",interactive: false,children: false,props: obj({tone: oneOf(["tip", "warning", "key"], "Kind of note", "tip"),title: text("Title", { maxLength: 120 }),text: text("Text", { maxLength: 1000 }),},["text"]),fallback: (p) => join(p.title, p.text),},Every non-structure component gets an optional
idprop (section anchor) added by a loop at the end of the registry; declare it yourself only to give it a default. -
Astro component
src/components/<Name>.astrowith matchingProps, styled withvar(--ulams-*)tokens. Interactive behaviour goes in a web component insrc/elements/. -
Mapping in
src/Node.astro:import <Name> from "./components/<Name>.astro"and add it to theCOMPONENTSmap.
tests/registry.test.ts fails if a registered component has no Astro file or no import in
Node.astro, has a description of 20 characters or less, defaults that do not validate, or an
enum default outside its enum. Add
schema or rendering cases to tests/render-core.test.ts when the component has special props.
More: UI components.
4. Tests
Section titled “4. Tests”-
Unit (Vitest): view-model mappers in
front/web/tests/unit/view-model.test.ts; documents are checked against the catalogue indocs.test.ts. -
Smoke (Playwright):
front/web/tests/e2e/smoke.spec.tsloads every tenant; add an assertion for the new page if it has tenant-specific content. -
Accessibility: add the page to
PAGESinfront/web/tests/e2e/a11y.spec.ts. It runs axe with the WCAG 2.2 AA tags on desktop and on a 360 px phone viewport. Learner-facing UI must pass WCAG 2.2 AA.
corepack yarn workspace @ulams/web testcorepack yarn workspace @ulams/ui testcorepack yarn dev:web & # or build + start for production numberscorepack yarn test:web:e2ecorepack yarn turbo run typecheck lint --filter=@ulams/web --filter=@ulams/ui --filter=@ulams/sdkFor pages on the critical path (landing, course, lesson), check the budget with
corepack yarn workspace @ulams/web perf (Performance). The docs
coverage check expects every learner route to be listed in some page’s learnerRoutes.