How this page is built
The [homepage](/) is a business card: a name, a photo, some links. It could be one HTML file written in an afternoon. It is one HTML file. The afternoon part didn't work out.
This is what happens when you ask "how far can one page be pushed?" and then keep going after the honest answer became "too far".
The numbers
- 10,275 bytes gzipped for the whole page: HTML, CSS, JavaScript and a placeholder photo. (The build fills in this number, so this page can't go stale either.)
- 2 requests: the page and one photo. No web fonts, no icon font, no analytics, no cookie banner (there are no cookies).
- 100 · 100 · 100 · 100 in Lighthouse on mobile. CI fails anything lower (it allows 98 for performance, which wobbles on shared CI machines).
- 51 lines of TypeScript run in your browser.
- 706 lines of TypeScript make sure those 51 lines behave, plus a CI pipeline. For a business card. Yes. (Both counted by the build. This page made the second number bigger.)
- 0 frameworks, 0 runtime dependencies, 24 packages to build it.
One file, one round trip
A new connection sends about ten packets, roughly 14 KB, before it waits for the first answer. Whatever fits into that first flight renders without another round trip. So the build has one job: fit.
- Vite builds the page, then [a small plugin](https://github.com/mayrsascha/homepage/blob/master/vite.config.ts) inlines the CSS and JavaScript into the HTML. After the document arrives there is nothing left to wait for.
- HTML comments are stripped and the hand-written inline scripts minified. Comments are free to write, not to ship.
- The build measures the result and fails above 10.5 kB. The footer says "10 kB", and the footer is not allowed to lie.
No analytics
From 2023 the page ran Google Analytics. It was removed in October 2026, for two reasons:
- Speed. Its script is 177 KB gzipped today, about seventeen times this entire page, and on a phone it blocked the main thread for 150 to 380 ms. It was the only thing standing between the page and a Lighthouse score of 100.
- Data. It sends every visit to Google, and in 2022 data protection authorities in Austria, France, Italy and Denmark ruled against websites using it under the GDPR, because of how the data reached the US. A business card shouldn't come with a legal opinion attached.
Removing it also removed the reason for a cookie banner. The cost: no idea how many people read this. That seems fair.
The photo
The photo is the only thing on the page that isn't text, so naturally it got the most engineering.
- A 4.4 KB placeholder (AVIF, 500 px, quality 18) is embedded in the HTML as a data URI, so something photo-shaped paints with the first bytes.
- The real photo comes in three widths: 500, 800 and 1280 px, which is 20, 48 and 120 KB as AVIF, with WebP for older browsers.
srcsetandsizespick one, so a phone doesn't download what a 5K display needs. - It fades in only once it is loaded and decoded. Fading in an image that is merely loaded paints an empty box for one frame. Yes, that frame was visible.
- Safari loads even a data-URI image after its first paint, so on reload the photo blinked. Now the text waits for the placeholder, at most 300 ms, and text and photo appear in the same frame. Three hundred milliseconds of engineering, for one blink.
- The first placeholder was a blurred thumbnail. It looked like a ghost. Ghosts are off-brand.
sizesis [generated by the build](https://github.com/mayrsascha/homepage/blob/master/scripts/generate.ts) from the same measurements as the CSS. Before that it was a hand-written formula, which went negative in some window sizes. Browsers read a negative size as zero, the image then reports a width of 0, and on foldables the photo simply never appeared. A review found it, seven rounds in.- The image encoders don't exist on the build server, so it uses the committed files, and CI refuses to deploy if the photo changed without being re-encoded.
Layout math
The text is a Markdown document dressed up as source code. It must never wrap on a desktop, never shrink below 12 px, and the photo must never be cut off by the edge of the screen like a mole emerging from the ground.
So the text size is a formula ([the stylesheet](https://github.com/mayrsascha/homepage/blob/master/src/style.css)):
--fs: clamp(.75rem, min( (100vh - 6.25rem - 21rem) / 33.7, /* fit the height */ (100vh - 6.25rem) / 49.9, /* ...at big text sizes */ (100vw - 48px - var(--pw)) / 50.5, /* fit the width */ .8125rem + max(0px, (100vw - 1440px) / 64) /* grow on big screens */ ), 1.75rem);
- The text block is 50.5 characters wide and 33.7em + 21rem tall. The rem part is the link rows, which stay 24 px apart so a thumb can't hit the neighbour. These numbers were measured in two browser engines, not guessed.
- Sizes are in rem and breakpoints in em, so the layout follows the font size set in your browser. Tested at 24 px.
- On phones the text fills the first screen and the whole photo arrives on scroll. Deciding "fits or doesn't" is a step function, and CSS has no step function, so it's faked:
clamp(0px, (needed - available) * 1e5, 100vh - 1em). Multiply any shortfall by a hundred thousand and a fraction of a pixel becomes "the whole screen". - iOS 15 ignores media queries written in the modern range syntax, which the CSS compiler had helpfully produced. The result on iOS 15: no layout at all. The build now targets Safari 15 explicitly. Somewhere someone still runs iOS 15, and their business-card experience is flawless.
Dark mode, the hard way
- The theme is set before the first paint, so there's no white flash. Without JavaScript the page follows your OS.
- A click on the toggle is remembered. Choosing what your OS shows anyway forgets it, so the page follows the OS again. Other open tabs switch along.
- Printing always uses the light theme. Printers skip dark backgrounds, and light-grey text on white paper is a crime.
- The theme colours also live in places the stylesheet can't reach: the no-JavaScript style, the inline scripts, the browser's
theme-colortags, the 404 page. The build checks every copy against the stylesheet. That check has been tightened twice, because two reviews found inputs it let through.
Security for a page with nothing to steal
The policy, as sent with every response:
default-src 'none'; script-src 'sha256-…' 'sha256-…' …; style-src 'sha256-…' …; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types 'none'
- A hash-based Content Security Policy. The build computes the SHA-256 of every inline script and style and lists exactly those. Anything else, an injected script or a single changed byte, is refused by the browser. Nonces would need a server. Domain allowlists were bypassable in about 95% of real-world policies in [Google's study](https://research.google/pubs/csp-is-dead-long-live-csp-on-the-insecurity-of-whitelists-and-the-future-of-content-security-policy/). Hashes work on a static CDN.
default-src 'none': whatever isn't explicitly allowed is denied. No forms, no frames, no<base>tricks.- Trusted Types: no script can reach
innerHTML,evaland friends. The page uses none of them, which makes this the easiest security win in history. - HSTS for two years, submitted to the browsers' preload list. Other windows get no handle on this one (COOP), no MIME sniffing, no framing.
- The domain sends no email, and DNS says so: null MX, SPF
-all, DMARCreject. Nobody can send mail as this domain. Nobody wanted to, but now they really can't.
Accessibility
- Every colour passes WCAG AA. Solarized's accent colours didn't, so they were darkened just enough, same hue, same saturation.
- Screen readers don't read the Markdown syntax aloud. "Bracket, Shumi, close bracket, parenthesis, https colon slash slash" was nobody's favourite podcast. Every link has a proper name, "opens in a new tab" included.
- Visible focus rings, 24 px between links, works without JavaScript, works at 400% zoom.
The checks that keep it honest
Every push runs ([the workflow](https://github.com/mayrsascha/homepage/blob/master/.github/workflows/check.yml)):
- the size budget: above 10.5 kB, the build fails;
- [verify-build](https://github.com/mayrsascha/homepage/blob/master/scripts/verify-build.ts): every inline script and style has its hash in the policy; no style attributes, event handlers or
javascript:links; every security header present and exactly as strict as intended; every theme copy complete and correct; - a check that the committed files match the build: content, photos, sizes;
- a fallback test that simulates the build server without image encoders;
- Lighthouse CI, three runs, against [a local server](https://github.com/mayrsascha/homepage/blob/master/scripts/serve.ts) that sends the real headers, so a blocked script fails the build instead of the website;
- strict TypeScript for the page and for the build scripts, and Biome for lint and format.
New dependency versions wait seven days before they're even proposed, pnpm refuses anything younger than three days, and every GitHub Action is pinned to a commit. The Lighthouse tooling alone used to account for most of the 313 packages the repo installed. It now runs from a pinned action, and the repo installs 24.
How it got this way
Built together with AI, which is how software gets built now. I made the calls: what the page is (a business card, not a CV), what it must never do (cut the photo in half, drop the URLs on a desktop, track anyone), and when a suggestion was wrong. Claude wrote most of the code. Reviews came from AI agents told to find "the top 10 things to improve".
Each round got fixed, then reviewed again. The first rounds found real problems. Later rounds found problems in the checks written to prevent the earlier problems. Round twelve found a bug in the code that checks the code that checks the colours. That seemed like a good moment to write this page instead. It's [Markdown too](https://github.com/mayrsascha/homepage/blob/master/src/how-its-built.md), turned into HTML by the build, naturally.
What's left
- No HTTP/3 and no DNSSEC, because Netlify, the host since day one, offers neither. Moving to Cloudflare would have fixed both. It was a close call, and the page stayed:
- My other projects already run on Cloudflare. If Cloudflare has a bad day, the one page that says how to reach me should not go down with them. On 18 November 2025 it had a very bad day, and X, ChatGPT, Spotify and Canva were among the sites that failed for about three hours.
- Roughly one in five websites already sits behind Cloudflare. The web is healthier when not everything does.
- And what's actually lost is small. HTTP/3 shines with many requests on a shaky connection; this page is two requests in one round trip. DNSSEC protects DNS answers from forgery; with HSTS, a forged answer ends in a certificate error rather than a fake page.
- Other hosts were checked too. GitHub Pages can't send the headers this page depends on, and the hosts that do offer HTTP/3 are mostly Cloudflare, Google or Amazon underneath. Netlify stays.
- On a 6K display the photo is upscaled 1.56 times. Nobody with a 6K display has visited yet, probably.
- The page still can't tell who you are.
{visitorName}stays a placeholder on purpose: there's no analytics, so it genuinely doesn't know.
[Back to the homepage](/) · [The source on GitHub](https://github.com/mayrsascha/homepage)