Anatomy of a Four-Page Site
One config file grows the whole site. Taste is taste; engineering is engineering.
This document covers how mila-site is built. The design decisions (why watercolor, why an aurora) live in the blog post; this is engineering only.
01Architecture overview
A static Astro site on a free Cloudflare Pages subdomain: hosting cost 0, domain cost 0. The case for Astro is simple: zero JS by default, content collections turn posts into Markdown files in a folder, and the build output is plain static files with no server to babysit. A four-page site needs nothing heavier.
02One config file grows all the styling
Not a single color or font is hard-coded in any component. The single source of truth is one brand.json: palette, fonts, both light/dark palettes, channel links. A pre-build script emits it as CSS variables plus metadata; components only ever reference variables.
✦ The payoff comes at revision time Swapping the palette, the fonts, or the tagline means editing one JSON file and rebuilding.
This skeleton will later generalize into a template: a new brand site equals a fresh brand.json plus a content directory.
03How light/dark mode is engineered
Dual mode is not a filter. Every color token has separate light and dark values, and even the cover images are a matched pair of paintings; the browser only loads the one for the active theme.
There are three states: an explicit user choice (data-theme on the html element, stored in localStorage), following the system (prefers-color-scheme), and an inline script that applies the theme before render to avoid a white flash.
⚠ The easiest thing to miss Every themed CSS rule needs two selectors to cover both paths, "manually chose dark" and "following the system"; write only one and the toggle button fights the OS setting.
04i18n: a four-page site doesn't deserve a framework
Bilingual, / for Traditional Chinese and /en/ for English. No i18n framework: both languages' copy live in one dictionary file, routes are hand-written, and a post ships in a single language by setting lang in its frontmatter. I evaluated frameworks; for four pages, the abstraction is bigger than the problem.
So when does a framework earn its place? My three tripwires — hit any one and it's time to re-evaluate:
- A third language. Two languages are an if/else; three become a matrix, and the dictionary file buckles first.
- Too many routes to hand-write safely. With enough pages, hreflang, sitemaps, and language fallbacks are mechanical work a framework should own; humans eventually miss one.
- Translation stops being a one-person job. Translation workflows, fallback to a default language, tracking what's untranslated: that is framework territory.
Until then, every added layer of abstraction is interest paid on a problem that doesn't exist.
05Motion engineering: keeping the aurora alive across pages
Motion has one goal: the whole site should carry the aurora's flow. Three pieces of engineering:
- Page changes use View Transitions; the background aurora layer carries
transition:persist, so the element survives navigation untouched and its animation never restarts. Without this, the aurora blinks out on every page change. - Scroll reveals use IntersectionObserver, with a fuse: if nothing fires within 2.5 seconds, everything is force-shown. A dead animation is acceptable; invisible content is not.
- Turn on
prefers-reduced-motionand everything goes still.
✎ Motion discipline Slow and light, one metaphor at a time (the aurora). The first version layered colored fog over the page: technically animated, visually zero. Tearing it down produced the current light-and-mist approach.
06The image pipeline
The cover auroras come from an image model, with style held by anchors rather than luck: every generation attaches my own watercolor paintings as reference images with a fixed set of style terms, so the output stays in one family. Everything is re-saved through PIL before it ships, stripping all metadata. Generation runs on a subscription quota at zero API cost — a pipeline that deserves its own write-up.