← Projects

Project write-up · leo.wilssens.com

Building leo.wilssens.com

A sponsor-facing site for an eleven-year-old track racer, where the rules for publishing about a minor were written before the first page, and enforced in the build rather than in good intentions.

Steven Wilssens · October 2026 · 10 min read

8
sections, from a palmares laid out like a start list to a games arcade
4
browser games about the sport, playable without an install or an account
0
road rides published: a GPS trace of a minor never reaches the build
1
way to get in touch, and it goes to a parent
Share on LinkedIn ↗

leo.wilssens.com is a media kit for Leo, a junior track sprinter: results, race reports, photos, training rides from the velodrome, four games he is credited with on the site, and a page for the sponsors a season of racing needs. It is a static Astro site on Cloudflare, a small Worker behind it, and eight weeks of evenings between a design spec on 20 August 2026 and the last commit in mid October.

Everything about it follows from one fact: the subject is a minor. That fact sets who can be contacted, what can be shown, what can never be shown, and how the site is built so that the second list stays empty by construction.

01The rules came first

Before the first page, the design spec and the project’s instructions file fixed the safety rails, and they have not moved since. Every sponsorship conversation goes to a parent, through one address; there is no way to reach Leo through the site. Results, palmares and action photos are fine. His school, home town, training schedule and location are never published. Race reports are written after an event, never as a calendar of where he will be. Season goals name major public events only.

What reaches the page, and what never enters the buildResultsreal ones only, never roundedRace reportswritten after the eventRidesfrom the training platformPhotos and videoaction shots, ride mediaInstagram postssynced by a WorkerSurface filtervelodrome and trainer rides onlyDay precisiondates, never times or placesMetadata stripEXIF, XMP, video location boxesGuardian approvalposts wait for a parent to approveleo.wilssens.comstatic pages, one contact routeThe filters live in the data selection and the build, not in the templates: a page cannot show what the build never received.Never in the buildschool · home town · training schedule · weekly calendar · real-time or exact location · road rides · any way to reach Leo directly
Inputs pass through filters that live in the data selection and the build. What is on the bottom row never enters the build at all.

The rule I care most about is where those rules live. The site shows training rides from the velodrome, pulled at build time from the training platform I describe in the other write-up. A road ride is a GPS trace of a child, so road rides are excluded when the rides are selected, not hidden when they are displayed: a template cannot show a ride the build never received. Dates are published at day precision only. Every photo and video the build fetches is stripped of its metadata, the EXIF and XMP blocks and the location boxes inside video files, and nothing fetched is ever served byte for byte. Instagram posts are synced into a database as unapproved and wait for a parent’s approval before the site or the media endpoint can publish them, and the sync asks for no location fields.

The analytics follow the same thinking: no IP addresses, no full browser strings, visitor identifiers protected with a keyed hash, Do Not Track honoured, nothing kept beyond thirteen months, and a privacy page that says so in plain words. There are no accounts and no comments.

02Separate on purpose

Leo’s site began as a planned page at wilssens.com/leo, listed as the second property of the junior track cycling hub. On 20 August 2026 I decided it would be neither: its own repository, its own Cloudflare Pages project, its own address. The hub must read as a neutral community resource; this site is one rider’s promotion. The moment they share code or a domain, the hub looks like a vehicle for him, and its credibility is its value.

Separate does not mean disconnected. The site lives at a wilssens.com name, as every family site does, with DNS still at the registrar and a single record pointing at Cloudflare. It reads the training platform’s activity API at build time for the rides page, with a key, and its first palmares entry was copied from the hub’s nationals results rather than typed. It borrows two patterns from this site: the admin sign-in behind a token and the deploy-token setup.

The home page. One accent colour, one typeface for words and a monospace face for anything that is data, and a result card laid out like a start list.

03Static, dark, kinetic

The stack is locked in the instructions file so that nobody, including the AI I build with, re-proposes it: Astro, strict TypeScript, static output only, no server adapter, and no client-side framework islands at all. The interactivity that exists, a gallery lightbox, the mobile menu, the games, is plain scripts, and since October the build hashes every inline script into the Content Security Policy header, so a script the build did not write does not run. Content is three Markdown collections, palmares, stories and albums, with a schema that makes a draft the default.

  • Dark only, one accent. A near-black background, one acid yellow for emphasis, one red. No light theme to maintain. Tokens live in one file and components use only the variables.
  • Mono means data. Inter for everything written; JetBrains Mono only for values a reader treats as data: times, points, positions, years in tables. A monospace label is a bug. Headings are sentence case, with hierarchy from weight and size, not capitals.
  • Two signatures. A giant outlined mark in the hero, a race number in the spec and his initials since September, and a palmares laid out like a start list. The velodrome photo and the animated hero came later; the rules about what moves did not change.

Static output is a safety decision as much as a performance one. There is no server rendering a page with a child’s data on request; there are files, produced by a build I can read, from inputs I chose.

04Real results only

A media kit is a document a sponsor may check. The accuracy rules are short and absolute. Results are real and never rounded; the palmares holds three entries from one season, including a sixth place in the 500 metre time trial at the junior track nationals, timed to the thousandth, and a thirteenth in the omnium that a less honest site would have left out. Everything marked as a placeholder, the contact address, the bio, the photos, the season goals, must be confirmed by a parent before the address is announced, and the checklist for that is in the deploy notes.

