Book of Us
A private app I built as a gift, then kept building from its one user's feedback. You can walk through all of it; the memories in there just aren't the real ones.
What it is
A two-person archive of a relationship: monthly chapters, time capsules, a bucket list that converts into albums, a vault, and a purpose-built procedural scene engine that paints the world behind it.
Why I built it
I wanted to build something for a special someone. They became its one real user, and every round after the first came from what they asked for.
It started as a gift, not a portfolio project, and that's most of why it's the one I'm proudest of. I didn't design it once and hand it over. I shipped a version, asked what they liked and what they wanted next, then built that. The chapters, the time capsules, the bucket list that turns into albums, the world drawn behind all of it — none of that was in a plan at the start. It arrived because someone asked. One user sitting next to me, telling me which parts were worth having, is the closest thing to a real product loop I've worked in. Keeping the archive private turned out to be the useful constraint: it forced the privacy guarantees to be structural instead of promised. The code is public. The memories were never in it.
In the app
Captured from the schema-isolated demo, whose contents are seeded fiction.



By the numbers
- lines in the scene engine
- 2,376
- TypeScript source files
- 220
- database migrations
- 13
- dependencies in the scene engine
- 0
Counted against the source on 9 August 2026, not estimated.
The scene engine, running
The landscape below is generated in the browser from one number — sun, clouds, five ridges of fractal noise, all SVG paths, no images, no dependencies. It plays on its own; step back to a seed you have seen and you get that exact world again. This is the same engine the app runs, and it is 2,376 lines in lib/ambient/.
Sun, clouds, five ridges of fractal noise, and the blooms on the near slopes — all derived from the one number above, smoothed with Catmull-Rom splines, and painted by the site’s theme tokens. Every few seconds the seed advances and the world is rebuilt from scratch. Step back to a number you have seen and you get that exact landscape again — on this machine, on the server, and on your next visit. Switch the site to dark and the same landscape falls into dusk.
Decisions that mattered
6 of them. Each opens onto what I did and the reasoning behind it.
The feature list came from the person using it, not from me
What I did, and why
There was no scope document. Each round was: ship what exists, find out what they actually reached for and what they asked for next, build that. The bucket list converting into albums, the time capsules, and the scene engine's presence at all entered the app that way.
A gift built to a specification I invented would have been a gift about me. Working a round at a time meant nothing got written until it had been asked for, so the features I would have guessed wrong on never got built. It is the smallest product loop there is: one user, no analytics, nothing between the request and the person making it. It also taught me more about deciding what not to build than anything else here.
A generated world, because a scrapbook of uploads was the boring version
What I did, and why
The backdrop is not artwork or stock imagery. It is a 2,376-line scene engine with no dependencies, generating sun, clouds, stars and five ridges of fractal-noise terrain as SVG paths from a seed, so a chapter's world is derived from the chapter instead of picked out of a folder.
The obvious build was a static digital scrapbook, which is a photo album with worse ergonomics. Generating the world means the app can have a look for a moment in time without me drawing one for every moment, and it feels like a place instead of a table of records. Seeding it, instead of randomising it, is what makes this a design decision and not decoration: the same chapter has to open onto the same world every time, on server and client both — which is exactly what the Math.sin bug below was violating.
The public demo is isolated by Postgres schema, not by a flag
What I did, and why
The demo client is configured with db.schema: "demo" — its own copy of every table, not a filtered view of production. A check in the test command parses the migrations and fails if any public table lacks its demo twin with RLS; it passes over 11 tables.
Isolation by query filter is isolation you have to get right on every query forever, and one missing WHERE clause is a leak. Isolation by schema means the demo connection cannot name a real table, so the guarantee stops depending on my future carefulness. The parity check exists because the schema split has its own silent failure: a migration can add a public table and forget the mirror, and nothing breaks until someone reports the demo is missing a feature. It reads the migration files, not a live database, so it needs no credentials to run.
Hard delete is impossible because the policy does not exist
What I did, and why
No table has a DELETE row-level-security policy. The single exception is an admin-gated purge that runs through the service role.
RLS denies by default, so omitting the policy is a stronger statement than writing one that returns false — there is no rule to misconfigure. For an archive of things that cannot be recreated, the correct number of accidental-deletion paths is zero.
A hydration mismatch traced to Math.sin
What I did, and why
The scene generator was made to avoid transcendental functions in server-rendered geometry after identical seeds produced different output on server and client.
Math.sin is not required to be bit-identical between V8 in Node and V8 in the browser, and it is not. A one-ULP difference in a noise field is invisible until React diffs the SVG path string and replaces the whole scene. The bug is why the engine is seeded and not random in the first place.
A waiting system with no spinners
What I did, and why
Ten route-level loading files render the shape of the content that's coming instead of a spinner. Reduced-motion support is an enforced invariant across the app.
A spinner says 'something is happening'. A skeleton says 'this is what is about to be here', which is the question the user actually has. Doing it per route instead of per component keeps it from being negotiable later.
Stack
- Next.js 16 (App Router)
- React 19
- TypeScript (strict)
- Tailwind v4
- Supabase (Postgres + RLS + Storage)
- Framer Motion
- Vercel Cron
- Postgres RLSschema-level demo isolation, delete-impossibility
- Procedural generationseeded RNG, value noise, fBm terrain, Catmull-Rom
- SVGthe entire scene is generated markup, no canvas, no images
- Supabase Storageprivate buckets with server-minted signed URLs
What is honest about it
- Ten route-level loading files against two error boundaries, one for the app group and one global. The waiting states are worked out per route; the failure states are a backstop, so a route that breaks gets a generic apology instead of anything that knows what was being loaded.
- The tests are 23 assertions over the pure functions of two features, plus the schema-parity check. Everything with a database or a React tree in it is untested, and there is no CI — the suite runs when I run it.
- The admin allowlist falls back to two hardcoded usernames when ADMIN_EMAILS is unset, which is how it runs.
- The code is public; the archive is not. The demo is a real, fully working instance, but everything in it is seeded fiction and the real book is not reachable from it.
Want this kind of work on your codebase? Email me and tell me what you’re trying to build.