Notes
How this site is built
Static Angular, no server, a strict CSP and a CI gate that measures bytes instead of scores. What is in the repo, what each piece is for, and the two decisions I would take back.
This site is a static Angular 22 app. ng build prerenders every route to HTML, Cloudflare Pages serves the folder, and there is no server anywhere in the path. That sounds like the boring choice, and it is; most of what follows is about what it took to keep it boring.
Prerender, then hydrate
Every page exists twice, at / and /pt/. The language is derived from the URL, so the toggle in the nav is a plain link to the sibling page — no cookie, no redirect, no flash of the wrong language. Both trees are prerendered by outputMode: 'static', then hydrated in the browser with event replay, so a click during the load is not lost.
All copy lives in src/content as a typed schema. Every user-visible string is an L<T> = { en: T; pt: T }, and a set of tests walks the exported content tree generically: every L<string> is non-empty in both languages, every L<string[]> has the same number of items on each side, dates are real months and not in the future, project links are absolute HTTPS. There were 28 of them when I wrote this, and none lists a field by hand — a new L<> anywhere in the schema is covered the moment it exists.
One of them is a privacy check. The experience section deliberately names no end client, so the test greps the content files for the names I removed and fails if any of them comes back. It is the kind of assertion that looks paranoid until the day you paste a bullet from an old CV.
The hero, and what it is not allowed to cost
The home page has a looping clip behind the hero. The first version loaded it like any other video and a mobile Lighthouse run scored the page in the forties, because an 846 kB WebM was competing with the fonts and the first paint.
The fix was not to make the video smaller, although it also got a 193 kB mobile encode. The fix was to hold it back. The prerendered HTML contains no <source> at all; the sources are added on the client, and only after a fixed delay and an idle callback:
export function whenIdle(start: () => void, delay = HERO_VIDEO_DELAY_MS): void {
if (typeof window === 'undefined') return;
const idle = (window as { requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number }).requestIdleCallback;
window.setTimeout(() => (idle ? idle(start, { timeout: 600 }) : start()), delay);
}The delay comes first on purpose. requestIdleCallback on its own fires within a few hundred milliseconds on a fast connection, which is exactly the window this is meant to keep clear. Twelve hundred milliseconds of nothing, then idle, then the clip. Reduced motion, saveData and a 2G connection get no video at all; a narrow viewport gets the light encode.
The wireframe object next to the hero follows the same rule. Three.js is about 150 kB over the wire for an ornament, so the page renders a static SVG first and the real thing loads lazily behind the same whenIdle. Once it is up, the render loop is paused whenever the object leaves the viewport or the tab is hidden — there is no reason to burn a frame budget on something nobody can see.
A CSP that pins Angular by hash
The site ships a strict Content Security Policy: no 'unsafe-inline' for scripts, every inline piece allowed by its SHA-256. Angular's prerender emits exactly three: the onload handler on the async stylesheet link, the event-replay contract, and the bootstrap call that lists which events replay will capture.
That last one is the trap. window.__jsaction_bootstrap(document.body,"ng",["click"],[]) is the version on the CV page. The home page has cards that light up under the cursor, so its version says ["click","pointermove"]. Different bytes, different hash. The array changes whenever a page starts replaying an event type it did not replay before, and nothing tells you — the browser blocks the script, replay silently stops working, and the only trace is a console message.
A hash pins a behaviour, not a file. It changes when a template does, and the template will not mention it.
So CI walks every prerendered page after the build, hashes each inline script and handler it finds, and fails when one is missing from _headers, printing the exact 'sha256-…' to paste. It also flags a hash that no page emits any more. Under a hundred lines of Node, and the failure moved from the browser console to the pull request.
Budgets in bytes, not scores
Lighthouse runs in CI against the build it just produced, never against the live site. The gate is not the performance score. That number moves with the speed of the runner — the same home page build scored between 41 and 50 on mobile across five sweeps, and 72 to 96 on desktop. A floor that low catches a collapse, not a regression.
What is deterministic is the transfer size. Static bytes are identical run to run, so each URL has a budget for total, script, image, font and media, with a thin margin over the measured value. Angular adding 30 kB to the main bundle fails the build; a slow runner does not.
One number in there was wrong for a while. The desktop budget for media was fitted to runs where the hero clip had not arrived inside the audit window, then went red on a build whose media had not changed at all — a fast runner had simply downloaded the whole file. The budget now covers the entire clip, and the comment block in the config records why.
The CV is a route
There is one CV, in two languages, and it is a page: /cv and /pt/cv. The PDFs people download are printed from those routes by a headless Chromium with --print-to-pdf, laid out for two pages by print CSS, and committed alongside the content change that produced them. There is no second source of truth to drift.
Cloudflare Pages, with the version pinned
Deploys are Cloudflare's GitHub integration: push to main, the build runs, the folder goes live. The one thing worth writing down is NODE_VERSION. A bare 24 resolved to 24.13.1 on Cloudflare's side, which Angular 22.1 refuses. The exact version is pinned in the Pages environment, in .node-version and in .nvmrc, and it is the same number in all three.
The 404 is real. The build prerenders a not-found page, a postbuild step moves it to 404.html, and Pages serves it with a 404 status for any path it has no file for — which also switches off the SPA fallback that was answering every unknown URL with the home page and a 200.
What I would do differently
Two things. I would write the CSP checker before the CSP, not after the first script it silently blocked. And I would start with the byte budgets and skip the score floors entirely; they have never caught anything the budgets did not catch first, and a floor set low enough not to flake on a slow runner is too low to mean anything.