The Penny Post

The Penny Post: development and architecture

A postcard you can watch arrive

The Penny Post is a small public exhibit at pennypost.mechanicalcupcakes.fun. You pick an engraved front, write a few lines, address it to a name, and post it. A stamp lands, a cancellation mark strikes it, and the card goes into transit. Wait about twelve seconds and it arrives: a flag drops, an unread badge appears, and you can open the card and turn it over to read. Then you can take it home as a picture.

It sends nothing. It stores nothing on any server. It has no accounts, no database, no API routes and no environment variables. That isn't a limitation I worked around. It's the argument the exhibit is making, and this document is the account of how it got built and what the decisions cost.

The private tier is the lab; the public tier is what it spawns

This app has a parent. I built a private postcard app for myself and someone close to me, so we could send each other good-morning and good-evening notes with a picture attached. It's still private, still gated, and deliberately not part of the gallery. I'm not going to describe what's in it or link it, and that boundary is the point rather than an omission.

It was a good little project and I wanted to show it off. That's the whole origin of this one. The problem is that showing off the original would mean either handing strangers a login to a private mailbox, or standing up something that sends real mail on behalf of anonymous people. I don't want to own either of those. So the question became: what's the smallest version of this that a stranger can actually use, that teaches the same thing, and that can't be turned into a spam cannon?

That question is most of the design.

There's a pattern underneath it that I follow on purpose, and it comes out of permaculture rather than software: patterns to details. Nearly everything I've built started as something for my own use, and some of them turn out to solve a problem other people have too. Working that way is a form of risk management as much as a creative method. You spend resources you already have, on a problem you already understand, before you spend resources you don't have on a guess. If it works for me it might work for somebody else, and if it doesn't, the only person who lost time is me.

The other thing this one is meant to be is forkable. Somebody just starting out should be able to copy it and make their own version, for whoever's in their life, without asking anyone's permission and without a platform deciding that the only replies available are a heart and a thumbs up.

Cutting the email killed 60% of the build and made the demo better

The public version was going to send real postcards by email, gated behind a CAPTCHA. I killed it, and the reason is worth stating plainly because it wasn't a technical failure. The private original does send mail, and it works fine.

The difference is who it sends to. The original only ever mails people who already know each other, at addresses that were known in advance. Opening that up is a completely different piece of software: a sending domain standing up from scratch, whose first production traffic is anonymous strangers submitting arbitrary text, next door to the address I run a business from. That's an abuse vector with a toy attached to it. I don't like spam, and I'm not going to build something that makes it easier for somebody else to send.

Cutting it removed roughly 60% of the remaining build and 100% of the abuse surface.

What I didn't expect was that it produced a better exhibit. A real sending app can only ever show you the sending half. You compose, you hit send, and the story ends at your screen. The simulation shows you the card arriving, which is the half the private app is actually about and the half that makes anyone feel anything.

The theme is the thesis, not the decoration

The app is dressed as an 1840s broadsheet because it's named for the Uniform Penny Post, the reform that made British postage a flat prepaid penny instead of a distance-priced charge collected from the recipient at the door. The full history is on the about page, and I'm not going to repeat it here.

What matters for the architecture is that the theme and the privacy claim are the same claim. A capable thing gets held behind a price, or a login, or a form that wants your details, and then somebody makes it cheap and open and it turns out a lot of people were waiting. The app asks you for nothing because that's the thing it's about. Every technical decision below comes from wanting that sentence to be exactly true rather than roughly true.

Three runtime dependencies, and that number is a constraint

The stack is Next.js 16, React 19 and Tailwind 4, built with output: "export" so the whole app compiles to static files. apps/pennypost/package.json has exactly three runtime dependencies: next, react and react-dom. Everything else is a build tool.

That number's a constraint, not a boast. Every dependency is code I'd be shipping to someone's browser on the strength of somebody else's judgment, and the app's central claim is about what does and doesn't happen in that browser. The icons and engravings are hand-written inline SVG for the same reason, not because an icon library would've been hard to add.

The file map is small enough to read in one sitting:

apps/pennypost/
  app/
    page.tsx          the exhibit
    about/            why it is called that
    globals.css       the whole design system, such as it is
  components/
    PennyPost.tsx     the state machine and all five beats
    Engravings.tsx    four line engravings, drawn in code
    Stamp.tsx         the stamp and its cancellation
    PostcardFlip.tsx  the 3D flip
    LetterRack.tsx    the arrivals rack
    Masthead.tsx      the broadsheet furniture
  lib/
    types.ts          Card, and the delivery predicate
    seed.ts           the cards that exist before you write one
    session.ts        the entire persistence layer
    png.ts            the take-home encoder

Five beats: the counter, composing, transit, arrival, and taking it home.

Five things that were harder than they looked

Prerendering fights session state. A static export prerenders every page in Node, where sessionStorage doesn't exist. Read it during render and the build dies with ReferenceError: window is not defined. What makes this genuinely nasty is that npm run dev passes clean, because dev mode doesn't prerender. So the app starts from seed data with a clock of 0, and adopts real session state in an effect after mount, behind a loaded flag. Seed cards carry deliverAt: 0 so that 0 <= 0 marks them delivered at the very first paint, which keeps the prerendered HTML and the first client render identical.

The lint rule is wrong here, and saying so in the file was the fix. React's react-hooks/set-state-in-effect rule tells you to derive the value during render instead. In this app that advice is precisely the bug: deriving it during render is the build-time crash above. The disable comment carries the reasoning, because a bare eslint-disable is a decision nobody can audit later.

