Skip to content

front/web (reference frontend)

Generated from front/web/README.md

Server-rendered learner frontend for ulams tenants: landing, course page, lesson player, quiz and finish page. Astro 5 (SSR, Node adapter), zero client JS by default, small web components where a page needs interaction. Decision record: ADR 0008.

Requirements: Node 22 (.nvmrc), the API stack running (yarn dev:api, Caddy on port 80, so http://coffee.localhost answers).

Terminal window
cp front/web/.env.example front/web/.env # then set DEMO_STUDENT_PASSWORD (TENANT_DEMO_PASSWORD in api/.env)
yarn install
yarn dev:web # astro dev on :4321

Open the platform product page or a tenant by host name (*.localhost resolves to 127.0.0.1, no Caddy change needed):

The six demos are the Coffee Atlas, On-Call, Night Sky Explorers and the three free interactive academies Gravity Lab (gravity, dark space theme), Poland, Measured (poland, cartographic) and The Scottish Book (ulam, squared-paper notebook). Each tenant also answers on <slug>.localhost (API) and <slug>.admin.localhost (admin); the wildcard hosts in the Caddyfile and *.localhost need no change for a new slug. The landing hero of the three new demos plays a clean, self-running loop of the course’s interactive package (no step text or controls, a still first, a static frame under reduced motion, a small “Try it” link; ADR 0093) through the public GET /api/interactive/showcase endpoint, and a drawing (orbits, a map graticule or an Ulam spiral) while the tenant has none.

Production build and server:

Terminal window
yarn workspace @ulams/web build
yarn workspace @ulams/web start # node dist/server/entry.mjs on :4321, reads .env
Command What it does
yarn workspace @ulams/web test unit tests (vitest): view model, documents vs catalogue, cache, tenant, BFF rules
yarn workspace @ulams/web test:e2e Playwright against the server on :4321: smoke tests of every tenant and the platform page, plus an axe WCAG 2.2 AA scan of every page type, desktop and 360 px phone (WEB_BASE_PORT for another port)
yarn workspace @ulams/web perf LCP, CLS, JS and transfer per page from a running production build
yarn workspace @ulams/web typecheck astro check + tsc
yarn workspace @ulams/web lint eslint

See .env.example. Everything is read at runtime on the server; nothing is inlined into the client build.

Variable Default Meaning
ULAMS_TENANT_HOSTS {slug}.app.localhost=>http://{slug}.localhost host rules, same syntax as the old front
ULAMS_ADMIN_URL http://{slug}.admin.localhost tenant admin (demo badge link)
ULAMS_PLATFORM_HOSTS app.localhost,localhost,127.0.0.1 hosts (port ignored) that serve the platform product landing
ULAMS_DEMO_TENANTS gravity,poland,ulam,coffee,oncall,nightsky demo academies shown on the platform landing
ULAMS_DEFAULT_TENANT coffee tenant for other hosts without a rule (e.g. a LAN IP); empty shows a picker
ULAMS_CACHE_TTL 45 seconds public API data is fresh; it is served stale for 30 min while refreshing
ULAMS_WARM_TENANTS gravity,poland,ulam,coffee,oncall,nightsky tenants fetched when the server starts
ULAMS_COOKIE_SECURE auto auto detects https from the request / X-Forwarded-Proto; true forces Secure cookies (proxy without the header), false only for a plain-http trial install
ULAMS_COOKIE_FALLBACK_PREFIX empty name prefix of the session cookies over plain http (dev on *.localhost), where the browser rejects __Host-
DEMO_STUDENT_EMAIL student1@{slug}.ulams.app fallback demo account when the tenant has no demo mode
DEMO_STUDENT_PASSWORD (empty) its password; keep it in .env (git-ignored)
browser ── HTML (SSR) ──────────────── Astro server (front/web) ── fetch ──▶ tenant API (http://<slug>.localhost)
│ │ middleware: Host → tenant, session cookie, auto demo login
│ │ in-memory SWR cache of public data (per tenant)
├─ /bff/api/… (allow-listed) ─────────┤ adds the httpOnly session token, forwards
└─ /h5p/… (iframe) ───────────────────┘ same-origin proxy to the tenant's H5P service
  • src/middleware.ts: tenant from the Host header; on /learn/* and /bff/* it makes sure there is a session (cookie, else a demo login: POST /api/demo/login, falling back to the password account).
  • Same-site content origin (ADR 0014, amended): the session cookies are __Host-ulams_session and __Host-ulams_author (Secure, Path=/, never a Domain), and every POST/PUT/PATCH/DELETE must carry the exact Origin of this site (or Sec-Fetch-Site: same-origin); src/lib/cookies.ts, src/lib/bff.ts (refuseCrossSite). A request from <slug>.content.<base> is a 403.
  • src/lib/data.ts: API access with the stale-while-revalidate cache (src/lib/cache.ts).
  • src/lib/view-model.ts: turns API responses into the data model documents bind to. It only uses API data; no invented numbers.
  • src/docs/<theme>.json: the landing page of each tenant as a catalogue document (src/docs/i18n/: the platform landing in Polish and Chinese).
  • src/lib/page-docs.ts: documents for the course page and each topic type in the lesson player.
  • src/pages: / (tenant landing, or the platform landing on a platform host), /courses/:id, /learn/:course (resume), /learn/:course/:topic, /learn/:course/finish, /account (profile, my courses with progress, logout), /events and /events/:kind/:id (webinars, in-person events, consultations), /login, /bff/*, /h5p/*, /healthz.
  • Platform mode: on a platform host (ULAMS_PLATFORM_HOSTS) there is no tenant; / renders src/docs/platform.json with the demo cards built in src/lib/platform.ts from each demo tenant’s API (learner link → /learn/:course on the tenant front, auto-login; admin link → the tenant admin, which logs in by itself in demo mode).
  • Tenant look: the theme preset comes from theme.theme in the API settings, the accent from theme.accent; src/lib/accent.ts turns the accent into --ulams-* variables on the server, adjusted to keep AA contrast.

Packages:

  • @ulams/sdk (front/sdk): createClient({ baseUrl, token }) with auth, settings, courses, progress, quiz, events, products; demoStudentSession(); tenant and topic helpers. Plain fetch, works on Node and in the browser (where baseUrl is /bff).
  • @ulams/ui (front/ui): the catalogue (src/registry.ts), the renderer (src/Render.astro, src/render-core.ts), components (src/components), web components (src/elements) and the theme tokens (src/styles, --ulams-* as in ADR 0004).

Topic types in the player: rich text (Markdown, tables, math as MathML), video (HLS loaded on play, chapters, transcript), audio, image, PDF, embed (YouTube/Vimeo), H5P (framed, xAPI to progress), SCORM (the API’s player page, or an explanation when the package files are not served), cmi5 and project (launch/brief cards), GIFT quiz (all eight question types through the quiz-attempts API). Progress: ping while visible, complete on the button, at the end of reading, at the end of a video, on a completing H5P statement or a passed quiz.

  1. Read the catalogue: front/ui/src/registry.ts, or yarn workspace @ulams/ui catalogue for JSON. Each component has a description, a JSON Schema of its props (explicit enums and defaults) and says whether it takes children.

  2. Write a document. The root is Page (theme, title, description); its children are SiteHeader, Main (all sections) and SiteFooter:

    {
    "component": "Page",
    "props": { "theme": "coffee", "title": "The Coffee Atlas" },
    "children": [
    { "component": "SiteHeader", "props": { "brand": "The Coffee Atlas", "cta": { "label": "Start", "href": { "$data": "/course/previewHref" } } } },
    { "component": "Main", "children": [
    { "component": "Hero", "props": { "variant": "editorial", "title": { "$data": "/course/landing/headline", "$default": "Learn coffee the slow way." } } },
    { "component": "Syllabus", "props": { "variant": "folio", "lessons": { "$data": "/course/lessons" } } }
    ] },
    { "component": "SiteFooter", "props": { "brand": "The Coffee Atlas" } }
    ]
    }
  3. Bind facts to the data model with {"$data": "/json/pointer", "$default": …} instead of writing them: course title, lessons, prices (/plans), events (/events), testimonials (/course/testimonials). Pointers are listed in SiteModel / CourseModel in src/lib/view-model.ts. Do not invent numbers, ratings or quotes.

  4. Validate before saving: validateDocument(doc, data) from @ulams/ui/render-core returns every problem with its JSON path. At render time invalid nodes become plain text, so a bad node never breaks the page, but it should not ship.

  5. Render: <Render doc={doc} data={model} /> inside the Base layout (see src/pages/index.astro).

(Short notes until the documentation site, built on another branch, covers the catalogue.)

ComparisonTable (front/ui/src/components/ComparisonTable.astro, schema in front/ui/src/registry.ts). Products in columns, features in rows; no JavaScript. Sticky header and first column, the column with highlight: true is tinted, rows highlight on hover, sections reveal on scroll (off with reduced motion). Below 1180 px the table scrolls sideways inside a focusable region with edge shadows and a “scroll sideways” hint. Accessible table markup: caption, th scope="col" and th scope="row". Cell values are neutral: Yes, No, Partial, Via plugin, Paid add-on, Not documented, Coming (Coming only for our own roadmap), or a short phrase for licence and pricing.

The platform landing feeds it from src/data/comparison.json: every cell is {value, note, source, checkedAt}, competitor sources are official docs, pricing pages or licences, and tests/unit/comparison.test.ts fails when a cell has no https source or date. To update a fact, change the cell, its source and checkedAt, and the top-level asOf; the table shows “As of …” and a Sources list under it. Plain product names only, no logos.

The table has two groups behind a segmented control built from radio inputs and CSS (no JavaScript; without :has() support both tables simply show): “Open source & creator platforms” and “Enterprise suites”, ulams in both. comparison.json has top-level groups; a system lists the groups it is in, a row lists groups or applies to all. The component takes groups (2 to 4 tables) instead of columns and rows. Enterprise-only rows: data residency, SSO, SCIM, authoring tool, content library.

Rows are grouped in sections, in this order: Developer & headless, AI, Content standards, Business (top-level sections in comparison.json; each row has a section). The component takes sections per group (label plus rows) and renders one tbody per section with a header row (th scope="rowgroup"); rows still works for a flat table. Developer & headless rows: REST API, headless course management, published OpenAPI spec, typed SDK, CLI, MCP server, webhooks, course-as-code, self-hosting, generative UI. “CLI” means a tool for authors and developers; an operator CLI is Partial. Keep the true status in the data (the CLI and MCP server have shipped; webhooks and course-as-code are still Coming); official sources only, see ADR 0020.

The platform landing has three animated stories, all catalogue components: LivingCourseStory (hero story: a changed source updates the lesson that cites it), BuilderStory (a document becomes a cited course) and WorkflowShowcase (tabs: Claude Code, Claude + MCP, CLI, REST & SDK, Studio). A tiny timeline player (front/ui/src/elements/story.ts, <ulams-story> and <ulams-workflows>) plays them when visible, loops or auto-advances slowly, pauses on hover, focus and a Pause button, and under prefers-reduced-motion (or without JS) shows the final frame. Space is reserved, so nothing shifts; screen readers get a description or a plain transcript. The workflow commands and each tab’s true status are in src/data/workflows.json (available, preview, coming).

The white-label section (WhiteLabelStory, id white-label, after the workflow tabs) is a four-step story on the same player, with no extra JavaScript: bring your brand (an agent with the ulams CLI and a design tool’s MCP reads Figma or Stitch and runs ulams theme set / ulams settings set, or builds a custom front on the headless API), get a themed site on its own subdomain, bring content (one cited course per source), see per-course analytics. A numbered list under the stage says the same in words. Its steps carry status and a today caption: in actual mode the per-course analytics step and the Living Course sync line read Coming (the built-in brand importer is a roadmap item, the agent route works today); in final mode statuses and today captions are removed. Under reduced motion the four scenes stack, each in its final state. Every name, colour and bar in the stage is an example.

The hero’s right side is the capability orbit (Hero prop capabilities, component CapabilityOrbit in front/ui): the Orbital Folio book with ten capability cards on three elliptical orbits, one highlighted every few seconds with a one-line caption; hover or focus pauses it, a Pause button stops it for good, and each card links to the section that shows the capability. The rotation is CSS transforms only; the cycling is <ulams-orbit> (front/ui/src/elements/orbit.ts, about 0.5 KB gzip). Under prefers-reduced-motion it is a static arrangement with every label visible; in a container narrower than 560 px the same list is a tile grid. A capability’s status shows a small “Coming” tag in actual mode only. Cards carry a start angle chosen so none overlap in the static arrangement.

ULAMS_LANDING_STATUS (src/lib/landing-status.ts) decides what the platform landing displays:

  • final (default while the product is in review): every roadmap item is shown as delivered. No Coming/Preview badges or roadmap captions, no footnote about planned interfaces, and ulams’s own comparison cells that say Coming or Partial read Yes.
  • actual: the honest status. The data files always keep the true status, and competitor cells never change.

The Playwright smoke test “platform landing sells the product” asserts the mode it runs in: start it with the same ULAMS_LANDING_STATUS as the server (unset means final), e.g. ULAMS_LANDING_STATUS=actual yarn playwright test against a server started in actual mode.

Before any public launch, run in actual mode or confirm that everything the landing shows has shipped. Nodes of platform.json may carry final: { …prop overrides… } for text that only reads right in one mode.

Landing languages (English, Polish, Chinese)

Section titled “Landing languages (English, Polish, Chinese)”

The platform landing is also at /pl/ (Polish) and /zh/ (Simplified Chinese); / stays English (ADR 0096). They are Astro i18n routes (i18n in astro.config.mjs) answered on a platform host only; tenant hosts answer 404 there.

  • src/docs/i18n/platform.pl.json, platform.zh.json: the same document as platform.json, with the visible text translated. tests/unit/landing-i18n.test.ts keeps them parallel (components, order, ids, links, icons, statuses, code and commands, product names) and fails on a missing or still-English text.
  • src/data/i18n/workflows.<lang>.json (same transcript, translated prose, same commands) and comparison.<lang>.json (labels, descriptors and value words; competitor notes, sources and dates stay English). src/i18n/demos.ts holds the demo-card texts. The fixed strings of the catalogue components (badges, accessible names, buttons) are in front/ui/src/lib/i18n.ts and follow Astro.currentLocale.
  • Each language has <html lang> (en, pl, zh-Hans), a canonical URL and hreflang alternates with x-default. The header has a language switcher (EN, PL, 中文) that is a control of its own, not one of the six links; it keeps the current #anchor (front/ui/src/elements/lang-switch.ts, the only script added).
  • Chinese uses the system font stack (PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans SC); no CJK web font.
  • ULAMS_LANDING_STATUS works the same in all three languages (the status is applied to the English data, then translated). Terms are fixed in src/i18n/GLOSSARY.md; the copy has no native review yet (owner-action issue).
  • Playwright: tests/e2e/landing-i18n.spec.ts (meta, hreflang, switcher, overflow at 360 px, axe, screenshots in tests/screens/landing-{pl,zh}-{desktop,phone}.png).

Production build, measured with yarn workspace @ulams/web perf (Chromium, local API): see docs/plans/phase-5-reference-frontend.md for the latest numbers. Budget: LCP < 1.0 s, JS < 30 KB gzip on landing and course pages, CLS < 0.05.