App shell and routing
How a request becomes a screen: the entry points, the providers, and the hand-rolled router.
The three entry points
src/server.ts - the server
A small Hono app run by Bun.serve on port 3000 (or PORT). Its route table:
/assets/*- static files, tried indist/first, thenpublic/./docsand/docs/:slug- the documentation site, rendered from Markdown by the docs renderer;/docs/:slug.mdserves the raw Markdown instead.*- the SPA fallback: returnindex.htmlfor any non-asset path, so the client router can resolve the level from the URL. Asset requests that fell through stay 404s.
In development (NODE_ENV not production) every response gets
Cache-Control: no-store, because the watchers rewrite dist/assets in
place. The app construction lives in an exported createApp() so tests can
exercise the real routes through app.request(...) without binding a port;
Bun.serve only runs when the file is executed directly (import.meta.main).
index.html - the static shell
The HTML the server returns for every non-asset route. It holds the <head>
(meta tags, theme colour, favicon, stylesheet link), a header and footer, the
#root element the client renders into, a bilingual <noscript> warning
(the page must still say something with JavaScript off), and the client
bundle script. The header/footer text is filled in by ShellText once the
app mounts, because it must be translatable.
The footer has three slots: an empty spacer, the centred copyright, and a
"Documentation" link on the right. That link points at /docs with a plain
anchor (not the client Link), so the browser makes a full navigation to
the server-rendered guide. Both the copyright and the link text are
translated through the app_footer_copyright and app_footer_documentation
messages.
src/client.tsx - the browser entry point
Four lines: import the app, find #root, render(<App />, root) with
hono/jsx/dom.
The provider stack (src/App.tsx)
App composes the providers that hold the app-wide state:
LocaleProvider
AppShell (keyed by locale)
ToastProvider
SkinProvider
Router the current page
SkinChooser / SkinSwitcher / LanguageSwitcher floating controls
ShellText header/footer/meta text
- LocaleProvider holds the Paraglide locale, sets
<html lang>, and is keyed by locale: switching language remounts the whole subtree, so every rendered message is re-evaluated with no reload and no locale in the URL. - ToastProvider owns the toast list (
lib/toast.tsx): max 5 toasts, each auto-dismissing after 10 seconds, timeouts cleaned up on unmount. - SkinProvider loads the stored player skin, applies
data-player-skinto<html>, and offers the chooser/switcher. - ShellText writes the translated header, footer and
<meta name="description">on mount and on every locale change (they live outside the app tree inindex.html). - One small effect in
Appstamps the browser engine (data-browser-engine) vialib/browserEnginefor CSS quirk handling.
The client router (src/lib/router.tsx)
Routing is a hand-rolled pushState/popstate router, not Hono routing:
^/levels/([^/]+)$rendersLevelPage;/redirects to the stored last level (level 1 when there is none); any other path rendersNotFound.^/build(?:/([^/]+))?$renders the builder, with an optional share payload to edit.^/shared/([^/]+)$renders a shared level. See Level builder.Linkis a small anchor wrapper that callsnavigate(href)(pushState + a syntheticpopstate) so in-app navigation never reloads the page.- On first mount,
/is replaced with/levels/<slug>viahistory.replaceState, so the URL always shows a level.
Because /docs is a server route, links to the documentation use plain
anchors, not the client Link, so the browser performs a full navigation to
the server-rendered page.
The level page (src/pages/LevelPage.tsx)
LevelPage resolves the slug through the level registry
(domain/level.ts) and renders <Level> with the raw data, the next slug and
the previous slug. An unknown slug renders a "coming soon" placeholder with a
link back to level 1.
The level screen (src/features/level/Level.tsx)
Level is the orchestrator described in the data flow below. It:
- Takes either raw level data (parsed once with
parseLevel; invalid data renders an error message instead of a broken board) or an already builtLevelData, which is how the shared level page reuses it. - Holds the program state via
useSequenceand the run state viauseSimulation(both infeatures/level/hooks/). - Wraps everything in
DndProvider(the drag-and-drop context). - Renders, side by side: the
Playgroundwith thePalettebelow it on the left, and theLevelPicker(or, for a shared level, the title/author header),SequenceZone(the program) andControlson the right.OutcomeModal,ExportModalandToastStackoverlay the rest. - On a failure status, shows a translated toast explaining the outcome; on a
win, records completion through
completion.ts. The win modal also offers the builder once every built-in level is complete.
The exact data flow is diagrammed in The program tree and Simulation.
Next: Levels overview.