Delivery is a timestamp, not a timer. A setTimeout would have been the obvious way to make a card arrive. It's also wrong: refresh mid-flight and the timer dies with the page, stranding the card forever. Instead each card stores a deliverAt epoch, a 500ms tick updates now, and delivery is derived (deliverAt <= now). Refreshing during transit and watching the card still land on schedule is the check that proves it.

Dates had to be frozen at write time. Calling toLocaleDateString() during render makes the build host's timezone and the visitor's disagree, which breaks hydration in a way that's invisible until it isn't. Every card carries a pre-formatted dateLabel string instead.

Two bugs in the take-home encoder, both invisible in casual testing. The engravings are inline SVG styled with CSS custom properties, and those do not resolve once the SVG is serialised into a standalone data URL. There's no document left for var(--ink) to look up, so every stroke rendered black on black. The fix substitutes computed values before serialising. Separately, requestAnimationFrame never fires in a hidden or background tab, and tabbing away while waiting for a download is entirely ordinary. On its own it left the button stuck on "Preparing…" forever with no recovery but a reload. It's now raced against a 150ms timer so the export always proceeds.

The art is drawn in code because a web font would make the app lie

Fonts are system serif stacks. Not one web font, because a single request to Google Fonts would make "nothing leaves your browser" false, and the app would be telling a lie about the exact thing it exists to demonstrate. A claim you can check is worth more than a claim you assert, and this one is checkable in about fifteen seconds with the network panel open.

The card fronts are line engravings drawn as SVG paths. That's period-correct for 1840 and it's also the achievable option for one person who is not an illustrator. Both things are true, and I'd rather say so than pretend the constraint was a preference.

A zero-runtime app that still needs a container

The build produces apps/pennypost/out/, a folder of static files. That folder is the website. You can open it locally, zip it, hand it to somebody.

It still ships inside a thin nginx:alpine container, published to GHCR by CI and deployed by compose behind Nginx Proxy Manager. That looks like overkill for a folder of HTML, and there are two reasons it isn't. The gallery shell embeds this app cross-origin, so something has to send a frame-ancestors policy, a correct multi-page try_files rule and a /health endpoint. That's an nginx config, and an nginx config wants a container to live in. And it keeps this app on the same pipeline as everything else in the repo instead of inventing a second deploy path for one exception.

A check that cannot fail is not a check

The rule I try to hold to is that a passing check only counts if I've watched it fail first.

The hydration gate is the worked example. I deliberately added a sessionStorage read during render to confirm the build would abort, watched it throw, and reverted it. That's the only reason I trust a green build here. Before that experiment, "the build passes" meant nothing, because I had no evidence the build was looking.

The same discipline covers the rest. The zero-network claim is checked by loading the app with DevTools open and counting non-origin requests, and the check discriminates because adding a single remote image makes it fail. The exported PNG was opened and inspected rather than assumed. The live deploy is verified from outside with curl, asserting both that the new state is present and that the old state is gone, because only the second half can catch a deploy that silently didn't land.

What is not finished

There aren't any unit tests. The build gate and a live walkthrough have carried the whole verification load so far — defensible for a toy, less defensible now this app is the worked example other people are meant to learn from.

There's no lockfile in this app, so its Docker build installs from package.json alone and resolves fresh every time. That's a real supply-chain exposure and it's the reason a new dependency here gets argued about rather than added.

The nginx config still names a www. host in its frame-ancestors policy that has no TLS certificate, so that token sits inert. It's harmless and it's untidy, and it's tracked.

What it taught me

Two things, and the first one is about design.

Good design is produced by editing. A thing you imagine doesn't have much shape at first. It can be felt more easily than described, and most of the work is finding its edges. Constraints do that for you, which is why every good decision in this build came from one: cutting email made the demo better, refusing dependencies made the privacy claim checkable, and prerendering forced a state model that survives a refresh mid-flight, which a setTimeout version never would have. You can't build a thing that has everything for everybody. Working inside limits is what makes something useful to somebody in particular.

The second is about documentation, and it took me longer to get to.

Some of the most marketable things I have ever done are undocumented. They didn't need a bureaucracy at the time, so there isn't one, and that work is effectively invisible now. It matters more in this field than in most: if something happened on a computer, people reasonably assume a record of it exists, and when there isn't one the absence gets read as absence of work.

For context on why that lands hard for me: most of the last twenty years I spent doing farm work, some disaster response, a bit of construction. I was using computers the whole time, because that's a thing I'd do whether anybody paid me for it or not, and some of it was genuinely useful — barcoding stock coming back from a processor so it could be tracked out through a farmers market, that kind of thing. Almost none of it left a record anybody can read. This does.

So the writeup isn't overhead bolted onto the project. It's the part that survives. And the tools I teach are very good at exactly this, which means there's no longer much excuse for not having one. Documentation is also, in the plainest sense, an accessibility measure. It's how somebody who wasn't there gets in.

Provenance

Repo: github.com/ErnestOfGaia/Mechanical-Cupcakes-OS-Shell, in apps/pennypost. Written by Ernest (they/them), September 2026.

This file is also the page at /under-the-hood/. There is one copy of it, rendered at build time, so the page and the repo cannot drift apart.

If you want to talk about building something like this, text 503-664-0546 or email eog@ernestofgaia.xyz.

← Back to the counter