←yulan.me
All work
Colophon Since May 2026

How this
site is made.

Static HTML with no framework, self-hosted type, and figures that compute in the browser.

First published
May 2026

Developed continuously since, in the open.

Build
None

The pages you are reading are the files on disk.

Hosting
Cloudflare Pages
Deployed from Git
The build

No framework, because there is nothing here a framework would solve.

This site is seven files and some HTML. The homepage is index.html with index.css, index.js and figures.js. The six case pages share case.css and case.js. theme.js is common to both and does one job.

It started life as a Next.js application reading from Notion at build time, and that was the right shape while the content was still moving. Once it settled, the build step was pure cost: a toolchain, a dependency tree and a deploy pipeline standing between me and eleven pages that never change between requests. So it was rewritten as static files. Nothing renders on a server, nothing hydrates, and there is no step between editing a file and seeing it.

The lab is the exception and keeps its build. Ten instruments that run real computation in the browser are genuinely an application, so they live on their own subdomain as a Next.js static export, separate repository and separate deploy.

Type and colour

Two families, three roles for one green.

Inter for anything structural, JetBrains Mono for labels and metadata only. Mono is a signal that something is machine-readable, so using it for display headings spends that signal on nothing.

Both are self-hosted: thirteen woff2 files mirroring Google's exact builds, with the unicode-range subsetting preserved so a visitor downloads only the glyphs their language needs. No third-party font request, and the CSP can stay same-origin as a result.

Paper#FBFAF7
Ink#12151A
Signal#0E8A5F
Slab#191D21

The green splits three ways and that split is the reason the palette survives a theme switch. --signal draws graphics only. --signalText is the darker weight used for text on light ground. --signalOnDark is the lighter weight for text on the fixed dark slabs. One colour used three ways fails contrast in at least one of them; three named roles cannot.

The figures

Every animation is code, not a video.

The constellation behind the hero, the perturbation front on the AdverSec page, the certificate timeline on the domain audit: all of them are 2D canvas, drawn per frame in code written for this site rather than pulled from a charting library.

They share the same discipline. Device pixel ratio capped at 2, because past that you are paying for pixels nobody can see. Frames driven by the requestAnimationFrame timestamp with the delta clamped, so a backgrounded tab does not return and jump. Visibility gated on offsetParent before geometry, because a hidden canvas reports a rectangle at the origin and a naive check reads that as on-screen. Palettes resolved once and cached rather than read from CSS every frame, which is otherwise hundreds of forced style recalculations a second.

Measured

Claims worth making are claims worth checking.

Contrast, both themes
Zero

Failures, across every rendered text node on every page, each measured against its true painted background. The lowest ratio on the site is 4.65:1 in light and 5.39:1 in dark, against a 4.5:1 requirement.

Horizontal overflow
None

Checked at 280, 320, 375, 768, 1280 and 1920 pixels. Every auto-fit grid uses minmax(min(Npx,100%),1fr), which is the fix for the track floor that silently overflows narrow screens.

Third-party requests
One

A privacy-preserving analytics beacon. No cookies, no cross-site identifier, no advertising network, no font CDN, no tag manager.

The posture

A personal site is a security surface like any other, so it is configured like one.

A Content Security Policy with default-src 'self', no 'unsafe-inline' permitted for scripts, object-src 'none' and frame-ancestors 'none'. That last constraint is why the theme is applied by a blocking external script rather than an inline snippet: the inline version is shorter, works perfectly in local development, and is silently refused in production. Constraints you set for yourself only count if you let them cost you something.

Direction

Every decision here is mine.

The architecture, the content, the research it describes, the type, the palette, the three-role green, what each page argues and what was left out. I set the direction, supervised the implementation end to end, and reviewed every change against those decisions. Plenty were rejected and rebuilt, which is the part the history shows.

The history is public because the process is the honest part. Anyone can read what changed, when, and why, including the mistakes and the reversals. The repository is the site.