The bike page lists the parts of his bike, and each entry stays marked unconfirmed until the exact part is verified. The rule says never to infer a component from a photo. In the training-ride game described below, the fastest times on each segment are real fastest times from real players, not invented ones. None of this is hard; it is the discipline of saying less than you could.

The sponsor page: what a partner gets, four public goals for next season, and the one sentence that is not negotiable.

05Four games

The games are the part of the site a sponsor will not read and the part Leo opens first. The site credits them to him, and they are about the sport he races: Ride Day, an arcade satire of the training ride where you dodge car doors, dogs and potholes across eight days and chase the fastest time on each segment; a match sprint standoff that follows the real start rules and the sprinters’ lane; an elimination race; and a points race. They run in the browser on their own pages, with a keyboard on a computer and touch on a phone, no install and no account.

Ride Day, the first of the four: procedural pixel art on one canvas, sound from the Web Audio API, and no new dependency added to the site.

Technically they are the most code on the site: pixel art drawn procedurally on a small canvas, sound synthesised rather than loaded, and the high-score tables and segment records kept in the Worker’s database, falling back to the device when the table cannot be reached. A day after the fourth game shipped, the four were packaged as one free desktop game with Electron, in its own folder that imports the web code rather than copying it, where the track games unlock after a week of Ride Day. Leo keeps giving me feedback on them. He likes the games and he is an honest critic, and it has been fun to watch him look at a product with the candour a young person brings.

06Shipping without a slip

On a site like this the expensive mistake is not a bug; it is a deploy that publishes something it should not, or an empty build that wipes what was there. The pipeline is built against both.

One build, static output, one small server behind a proxyMarkdown collectionspalmares · stories · albumsAthlete datafacts, bike, photos, navTraining platform APIrides and ride mediaAstro buildstatic output, no islandsstrips media metadatascripts hashed into the CSPCloudflare Pagesstatic files at leo.wilssens.comFunctions proxy /api and /mediato the Worker, same originWorker + D1 + KVscores, records, analyticsposts awaiting approvalDeploypush → type check → build → tests → deploy, refused without its secretsThe type check is a separate step because the Astro build ignores type errors. Nothing deploys that did not pass it.
Content, athlete data and the training platform’s API go through one build into static files. A push to main is checked, built and tested before a deploy that refuses to run without its secrets.
  • A separate type gate. The Astro build ignores type errors, so the type check is its own required step, and the instructions file says why, so nobody removes it to save a minute.
  • A deploy that refuses. Since 28 September, deploys run from GitHub Actions after the checks pass, and the deploy step stops if the Cloudflare token or the training platform’s credentials are missing: an empty build cannot replace the live site. Before that, the honest note in the commit history says, pushes to main did not deploy at all.
  • Media that fails the build. If a photo or video cannot be parsed well enough to strip its metadata, the build fails rather than ships it.
  • One server, behind a proxy. The only server code is a Worker for the Instagram sync, the analytics, the admin page and the game scores, with its own database. It has no public address of its own; the site reaches it through Pages Functions on the same origin, and the admin sits behind a long token.

In October the dependencies went through a security audit that cleared every finding outside the development tools, and the framework moved a major version in the same change. The parked list in the instructions file records the one upgrade that was tried and reverted, and why, so it is not tried again blind.

07How it was built

The first two commits, on 20 August 2026, are a design spec marked approved and an implementation plan of ten tasks, from scaffold to the first push. Code came third. The spec fixed the purpose, the safety rails, the stack and the design system; the plan was written for an agent to execute task by task, with a commit after each. The next day’s commit is a verification report: the checks that ran, what the rendered HTML was searched for, one heading per page, no scripts, the current page marked in the navigation, and screenshots from a headless browser.

Across 126 commits on 16 working days, most written with Claude Code and for a few weeks in late August and September with GitHub Copilot, the instructions file grew into the real product document: what is settled, what is parked, the trap in the local network setup that rewrites the lockfile, and the rule that it must be checked after every install. The busiest day was 6 October, when three of the four games arrived in thirty-two commits.

The instructions file is the contract. It says what may never be published, what is settled, and what is parked. The AI reads it before every session, and so do I.

08Best practices

  1. Write the safety rules before the first page, then put them in the data path. Exclude at selection and at build time; never rely on a template to hide something.
  2. Keep promotion and community apart. Separate repository, project and domain, so neither can be mistaken for the other.
  3. Lock the stack in writing. Settled decisions, with dates, that an AI assistant is told not to re-propose. It saves every session from starting with a debate.
  4. Make the build the gate. A type check that cannot be skipped, a deploy that refuses without its secrets, media that fails the build when it cannot be cleaned.
  5. Say less than you could. Real results, confirmed facts, unconfirmed parts marked as such. A sponsor who finds one invention stops trusting the rest.
  6. Keep a parked list. What was tried and reverted, and why, next to what is deferred, so good ideas are not re-litigated and bad ones are not retried blind.

09What’s next

Before the address is announced to anyone, the placeholders go: a real contact address for a parent, a bio he has read, a photograph of the bike. The Instagram sync waits for storage to be switched on and the approval step to be used for real. The sponsor page gets a downloadable deck. And the open design question from the parked list, whether the palmares should lead with the strongest result or the latest, is his to answer, not mine.

This write-up is refreshed from the project’s repository after every check-in; the numbers in the header are counted from it.

Ask the assistant something a recruiter would ask.