5 min
How this site is built
One page, five runtime dependencies, no environment variables, and comments on a quarter of the lines. What I decided, and what went wrong.
I make small things in the evening and then lose track of where I put them. This site is the list. It is one page, everything is on it, and you should not have to click anything to see the whole of it. That last part is the one rule I have refused to bend.
Most of what is here comes out of work. I am a digital learning designer at a university, which means I help academic staff get technology into their teaching in a way that is worth what it costs them. The tools are for that job, either for me or for the people I support. cdul.app is the one that is actually live. The rest sit in the index in various states of unfinished.
The layout
The mosaic took the longest to get right. On a wide screen the page is a twelve column, ten row grid that fills exactly 100dvh, and every tile is placed by hand with an explicit grid-area so that between them they cover the whole viewport. There is no empty space anywhere. Where a tile has nothing to say it carries texture instead, a dot field or a diagonal hatch, so that leftover area reads as part of the pattern.
The hairlines between tiles do all the dividing, which is why there are no cards and no stacked sections. Placing every tile by hand sounds fussy and it is, but sparse auto-flow never moves its cursor backwards, so the index could only sit beside my name if it came first in the markup, and that is the wrong order the moment the mosaic collapses. Explicit areas let the source order suit the phone instead.
Below the lg breakpoint I abandon the mosaic completely. The row spans add up to far more rows than the grid has, and forcing that into one phone screen crushes every tile, so a narrow screen gets an ordinary scrolling column with some hatching absorbing the slack. I would rather drop the idea cleanly than ship a squashed version of it.
Colour and type
The visual language is adapted from open-slide.dev: a near black ground, white alpha hairlines, and a sans display line with a mono counter line under it. The accent is my own.
There are two accent values and they are not interchangeable, which is the sort of thing you only find out by measuring. The brighter one clears three to one on the light canvas but not four and a half, so it is right for display type, fills and rules, and wrong for small text. The quieter one carries links, small text and the focus ring.
I measured every token against both canvases and wrote the numbers at the top of globals.css, because I know I would otherwise forget which value was the safe one. There is also a decorative grey that fails three to one in both themes. It is allowed on row numbers and month labels, and nowhere that has to carry meaning on its own.
The display face is Geist with Geist Mono underneath it. Geist Mono has no glyph for an arrow or a star, so every arrow here is inline SVG and star counts are spelled out. Typing those characters renders tofu, which is how I found out.
What it runs on
Next.js 16 with the App Router and React Server Components, Tailwind CSS v4, TypeScript, and Cloudflare Workers through vinext, which is still in beta. No database, no CMS, and nothing in the environment.
The contribution graph along the bottom is scraped, and I did that on purpose. GitHub has no REST endpoint for the calendar, and the GraphQL one wants a token even though the data is public. Scraping the public profile fragment means the site has nothing to keep secret, so there are no API keys and no environment variables at all. What I pay for that is a parser reading markup GitHub can change whenever it likes, so every path that can fail returns null and the section takes itself off the page. Both reads sit behind use cache for an hour, and a failed read only lasts minutes, because one rate limited call should not be able to hide a whole section until the next hour.
The notes are TypeScript modules. That looks wrong until you remember the Worker has no filesystem to read markdown from.
Working with an agent
Almost all of the code here was written with Claude Code, and the honest account is more useful than the tidy one.
The comments are the first thing I would point at. About a quarter of the non blank lines across app, components and lib are comments, and most of them explain a decision rather than describe the code. Every time we worked out why something had to be a particular way, it went into the file, because the next session would otherwise undo it. The dot field is masked on its own layer because a mask applies to an element and everything inside it, and putting it on the tile erases the headline. That sentence is in the source. It is there because the alternative is finding it out again.
It gets things wrong too, in ways worth knowing about. Twice in one afternoon it deleted the same block of layout CSS while removing something unrelated, because it was cutting a range of lines instead of matching exact text, and both times the build still passed. Once it wrote an animation that silently never ran: the flag meant to make it play once was set before the animation started, and React runs effects twice in development, so the second pass thought it had already played. It also told me an effect could be made cheap, then checked and found that it could not.
None of that is mysterious. They are the mistakes a tired person makes. What is different is the speed, in both directions. It produces a lot of correct work quickly and it produces the occasional confident wrong answer just as quickly, so the checking is mine. The build and the linter catch some of it. Opening the page catches the rest.
AGENTS.md holds the reasoning, and it is the most useful file in the repo. It lists the things that are easy to get wrong here and why they are wrong, so a session starting cold does not have to learn them all over again. The whole site came together over about ten days of evenings. The repo is public if you want to read any of it.