The architecture of this site and the design decisions behind it, read from nine months of commits
A record of how InfraPhysics is built, reconstructed from its 1,148 commits between January and October 2026. It describes the system as it stands, the nine stages it went through and the decisions taken in each layer (content, media, delivery, styling, performance, state at the edge), each one with the constraint behind it and its cost. It also reads the repository as a testbed for the coding models that wrote most of it, and stops where the history stops saying who wrote what.
The first write-up of this site covered eighteen days in January and February: a React app, a markdown compiler and a graph of notes, built by someone who had never built a website. Nine months later the same repository holds four products, a build that writes every index, feed and card the site serves, and an edge layer that answers crawlers, counts visits and runs an assistant. Most of what changed in between is recorded in the commit history, and from September the commits say why as well as what: the messages became full sentences that name what was wrong and what replaced it.
This piece reads that history as a log. It is organised like a report. It first says what the system is today, then how the history was read, then what the nine stages were, then the decisions layer by layer, each told in the same shape, then what it meant to build all of it with coding models, and last what it costs to keep the whole thing true. The decisions are the core; everything before them is there so they can be checked.
Day one26 · jan 21Read noteThe first commit is called Initial commit and the second First iteration (migrated). Twenty of the early ones are just called Checkpoint. The build script that turns markdown into pages arrived two days later with 135 lines. It is past four thousand now.
What the system is
InfraPhysics has no database, no server that renders pages and no content management system. Everything a reader sees is computed in one of two places: at build time on my machine or in CI, or at the edge when a request arrives. The sources are markdown files with YAML frontmatter, about 2,830 of them: articles in three categories (projects, essays and Bits2Bricks), 1,531 notes in the wiki with another 560 files that hold questions between notes, 243 concept cards and 415 questions for Lobeworks/17, and the Mandarin notebook, which is a snapshot of a public Anki deck pinned to a version. A build turns them into JSON, HTML and a handful of indexes, and Cloudflare Pages serves the result as static files to a React application.
media pushMarkdown sourcesarticles, notes, cardsImage masterskept out of gitBuildcompiler, checks, indexesR2 CDNimages and share cardsStatic siteshell, JSON, playgroundsEdge functionscrawler text, counters, assistantBrowserthe React app
Three pieces live outside the build. Images go to R2, Cloudflare's object storage behind a CDN subdomain, through a script of their own. The share cards that a link shows on X or WhatsApp are photographed by a headless browser and stored there too. And a small set of Workers and Pages Functions handles what static files cannot: the text a crawler needs from a page that is otherwise an empty <div>, the visit counters, the newsletter interest form and the assistant that answers questions about the site.
The repository is one tree for all of it. The folders below are the ones that hold code or decide behaviour; private material and personal records live elsewhere and are left out of every count in this piece.
srcthe application and everything compiled into it components, viewsReact, one view per route
lib/contentthe compiler's blocks, links and validation
stylesone cascade of colour tokens, 32 sheets
data/pagesthe markdown sources
notebookthe Mandarin notebook, a standalone page template
Build and edgewhat runs outside the browser scripts48 build and maintenance scripts
functionsPages Functions for crawlers, counters and the assistant
workersone Worker that owns the counters
vite-pluginsimage sync for the dev server, a shader filter
Assetswhat the site serves as files mediaimage masters, uploaded and never committed
publicstatic files and generated indexes
For agentswhat steers the coding models CLAUDE.md, AGENTS.mdthe instruction file and its pointer
.claudehooks, skills and permissions
.githooksthe pre-commit hook that renders share cards
In code, the system is about 91,000 lines today, counted over the application, the build scripts and the edge functions, with the markdown, the generated JSON and the vendored dictionary left out.
Area Lines What it holds
Application 42,645 views, components, hooks, the compiler's browser half
Styles 16,846 the token cascade and every page's sheet
Build scripts 9,265 compiler, validators, generators, media, share cards
Wiki console 8,174 Undernotes, its graph and its search
Lobeworks/17 5,077 the second atlas and its WebGL ground
Mandarin 3,806 the notebook page and its frame
Edge 2,180 Pages Functions and the counters Worker
Admin 1,787 the analytics control room
Assistant 755 the panel that grows out of the bar
The styles are a fifth of it, which says something about where the time went. I am an industrial engineer, and a frontend engineer would probably have written half of those sheets.
How the history was read
The primary source is the git log: 1,148 commits between 21 January and 20 October 2026, with their messages, the files they touch and the Co-Authored-By trailer that names the coding model when there was one. The second source is CLAUDE.md, the instruction file for coding agents. It is touched by 217 commits, more than any other file in the repository, and its Gotchas section works as an incident log: each entry was written right after a bug was found, says what broke and why, and carries the date when it matters. The third source is the documentation that ships with the code, which records several decisions with the reason the author gave at the time.
Three choices shape what follows. The stages are cut by what was being built and by the two pauses in the history, from early April to late June (nine commits) and from 6 July to 1 September (none), so they are uneven by design. Lines of code are counted net per week, written minus deleted, over src, scripts, functions, workers and vite-plugins, with markdown, JSON, the notebook's data and legacy files left out. And authorship is read from the trailer and from nothing else.
Every decision in the record below is told in the same shape, so that two of them can be compared:
Constraintwhat forced a choice, usually a failure that had already happened
Before and afterthe two states side by side, aspect by aspect
Costwhat the new state broke, or what it still costs to keep
Read the authorship figures as a floor.
The trailers were removed on purpose until March (a commit on 6 February told the commit skill to leave them out), and Codex, which also worked on the repository, writes no trailer at all. A commit without one was written by a person, by Codex, or by Claude Code before March, and the history cannot tell which.
Nine stages in nine months
The history falls into two pushes, January to March and September to October, with a quiet spring and summer between them in which only articles moved. Each push starts with a rebuild of something the previous one got wrong. The busiest week is the one that starts on 28 September, with more than three hundred commits: the assistant, the analytics panel and the first version of the second atlas all landed in it.
Stage Dates Commits What was built
E1, a first SPA Jan 21 to Feb 5 73 React with the Tailwind play CDN, the first build script, the first notes
E2, one file per note Feb 6 to 17 190 notes split into files with ids, an incremental cache, a validator, the first agent rules
E3, editor and crawlers Feb 18 to Mar 31 171 a browser editor, counters on KV, comments, the graph explorer, text for crawlers
E4, quiet months Apr to Aug 36 articles only, and a purge of em-dashes across every text
E5, the September rebuild Sep 1 to 12 91 readable URLs, the media pipeline, share cards from a headless browser
E6, one visual language Sep 13 to 27 75 the front door at /hello, the site restyled to match it, the Undernotes console
E7, assistant and control room Sep 28 to Oct 1 178 the public assistant on a budget, the analytics panel, the list of living parts
E8, Lobeworks/17 Oct 2 to 8 229 a second atlas, Tailwind compiled at build, real 404 pages, the Anki cards
E9, scale Oct 9 to 20 106 481 notes in one batch, the wiki index in two halves, the notebook as its own page
The week-by-week picture, with the commits coloured by model and the running count of code under them, is in the repository timeline. Two things show there that the table hides. The line of code barely moves between March and September, then climbs from about 30,000 to about 90,000 lines in seven weeks. And the commit messages change shape in the last week of September: until then they follow the conventional feat(scope): form, and from then on they are full sentences that say what the reader of the site will notice and why the change was made. That shift was deliberate. By then the next reader of a commit message was usually an agent starting a session with no memory, and a sentence that carries its reason saves it from undoing the change.
Back26 · sep 01Read noteThe first commit after the summer touches 410 files and carries no trailer. Most of what follows in this table is the rewrite it started.
The record of decisions
The decisions below are grouped by the layer they changed, from the sources to the edge: content, media, delivery, styling, performance and state. Each layer has one or two turning points; the smaller ones are mentioned in passing. The order inside a layer is chronological.
Content: the markdown is the database
The site started with all of its notes in one file, which every edit to any note had to open and every merge had to reconcile. On 6 February the notes were split into one file each with their own frontmatter, and the build gained an incremental cache keyed on each file's content. On 15 February the file names moved from the note's address (web dev//build) to a random eight-character id, because an address changes when a note moves in the hierarchy and an id does not: every link in every article is written against the id, so a rename touches the note and nothing else.
Before
After
one shared file Where a note lives one file per note, named by a stable id
the note's address What a link points at the id; the address is a field
edit every file that links to it A rename one script, one file changed
shows as plain text A broken link fails the build
The compiler grew the same way, by refusal. Markdown cannot express a typed box, a context note in the margin, a definition list or a sequence of steps, so each got a small extension with one syntax and one rendering, and each later got a check: an unknown id fails the build, a code block without a language that draws a diagram is reported with the form that should replace it, and the writing rules that can be checked mechanically (no double quotes, no arrows, no lists of two) print [STYLE] warnings on the source line. The pipeline is one command:
1Read the frontmatterdates quoted2Compile the body with marked, Shiki and KaTeX3Resolve every linkan unknown id stops the build4Check the writing ruleswarnings on the source line5Write the JSON, the sitemap, the feed and llms.txt
The cost is that nothing appears until the build runs, and that the build is now the place where every editorial rule lives, which makes it the file most often touched after the instruction file. It also means the site can be checked like code: the CI runs the build, the type check, the tests and a check of the documentation on every push to the main branch, so a broken link fails there before it reaches a reader.
In hindsight.
The browser editor built in February (a CodeMirror pane on the dev server, with a small API that wrote the files back) was the wrong investment. By September the notes were edited as files by agents and by me in an editor, and the in-browser one was deleted without ceremony inside a commit about something else. Files were the interface all along.
Media: from wrangler by hand to a manifest
Until September every image reached the CDN by hand. I uploaded each one with wrangler, Cloudflare's command-line tool, under a name invented on the spot (6184744-banner.png), pasted the URL into the article and, when the CDN or the browser kept an old copy, appended a version by hand: one commit in July adds ?v=2 to a banner for exactly that reason. The repository had no record of which images existed, which article used which, or whether a URL in a draft pointed at anything at all.
By hand
Pipeline
wherever I saved it Where the master lives media/, by article and by kind
one wrangler command per file How it reaches R2 uploaded a few seconds after saving
whatever was uploaded Formats WebP for pages, a JPEG twin for share cards
a version typed by hand Cache busting a content hash stamped by the build
nothing What the repository knows a manifest of every object, tracked
a broken picture in production A missing image a [MEDIA] warning at build
The masters now sit in a tree whose folders say what each image is for, and the dev server pushes a file to the bucket as soon as it is saved:
mediamasters, out of git articles//coverthe hero of one article
articles//figuresillustrations in its body
site/art for the site's own pages
src/data/media-manifest.jsonevery object in R2, with its hash, tracked
The manifest is the decision that matters. Because it is committed, a build in CI that has no credentials for the bucket still knows every image, stamps each URL with its hash and warns about any URL whose key is missing (a master never pushed, or a name mistyped). The masters themselves stay out of git, which keeps the repository small and means they need a backup of their own. The JPEG twin is what the share tags point at, since link previews are less forgiving about formats than browsers.
Delivery: a single-page app that a crawler can read
A single-page app sends every visitor the same empty shell and lets JavaScript draw the page, which a search engine or a link preview never runs. Static generation would have solved that at the root, and Astro would have been the better tool for a site that is mostly text. I kept the SPA for the reason the first write-up gave: one framework, one router, one way of thinking about the whole thing, and parts of the site (the wiki console, the notebook, the analytics) that are applications more than pages.
The price is paid at the edge. A Pages Function reads a manifest the build writes for every URL and puts the right title, description and Open Graph card into the head of the shell before it leaves; crawlers also get the page's text and structured data. The same function decides status codes: a URL with no entry gets a real 404 with noindex, and the build writes a 404.html so that anything outside the function's routes is a 404 too. Until early October every unknown URL answered 200 with the lost-robot page drawn by the app, which search engines read as a page. Every shared URL now has its own card, photographed from a design route by a headless Chrome inside the pre-commit hook, which redraws only the cards whose text, cover or design changed and stages them into the same commit.
A route that is not in the include list is a static file.
Cloudflare Pages runs a function only for the paths listed in _routes.json; any other request is answered from the static files, where the SPA's fallback returns index.html with status 200. Two new API endpoints shipped without their entry in September, every request to them got HTML instead of JSON, the client swallowed the parse error, and the visit counter on the home page sat frozen for a week before anyone looked. The lesson is to probe the live URL with curl after every new endpoint and look at the Content-Type.
Two smaller findings belong here because they are invisible from the code. WhatsApp builds its preview on the sender's phone and fetches the image over TLS 1.2; the CDN domain had its minimum set to 1.3, so every WhatsApp preview came out bare while X showed the card. And WhatsApp reads only the start of a page, so the share tags now go right after the charset declaration instead of at the end of a head that carries twenty kilobytes of inline scripts.
Styling: one cascade, compiled late
Colours never appear in components. They are custom properties on the root, redefined for the light theme, mapped to semantic Tailwind names, and used only through those names; the accent of each category is the one exception, the same in both themes. Tailwind itself was loaded from its play CDN for eight months, compiling classes in the reader's browser, and was compiled at build only on 2 October, with the same version and the same configuration, so that nothing would change on screen. Waiting that long cost little, and the move broke one thing I had not thought of: classes built by string interpolation (bg-${color}) produce no CSS at all once a compiler has to find them in the source, so every dynamic colour had to become a custom property set on the element.
Since 8 September the site is dark everywhere by default, as one atmosphere, and light is a single preference the reader sets once. Light is kept working, and every new component is designed in the dark first and checked in light after.
Performance: the wiki is the heavy part
Almost every performance problem the site has had came from the wiki, because it is the one part that loads a graph of all its notes. The index started as a file bundled into the JavaScript, became a file fetched over HTTP in February, and became two files in October: every note without its body first, the bodies later, when the page goes idle or when a search needs them. The October split was forced by a measurement. A provider that held the old console's state wrapped the whole application, so every first visit to any page downloaded about 2.8 MB of wiki it never showed; the tab titles came from a three-megabyte manifest fetched on every change of page. Both were moved to files the size of what they serve.
The layout of the graph was the other half. The first 160 steps of the force simulation took about a second on a laptop and nine seconds with the CPU throttled four times, all in one task, so the page froze while the wiki opened. On 27 September the layout moved into a web worker sharing its forces with the page through one module, and a layout at rest is now saved as a seed (in the session, in the browser, or precomputed by the build) keyed by a fingerprint of the exact input, so a stale seed is never used. The edges of the 3D view are one draw call instead of two thousand, and on a screen scaled to 150 % their stroke is capped at one device pixel, because at 0.8 CSS pixels the GPU tessellated three thousand segments and that alone halved the frame rate.
Put the heavy work where the reader is not waiting.
Each of these fixes moved work out of the moment the reader is watching: into the build (seeds, indexes, share cards), into a worker (the layout), or to after the first paint (note bodies, the search palette's code). None of them made the work smaller. A note opened from a link now draws from its own small file before the index exists, and the console takes over underneath without moving the text by a pixel.
State at the edge
The site keeps very little state, and all of it at the edge. View counts and likes started in February as keys in Workers KV, whose writes are only eventually consistent. In September a Worker backed by a Durable Object was built to own them, with a migration from KV staged behind a switch; it deduplicates visits and keeps hourly series for seven days before folding them into days. Visits from browsers that never report activity are counted apart as suspected bots, and my own visits are excluded by a cookie and a list of addresses, both kept as secrets. The analytics panel draws its Sankeys, treemap and calendar by hand in SVG, because its content security policy allows only the page's own scripts and so rules out every chart library. Deployment is a push: Pages builds and publishes every commit to the main branch, which has never needed more thought than that.
The repository as a testbed for coding models
The site was built with coding models from the second week, and the history lets me say a little about how that went, though less than I would like. The commits name five models in their trailers. What they cannot show is the work no trailer records: the first two months, when attribution was switched off, and Codex, which signs nothing.
Model in the trailer First Last Commits
Claude Opus 4.6 Mar 12 Mar 12 1
Claude Opus 4.8 Jun 27 Jul 6 33
Claude Fable 5.1 Sep 6 Sep 21 82
Claude Opus 5 Sep 17 Sep 17 1
Claude Opus 5.5 Sep 25 Oct 20 570
No trailer Jan 21 Oct 20 463
Read against the stages, the table says one useful thing: the second push is almost entirely the work of models that arrived during it. The September rebuild was done with Fable 5.1 in web sessions, and from the last week of September Opus 5.5 wrote nearly everything, including the commits that describe this article. The jump in the line of code in the timeline is that change of tools as much as a change of ambition.
The instruction file is an interface
The first rule I wrote for an agent, in February, said to do only what was requested and nothing more, after a one-line CSS fix came back with the theme logic improved. Nine months later CLAUDE.md is 519 long lines and the most edited file in the repository. It works like an interface specification for a colleague with no memory: a block of automation rules (when X happens, do Y), the architecture in the terms the code uses, and the gotchas, each one a bug that happened at least once, written so the next session does not repeat it. A one-line AGENTS.md points every other agent (Codex among them) to the same file, so there is one source of truth and no second copy to drift.
The rules that matter most are executable. Hooks remind an agent to run the build after it edits a note, and the git pre-commit hook regenerates share cards whatever wrote the commit. Two skills package procedures that are too long to keep in the instruction file: reviewing an article against the writing rules, its links and its sources, and reviewing the living parts of the site described below. The writing rules themselves are checked by the build, which turned style from a request into a test.
What went wrong, and what it taught
The failures worth keeping are the ones a model made that a person would not, or would make once.
Effects that run twiceReact runs effects twice in development, and an animation library reading a value mid-transition took the half-faded opacity as its end state, so elements stayed invisible in dev and visible in production
Pages that change their URL and not their screenan animation loop that set React state on every frame kept the router's transition pending forever, in development only, which looked random until it was traced
A test that broke the dev servera script that started its own Vite server shared the dev server's cache, re-optimised its dependencies and made every open tab fail its next lazy route with an error page
Mathematics that lost its backslashesa replacement written as a JavaScript string turned \b and \t into control characters, and two notes shipped with broken formulas and no build error
Two sessions in one working treeagents running in parallel on the same checkout staged each other's changes, which is why the instruction file now tells them to stage only their own hunks
The deeper lesson is about alternatives. A model makes a second and a third version of a design almost free, and the history shows it: throwaway routes like /s1 to /s8, ten versions of the home's clock field, five study pages for how links should look, each built, compared on screen and deleted once one was chosen. The cost of building moved to the cost of choosing and the cost of deleting, and the repository stays readable only because the losing versions are removed in the same week they lose.
In hindsight.
Comments through Giscus were added in February because I wanted to learn how, and removed in September after one discussion and no comments in seven months; the widget only added an iframe and a loading gap. A reader who wants to answer has the contact page, and an end-of-article form went the same way. Features added to learn a technology should be removed as soon as the learning is done.
Security, language models and the markdown
Nothing in this section is unusual, and the details stay private on purpose. What can be said is the shape.
No credential has ever been committed. Tokens for the bucket, the analytics panel and the counters live in local files that git ignores and in Cloudflare's secret store, and the documentation names them without their values. The repository itself is private, because it also holds the workshop where articles and study material are drafted, and the site never links to it.
The assistant is the one part where a stranger's text reaches a language model, so it is the one part with real limits. Each visitor has a number of questions per day, the month has a budget it cannot pass, and the model can only link to pages the build lists as allowed, so a page it invents is silently dropped. The text of the page the reader has open is sent with the question, capped in length, which is the obvious door for prompt injection; the defence is that the model can do nothing but write an answer in the panel and suggest pages from that list. The HTML that the compiler produces from markdown is sanitised after every transformation, never before, and the analytics panel runs under a strict content security policy set by middleware, because the static headers file does not cover function responses.
The markdown is managed like code. Notes are renamed and moved only through scripts that rewrite every reference, after a dry run; translations are sibling files that record a hash of the English body they translate, so a stale translation is flagged at build; and the validator checks the whole wiki on every build for broken links and names that collide, while a separate script finds notes that nothing links to.
What it costs to keep it true
A site like this rots in a specific way: by things that were true on the day they were written. The text that crawlers see for the home page is a copy of what the home component renders; llms.txt has a hand-written frame around its generated lists; the Mandarin notebook is a snapshot of a deck at one version; the CV makes claims about the present. Since the end of September each of these is called a living part and has an entry in UPKEEP.md (where it is, how to check it, how often), and npm run upkeep lists what is due. The rule that comes with it is to derive a living part from its source whenever that is possible, and to register it when it is not, in the same commit that creates it.
1,148commits from 21 January to 20 October 2026git log of the main branch ~91,000lines of code today, application, build scripts and edgecounted over tracked files, content and generated data excluded 217commits that touch the instruction file for coding agents, more than any other filegit log 2.8 MBof wiki downloaded by every first visit to any page, until 19 October§the performance layer 9 sof frozen page while the wiki's layout settled, with the CPU throttled four timesmeasured before the layout moved to a worker 1 weekthat the home's visit counter sat frozen behind a missing route§the delivery layer
The decision I have not revisited is the first one, the single-page app, and the history explains why: every problem it caused was solved at the edge in an afternoon, while moving to static generation would touch every view. If the site were starting today with what it knows now, the articles would be static pages and the wiki, the notebook and the analytics would be applications inside them. It is not starting today, and the edge function that patches the gap is under 300 lines long.