VelaWind · Projects

Lodestar

Interactive astrophysics, explained in seven layers you choose to open.
Live site · Source

A dark simulation page at mid-transit: a planet’s black disc sits centred on a pale star while the marker on the light curve below rests at the bottom of a 1.01% dip, beside sliders for the star’s mass and size, the planet’s size and its orbit, and readouts for transit depth, duration and orbital period.

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

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:

.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.