Skip to content

Add a web page

The showcase site in web/ is a SvelteKit app built to static files, which our own nginx server serves; every page is prerendered, takes its data from the Python package at build time, and runs the engine in the visitor's browser only when they interact. This page is for a contributor adding a page: where the route goes, where its logic goes (not in the component), how it is tested for errors, accessibility and performance, and how visitors find it. It assumes make web-setup has been run and that you have read the architecture standard in web/README.md.

The worked example is the compare page, /compare/GAME: one prerendered page per game in the catalogue, whose settings live in the URL and whose comparison runs on several engines.

The pieces of a page

Piece Where /compare/GAME
Route and build-time data web/apps/site/src/routes/…/+page.server.ts routes/compare/[game]/+page.server.ts
Page shell: title, description, layout +page.svelte routes/compare/[game]/+page.svelte
Logic: settings, URL, request, what to draw a DOM-free class in $lib/ or web/packages/models CompareSettings, RateBar ($lib/compare.ts), TournamentRun (engine package)
Drawing a component in $lib/components/ CompareView.svelte
Unit tests Vitest, beside the class $lib/compare.test.ts
End-to-end tests web/tests/e2e/*.spec.ts phase5.spec.ts

1. The route

Pages are files under web/apps/site/src/routes/. The root layout (routes/+layout.ts) sets prerender = true for every page, and the static adapter is strict, so the build fails if a page cannot be prerendered. Two consequences:

  • A dynamic route lists its pages. /compare/[game] exports entries from +page.server.ts, one per game in the bundled catalogue, and its load returns that game's catalogue entry as plain data (refusing an unknown name with a 404):
export const entries: EntryGenerator = () =>
  SiteCatalogue.bundled().games.map((game) => ({ game: game.name }));

export const load: PageServerLoad = ({ params }) => {
  const catalogue = SiteCatalogue.bundled();
  const game = catalogue.find(params.game);
  if (game === undefined) {
    error(404, `There is no game called ${params.game}`);
  }
  return { game, regimes: catalogue.regimes };
};
  • Data comes from files Python wrote, read at build time: the catalogue (SiteCatalogue, from the protocol's describe fixture), the replays (static/replays/), the lessons (static/lessons/) and the demo previews (static/demos/, which /monitor loads in its +page.server.ts). Return plain objects; the page turns them back into model classes, so no page's script carries more than it needs.

The page sets its own <title> (… · {SITE_TITLE}) and a meta description in <svelte:head>, uses one <h1>, and lives inside the shared layout's <main>.

Security headers. Every page carries a Content Security Policy as a <meta> tag (kit.csp in web/apps/site/svelte.config.js): scripts and connections only to this site, no inline scripts beyond SvelteKit's hashed ones. A page needs no change to it; if it needs a different header (a page meant to be framed, a new kind of static file), add a rule to web/apps/site/static/_headers, which web/tests/e2e/serve.ts applies in the tests and docs/how-to/embed.md gives as nginx configuration. Nothing is loaded from another host.

2. The logic: models, not components

A Svelte component composes classes; it does not compute what they own. Put the page's logic in DOM-free classes that follow the architecture standard (readonly values, behaviour with its data, invariants checked at construction, no module-level mutable state):

  • in web/packages/models when another app could use it (the play page's PlaySpec, LiveSession, MoveTargets; the demos' MonitorSettings, monitorView, DemoRunner), exported from src/index.ts;
  • in web/apps/site/src/lib/ when only the site needs it (CompareSettings in $lib/compare.ts).

Settings that change what the page shows belong in the URL, as the command-line flags do (CompareSettings reads and writes ?agents=…&seeds=…, and gives the identical grounded-decision compare command for Copy CLI command); per-visitor display preferences use DisplaySettings ($lib/settings.svelte.ts) and never go in a link.

A page that runs the engine creates it on the visitor's first interaction, through siteTransport() ($lib/engine.ts), so the replay or recorded preview paints first without Python. Requests go through Engine methods; a new kind of request is a protocol operation.

3. Embeddable elements, if the page should travel

If other sites should show the same thing, it belongs in web/packages/elements as a <gd-*> custom element (decisions D70 to D76), built by make web-elements and documented in docs/how-to/embed.md; the site page then uses the element or shares its models. Most pages do not need this.

4. Tests

Unit tests (Vitest). Every model class has a *.test.ts beside it: URL round trips, defaults, validation errors, the command text. They run in make web-test and make web-check.

End-to-end tests (Playwright). web/tests/e2e/ runs against the built site served as our server serves it (serve.ts applies _headers). For a new page:

  • add its route to the ROUTES list of site.spec.ts (indexes and games) or of the spec that owns its section (demos.spec.ts for /parameters, /monitor and /runtime; phase5.spec.ts for compare, audit and the teaching pages): those tests open it in both colour schemes, check it with axe for WCAG 2.2 A and AA (accessibilityViolations in support.ts), and fail on any console error or warning (ConsoleWatch);
  • test what the page does: the preview's numbers at once, a live run, the URL after a change, the keyboard path, a phone's width;
  • if its data changes while it is open (a game playing, a setting applied), mark its parts with data-region, build them from fixed slots (the design system in web/README.md) and add the page to layout.spec.ts, which fails, in Chromium and Firefox, when any region moves.

make web-e2e builds everything and runs the whole suite. While working on one spec, run it alone with its own output directory, so it never collides with another run's reports:

make web-build
pnpm --dir web exec playwright test --config tests/e2e/playwright.config.ts monitor-page.spec.ts --output test-results/monitor-page

Lighthouse. make web-lighthouse runs Lighthouse CI on the pages listed in collect.url of web/tests/lighthouse/lighthouserc.cjs, and every category (performance, accessibility, best practices, SEO) must score at least 95. Add a page there when it is a new kind of page (a new section's first page); /runtime stands for the demo pages and /play/tictactoe for the play pages.

5. Visitors find it

  • The header ($lib/components/SiteHeader.svelte) links the sections: Play, Gallery and Learn. A new section adds a link with aria-current for its routes; a page inside a section is linked from that section's index or from the pages it belongs to (each play page links its compare, audit and teaching pages).
  • web/README.md lists every route and the classes behind it: add the page to the table of its section.
  • If the documentation describes the feature, link the page from the docs page, and add a CHANGELOG entry.

Definition of done

  • [ ] Route under routes/, prerendered (with entries for a dynamic route), build-time data as plain objects, title, description and one h1.
  • [ ] Logic in DOM-free classes (models package or $lib/), following the architecture standard; settings in the URL with the identical CLI command where there is one.
  • [ ] The engine starts on interaction; nothing loads from another host; _headers only if the page needs a different header.
  • [ ] Vitest tests for every class; the route in an e2e spec's ROUTES (axe in both schemes, no console errors) and tests of its behaviour.
  • [ ] In lighthouserc.cjs if it is a new kind of page.
  • [ ] Linked from the header or its section, listed in web/README.md, CHANGELOG entry.
  • [ ] make check WEB=1, make web-e2e and make web-lighthouse green.