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: every simulation imports 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/recomputes known quantities (Earth's orbital period, the Schwarzschild radius of the Sun, light travel times: 205 checks in 23 blocks) through the code paths the simulations use, logging on dev boot and asserted in tests. Each block has its own file, mostly named after the physics file it checks, with the shared tolerances and logging incore.ts. - Every route serves its own head.
scripts/routeHeadsPlugin.tsemits one HTML file per route at build time from the registry (24 routes, 47 files) plussitemap.xml, so a shared module link unfurls as the module rather than the front page. Each file also carries schema.org data (aLearningResourceper module, with no author or other personal details) and a<noscript>summary, so a crawler that does not run the app still reads the page's subject. - Built to fail gracefully. A chunk that fails to load (a 502, a dropped connection) shows a page with a retry button instead of a blank screen (
LoadErrorBoundary), and the app manages scroll itself per history entry, so Back returns readers to their place and each in-page jump has an entry of its own. Hashed assets are served as immutable for a year while HTML revalidates on every visit, andvercel.jsonsets the security headers, but deliberately no Content-Security-Policy, because KaTeX and the motion library set inline styles that a wrong policy would silently break. - Usage statistics are opt-in. A bar at the foot of the page asks once whether Lodestar may count visits with Google Analytics 4, and nothing from Google loads before the reader allows it. Consent Mode starts with everything denied and grants analytics storage alone, Google's advertising and data-sharing options are off, and page views are sent per route change because the site is a single-page app (
src/lib/analytics.ts). The About page states what is collected and can change the answer.tests/e2e/consent.spec.tsintercepts every request to Google and checks that none is made before Allow or after No thanks, that a page view follows each route after Allow, and that the About page takes the permission back; every other spec starts as a reader who chose No thanks.
Stack
Vite 6, React 18, React Router 7, TypeScript (strict, plus noUnusedLocals and noUnusedParameters), Tailwind 3, Zustand 5, Framer Motion 11, KaTeX 0.19, Canvas 2D. Tested with Vitest 4, React Testing Library and Playwright. Deployed on Vercel.
Tests
npm test is 1191 Vitest tests across 27 files. 25 of the files run in a Node environment and 2 render React in jsdom:
- the module page shell, rendered as the app renders it with React Testing Library: the title in the heading and the tab, all seven layers in order, the layers each depth tier opens, a layer opening and closing from its header, one labelled slider per parameter, the simulation's readouts, the step on the learning path, and the not-found page;
- one simulation's controls end to end: moving the escape-velocity mass slider changes the slider's label, the escape-speed readout and the equation in the Numbers view together, and the escape speed recomputed from the numbers the equation shows matches the readout; the launch-speed slider updates the apex equation and its "where" line; Reset puts every view back;
- the 205 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;
- slider stops on every param: the default is a stop and both ends clamp to the exact bound;
- the learning path against the registry: every module on it exactly once, steps numbered without gaps, and every module's declared prerequisites existing, earlier on the path, and free of cycles.
.github/workflows/ci.yml runs typecheck, lint, test and build in that order on every push and pull request to main, on Node 22; once those pass, a second job runs the Playwright suite on the two Chromium projects against vite preview of the build, and keeps the HTML report as an artifact when it fails.
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, the not-found route, scroll restoration on links, Back and Forward and in-page jumps, recovery from a chunk that fails to load, and each route's served head and structured data. It starts no dev server, so baseURL defaults to the live site and E2E_BASE_URL retargets it at a local npm run preview.
Performance
The table records the October 2026 performance pass, measured in the lab against npm run preview: Lighthouse 13 in mobile mode (simulated slow 4G, 4× CPU), median of three runs per route, and a Playwright frame-rate test at 4× CPU slowdown on a 390 px phone viewport (5 s idle with the sim on screen, then a 3 s slider drag at 60 input events per second).
| Before | After | |
|---|---|---|
| Largest contentful paint, home | 2.14 s | 1.87 s |
| Largest contentful paint, module pages | 2.59–3.20 s | 2.26–2.48 s |
| Largest contentful paint, About | 2.59 s | 2.17 s |
| Total blocking time, worst route (early universe) | 133 ms | 98 ms |
| Cumulative layout shift, every route | 0 | 0 |
| Idle frame rate, lowest module | 56.8 fps | 59.2 fps |
| Slider drag, median across modules | 50.9 fps | 55.5 fps |
| Slider drag, slowest module (early universe before) | 24.4 fps | 44.8 fps (hawking radiation; 50.8 median of four runs) |
| Entry JavaScript, gzipped | 145.1 kB | 23.8 kB |
A returning visitor now loads the scripts from cache, since every hashed asset is served as immutable; and all of these figures are from the lab, not from field data.
The entry chunk has grown since that pass with the features added after it: it is now 27.8 kB gzipped, and the React vendor chunk (React, React DOM and the router) is 59.1 kB. The November 2026 dependency update added 6.3 kB to the vendor chunk, almost all of it React Router 7. Lighthouse runs alternating the build before and after that update on one machine, five of each, found the same largest contentful paint, a median total blocking time within noise on the home page (145 ms before, 141 ms after) and about 30 ms higher on early universe (359 ms before, 391 ms after); run-to-run spread was about 60 ms. Those runs used a different machine from the table above, so their absolute values are not comparable with it.
Headless Chromium draws canvas without a GPU, so the frame rates are pessimistic; most of a drag's time in the profile is software canvas drawing. What changed: slider ticks re-render only the sim and its readouts, not the page; sims read their size from a ResizeObserver instead of forcing a layout per paint; label widths are cached; the early-universe scale factor comes from a table built once (agreeing with the old bisection to about 10⁻¹¹) instead of 240 000 integrand evaluations per frame; the starfield pauses while a sim is on screen; each module's content loads on its own page, with a build-time manifest for the index; framer-motion's features and KaTeX load only when needed; module pages preload their chunks; and figures ship a 640 w copy, plus a 1080 w one where the original is wider than that.
Known limits
Some simulation files are large and heavily commented: the largest, dark-matter, is 1050 lines and supernovae 926, which is awkward for a second contributor to navigate. The sanity suite, once a single 3031-line file, is split into 25 files of at most 209 lines. Component tests cover the module page shell and one simulation's controls (escape velocity); every other component's behaviour is covered only by the Playwright suite. CI runs it on the two Chromium projects (a phone and a desktop viewport) against a preview of the build, after the typecheck, lint, unit tests and build; the WebKit, Firefox and mobile-WebKit projects run only locally, so an engine-specific regression in Safari or Firefox is still invisible to CI.
The learning path's order is checked through declared prerequisites: each module lists the topics it assumes in src/content/path.ts, and a test enforces that they come earlier. What a test cannot judge, a glossary term used before the topic that teaches it, npm run path:audit lists for manual review.
The maths layer's Numbers view substitutes each slider's value in SI base units, because the constants in the equations are SI; a "where" line under each equation gives the slider's own reading (v₀ = 8 km/s beside 8000 m/s in the formula) for every substituted value shown in another unit.
How the physics was checked
Claims that can be computed are recomputed through the site's own physics code before publication, and the sanity suite keeps 205 of those checks running, through the same code the simulations run, on every test run. Every module's physics was also checked by separate AI reviewers that had not seen it being written, twice for most modules, followed by whole-site audits and one review by a model from a different AI developer. Each correction those reviews found was checked against its primary source before it was applied. No human astrophysicist has reviewed the site yet. Errors can be reported on the repository's issues page.