Lodestar
Interactive astrophysics, explained in seven layers you choose to open.
Live site · Source
The problem
An astrophysics topic has to reach two readers who share almost no vocabulary. Someone who has never studied physics needs an analogy and a picture before a symbol means anything at all. Someone who works in the field wants the derivation and the assumptions behind it, and stops trusting a page the moment it waves a hand at something they know is harder than that. Lodestar covers seven topics, and each one has to serve both readers from a single page.
Why the obvious approach did not work
The obvious answer is two versions of every topic, a beginner page and an advanced page, chosen by a toggle. Lodestar does not do that, because parallel texts drift. Every correction has to be made twice, the second copy is the one that gets missed, and after a few rounds of editing the two versions contradict each other on the same facts. The failure is quiet: nothing breaks, the pages just stop agreeing.
So there is one text per topic, written once, structured as seven layers that run from a one-sentence hook down to the open research questions. The depth setting in the header decides which layers start open, and that is all it does. It never changes a word. The mathematics is on every page, folded shut by default, which means the beginner is never lied to and the specialist never has to go looking for a different version of the site.
One trade-off, and what it cost
The glossary panel is portalled out of the React tree
onto document.body and positioned fixed. That is not
the natural way to build a tooltip, and it was forced: every layer body
sits inside the accordion's overflow-hidden box, which is
what makes the height animation read as a collapse, and it clips
horizontally as well. A panel rendered next to the word it defines gets
sliced off at the panel edge, and an absolutely positioned one is at the
mercy of any ancestor that later gains a transform. Leaving the tree
entirely is the only placement that cannot be clipped.
The cost is that the panel is no longer positioned or ordered relative to
the word it belongs to, and both halves of that had to be paid for
separately. Because it is pinned to the viewport rather than to the term,
it has to re-measure on every scroll event and only treats a scroll as
"the reader has moved on" once the page has been still for 200
milliseconds, which is also how it survives the smooth scroll that Tab
uses to bring a term into view. Because it renders at the end
of <body>, where nobody is reading, a screen reader would
hear the expanded state and then nothing, so the definition is spoken
through one persistent aria-live region created before the
text exists and shared by all the terms on the page. A visual fix turned
into an accessibility obligation, and the second part is the part that is
easy to miss.
The rest of this page is fetched from the Lodestar README at build time and rendered here, so it is maintained in the repository and not in this site. It covers the architecture, the stack, how the project is verified, and, at the end under "Known limits", what the project still owes.
Architecture
- A module is data, not code. One typed file in
src/content/modules/(prose as a serialisable AST, params, equations, references) plus one canvas component insrc/sims/.src/content/registry.tsbuilds both maps withimport.meta.glob, so adding a module needs no wiring in the shell. - One source of truth per quantity. Formulae and constants live in
src/physics/, shared by the animation loop, the readouts and the equation renderer. The simulations hold no physics of their own: all seven import from@/physics. - Canvas first. Simulations draw in
requestAnimationFrameloops reading refs, so dragging a slider does not re-render React per frame. Sims are lazy-loaded per route. - Terms are marked, not re-explained. A
termnode in the AST is a leaf: visible words plus a glossary id, so a definition cannot come to contain a link or an equation. Its panel is portalled todocument.bodyand positionedfixed, because every layer body sits inside the accordion'soverflow-hidden. - A sanity suite guards the physics.
src/physics/sanity.tsrecomputes known quantities (Earth's orbital period, the Schwarzschild radius of the Sun, light travel times: 33 checks in 8 blocks) through the code paths the simulations use, logging on dev boot and asserted in tests. - Every route serves its own head.
scripts/routeHeadsPlugin.tsemits one HTML file per route at build time from the registry (9 routes, 17 files) plussitemap.xml, so a shared module link unfurls as the module rather than the front page.
Stack
Vite 5, React 18, React Router 6, TypeScript (strict, plus noUnusedLocals and noUnusedParameters), Tailwind 3, Zustand 5, Framer Motion 11, KaTeX, Canvas 2D. Deployed on Vercel.
Tests
npm test is 324 Vitest tests across 8 files, in a Node environment:
- the 33 physics sanity checks as assertions;
- equation snapshots, so a formatting change cannot quietly rewrite the maths;
- a copy snapshot pinning every word a reader can see (layers, glossary, About page, captions, credits, alt text), so a copy edit fails until the snapshot is updated deliberately;
- canvas replay of each simulation at phone-to-desktop widths against a recording context, which catches labels drawn outside the frame;
- the sonification synthesised into a buffer with its frequencies measured back out;
- content-structure rules, including that every
termreference resolves in the glossary and every entry is marked somewhere; - readout formatting, and tooltip placement at the awkward edges.
.github/workflows/ci.yml runs typecheck, lint, test and build in that order on every push and pull request to main, on Node 22.
The browser suite is separate: playwright.config.ts defines five projects (Chromium at two viewports, WebKit, Firefox, mobile WebKit) covering tooltip journeys, figures, axe accessibility passes, keyboard operability and the not-found route. It starts no dev server, so baseURL defaults to the live site and E2E_BASE_URL retargets it at a local npm run preview.
Known limits
Some files are very large and heavily commented: sanity.ts is 964 lines and the escape-velocity simulation 758, which is awkward for a second contributor to navigate. No unit test renders a React component, so component behaviour is covered only by the Playwright suite, which needs a live deployment; a component regression is invisible to npm test and to CI. Two topics named in Connections layers were never written.