a monet a day
· 6 min
This site is one white card of monospace text on a full-bleed Monet. The painting advances once a day and stays the same on every page you open that day. A small browser-local identity gives each visitor their own shuffled month; people who opt out of storage share a date-derived painting instead.
That is the only genuinely interesting constraint in the build. Everything below follows from it, and from a refusal to put a server behind it.
the stack
Astro in static mode, deployed to Cloudflare Workers static assets. There is no Worker script at all — wrangler.jsonc points at ./dist and that is the entire deployment. Each page is a couple of kilobytes of HTML, one stylesheet, one font, and the paintings.
Per-visitor daily rotation is exactly the kind of feature that quietly installs a backend. You need to know which visitor this is, so you set a cookie. You need to remember what they have already been shown, so you add a store. And now the page differs per request, so it cannot be cached whole, so you have put a Worker in front of a site that was just static files on a CDN.
The way out is to notice that none of that state needs to be on my side of the wire. A visitor’s identity and their history are only ever interesting to that visitor. If the browser holds both, the HTML I serve is identical for everyone, Cloudflare caches it whole, and I never receive a byte about who is reading.
where the paintings come from
src/data/paintings.json is the only place art is declared. Everything else is derived from it.
npm run scout asks Wikidata for Monet’s paintings and ranks them by how many Wikipedias have written an article about the work, which turns out to be a decent proxy for “a person would recognise this”. It is deliberately read-only: the production list is a verified, hand-curated set of thirty major works, because ranking purely by fame gets you the Rouen Cathedral eleven times.
npm run art resolves each entry’s exact Commons File: title through the API, validates it, and atomically writes a normalised sRGB JPEG without risking the previous valid source. npm run accents samples a colour with at least 4.5:1 contrast against the card. Astro then makes focal-point-aware portrait and landscape AVIF backdrops, WebP fallbacks, tiny inline placeholders, and fixed 4:3 collection thumbnails at build time.
Two things I got wrong the first time. Wikidata will happily point you at a scan that includes the frame, so looking at the downloads before committing is not optional. And the slugs are a public key space — they live in every visitor’s localStorage, so renaming one silently deletes part of a stranger’s collection.
a bag, not a dice roll
The obvious implementation is one line:
paintings[hash(browserId + today) % paintings.length]
It is wrong in two ways a visitor actually notices.
The first: independent draws have no memory, so they can repeat immediately.
The second takes longer to surface. Collecting all thirty paintings by independent daily draws is the coupon collector’s problem, and the expected number of draws is n·H(n) — around 120 visits. A shuffled bag turns the set into a month instead.
So deal from a bag instead. Take the day number on the visitor’s own calendar, divide by the size of the collection, and call the quotient an era and the remainder a slot:
era = day ÷ 30 one shuffle of the whole collection, seeded by (id, era)
slot = day mod 30 today's place in that shuffle
An era is a single Fisher–Yates shuffle of all thirty paintings, seeded from the browser’s id and the era number. Same seed, same shuffle — so the browser recomputes today’s bag from scratch on every page load and never has to store it. Thirty positions contain all thirty paintings in a different order for every browser.
Two independent bags may meet on the same painting at their boundary. That is an accepted consequence of keeping the algorithm as a plain, calendar-anchored Fisher–Yates shuffle rather than adding a cross-bag rule.
There is no cross-bag swap and no extra guarantee to maintain. Within each month every item is unique; a repeat at the boundary is possible and harmless.
One more detail, because it took two attempts. The day number is built from local getFullYear/getMonth/getDate, not from toISOString(). UTC would turn everybody’s painting over at midnight in London, and subtracting a fixed twenty-four hours lands on the wrong day either side of a daylight-saving change.
The picker only uses a random ID that never leaves the browser and today’s date. There is no request, nothing to cache-bust, no session store to expire, and no cleanup job. The card explains that storage plainly and includes controls to opt out or reset it.
why it runs in the head
The bootstrap is inlined in <head> rather than bundled, which in most other circumstances I would call a mistake. Here it is the whole point: it has to choose the painting and set --accent and --art before first paint, or every visit opens on a flat colour and snaps to a painting a moment later. A bundled script is fetched, parsed and run far too late to avoid that.
It also injects a <link rel="preload"> for the chosen backdrop. Setting a CSS custom property does not start a download — the browser will not fetch the image until it has parsed the stylesheet and matched the rule — and the preload gets the painting moving immediately. A tiny inline crop is already painted underneath it, so even a cold or deliberately blocked image request shows a soft version of the artwork instead of a flat colour. Storage controls decode their next full backdrop before reloading as well.
the collection
Every painting you have been shown accumulates on /collection. The tiles ship in their undiscovered state with the thumbnail parked in a data- attribute, and the client fills in only the ones this browser has actually met. The page never downloads a painting you have not seen, so the reveal is real rather than a CSS trick.
If storage is blocked, or if a visitor chooses “use without remembering me”, the site derives a shared painting from the local date and stores no ID or collection. The same fallback still appears consistently across every route that day.
built with an agent
Worth saying plainly, since this audience will guess anyway: I built this with Claude Code. The parts that took thought were not the parts that took typing. An agent will happily hand you the one-line modulo — it is correct, it is idiomatic, and it passes any test you think to ask for. What it will not do unprompted is notice that an independently drawn thirty-item collection takes roughly 120 visits to complete. Deciding that this should feel like a month was the job. The shuffle was twenty minutes after that.
the shape of it
The whole site is a bet that the interesting part of “personalised” is usually not the personalisation — it is the state you take on in order to deliver it. Move that state onto the only machine that actually cares about it, and the feature survives while the backend disappears.
What is left is a static deployment on Cloudflare. It costs almost nothing to run, there is no database to migrate, and the most expensive thing on the page is a Monet.