The Personal Website of Jeremy Boles

Napkin

A notepad calculator that reads like you write.

Every calculator wants you to speak its language. Napkin speaks yours.

I built it when I moved to Linux and lost Soulver, the scratchpad I’d leaned on for years to think out loud in numbers. I wanted something that reads the way a person writes.

What I Needed

I needed to measure the span between two dates, then apply it to another date, so I could estimate how long something I was waiting on would take.

21 May 2026 - 23 April 2026 // 28 days
24 June 2026 + 28 days      // Jul 22, 2026

Those // notes are real syntax, not something I added for this page; more on that below.

How Napkin Works

Every design decision in Napkin follows a single rule: a line either produces a value or it shows nothing. It never shows an error.

That sounds small, but it isn’t. It means the parser has to be forgiving to a fault. Unknown words are just filler, and the last complete fragment on a line wins. You can write lunch with Carmen was 24 dollars, split three ways and get an answer, because Napkin quietly ignores everything it doesn’t recognize and computes what’s left. There’s no syntax to learn and very little to get wrong, which is the whole point of a notepad. No one wants a calculator that scolds them.

The flip side is that Napkin can never tell you why a line didn’t work. If a line is blank, you’re on your own. I’ve decided I prefer that to a sheet full of red squiggles.

But being forgiving has a cost. Here’s the bug that made me add comments. It’s why those // notes at the top of the page exist in the first place.

I wrote a date, then jotted a note beside it to remind myself of something. The note contained a date of its own. “Last complete fragment wins” did exactly what it promised and handed the line to my note:

February 29 2028 // Will be on Feb 28 209 next   →  209

Not a crash, not an error, just a confidently wrong number, which is worse. The tolerance that makes Napkin pleasant to write in is the same tolerance that lets a stray number in a note hijack a calculation.

So // now means “stop reading here”, C-style. It’s a lexer rule rather than a grammar one, which has a nice property: because // is punctuation rather than a word, it costs nothing in translation. Every language Napkin ever speaks uses the same marker. Single slashes are untouched, so 150 / 50 still divides and $120/hr is still a rate.

The one thing it costs: pasting a URL now eats the tail (https://example.com becomes https:). Those never computed anything anyway, so I took the trade.

The Core

The core is a Pratt Parser written in Rust, shipped to the browser as WebAssembly. But the more interesting constraint is what the core is not allowed to do: it has no I/O, no network, and no clock.

That last one surprises people. today is not something Napkin looks up. It’s a value passed into the engine along with exchange rates and your number format. The core is a pure function from text to results. Give it the same document and the same context on any machine, any OS, and you get byte-identical output.

There are three front-ends over that core today (the web app, a terminal version, and a GTK desktop app), and all three drive the engine through exactly one function: hand it the current lines, get back the ones whose results changed. No shell gets its own protocol. There’s also a proof-of-concept C ABI sitting there for whenever I get around to a native Mac version.

Napkin’s language isn’t defined in a document. It’s defined in a pile of plain-text files that look like this:

20°C in °F           => 68°F
$24 a day for a year => $8,765.82
3 kg in pounds       => 6.613868 pounds

Input on the left, expected output on the right. New language features get example lines before they get code, and the test suite runs every line on every build. When I change what a phrase means, the diff shows me exactly which phrasings moved.

On top of that, the full output of every example file gets hashed and the hash is committed. If the bytes ever drift and I can’t explain why, that’s not a language change, that’s a bug, and I go looking for it before I touch anything.

Small Decisions I’m Fond Of

Money isn’t a unit. Units live in a static table with fixed relationships; currencies have rates that change and formatting rules of their own. Conflating them looked tempting for about a day.

Arithmetic is exact. Conversions are done as exact rational numbers with a single division deferred to the very end, so 20°C in °F is 68°F—not 68.00000000000001—and converting back gives you 20°C again. Floating point shows up in exactly two places, both of them deliberate and both documented.

A month is 2,629,746 seconds. When money meets time, Napkin uses the average Gregorian month (a 365.2425-day year divided by twelve). It’s why $24 a day for a year is $8,765.82 and not $8,760. The calendar words stay calendar words; this only applies when a rate has to be bridged into clock time.

20k is twenty thousand, but 20 K is kelvin. One space, and it’s a temperature.

Bare in is never inches, because in is the conversion keyword: 12 km in miles. You have to write inches. Small ambiguities like this are where an embarrassing share of the design time goes.

The Cursor

The parser sounds like the hard part. It wasn’t. What consumed the project was a set of problems that sound trivial right up until you try to solve them: where a text cursor is allowed to sit, whether a note scribbled beside a number quietly hijacks the answer, whether a keystroke lands within one frame on a slow laptop.

The web app has no runtime dependencies at all: no framework, no CodeMirror. The editor is a hand-rolled contenteditable where a plain string is the source of truth and every keystroke is intercepted and applied to it by hand. Undo, copy, cut, and international input are all mine. That decision is the source of most of what follows, and I’d make it again, though ask me on a day when Safari is behaving.

The clearest example is the cursor.

When you reference another line, Napkin shows a little chip with that line’s value instead of the raw text you typed. It behaves like a single character: you can’t put the cursor inside it, and backspace removes the whole thing. That’s the right behavior, and browsers hate it.

No browser will place a cursor before a non-editable chip that starts a line, so I had to move the cursor across it by hand, and the engines disagreed about which way to be wrong. Firefox skipped the start of the line entirely: pressing left jumped to the end of the line above, pressing right overshot past the chip. Holding shift was worse, because it extended the selection over the chip and the newline in front of it, so typing over a selection silently welded two lines together.

Then iOS. Tapping the end of a line containing only a chip did nothing at all. There’s no real text on that line to hit-test against, so the tap landed on a neighboring line. I worked out how to compute the right position from the chip’s geometry, and it still didn’t work, because iOS won’t hold a cursor next to a non-editable element even when you put it there. The fix is genuinely stupid: there is now an invisible zero-width character beside every chip, existing purely so the cursor has a real piece of text to stand in. Every part of the code that reads the document has to know to ignore it.

And that fix created its own bug. The invisible character is nothing to my model but a real character to the browser, so an arrow key at the edge of a chip moved the cursor through it without moving my cursor. The keypress just looked ignored. So the keyboard handler had to take over those steps too.

None of that is calculator work. All of it is the difference between a parser and something you’d actually keep open.

What I took from it: the engine is the part you get to be clever about, and the shell is the part you can only be stubborn about. The engine has a spec, exact arithmetic, and tests that hash every result; I can reason about whether it’s correct. The editor has three browser engines with three different opinions and no spec worth the name, and the only way through is to find each disagreement and pin it down. That’s why the tests drive Chrome, Firefox, and real Safari separately. Every one of them exists because something broke, usually in a way I’d have sworn was impossible.

Speed as a Feature

The whole thing has to feel instant on cheap hardware, like the Chromebooks my kids use at school. This was a constraint I set early and have had to defend.

Every keystroke re-parses and re-evaluates the entire document, but no part of the UI redraws a line that didn’t change. There are latency budgets enforced by a benchmark that fails the build: a 300-line sheet has to stay well under one frame per keystroke. The shipped WebAssembly is about 100 KB compressed and the JavaScript is about 11 KB.

For now there’s no CI service; I use a simple bash script to run the tests and deploy the code. The deploy script is the gate. It runs the tests, the size limits, the browser matrix, and the performance budget locally, and refuses to ship if any of them complain.

Things Left Out

At first I thought I wanted real-time editing, two people in one document. Then I realized this is a personal app, and that real-time sync would strain the servers for little gain. So I left it out.

The interesting part is that cutting it left fingerprints all over the architecture. Real-time collaboration (CRDT-style) demands that two people applying the same edits arrive at the same result, which is where the “no clock, no I/O, pure function” rule came from in the first place. I dropped the feature and kept the constraint, and it earned its keep anyway: it’s what makes the whole language testable by hashing, and it’s why a document typed character-by-character is guaranteed to read identically to the same text pasted in all at once. A feature I never shipped is still one of the best things about how it’s built, which is either a real lesson about constraints or a very tidy way of describing an abandoned plan.

Sharing and Saving

You can share a sheet as a URL with the whole document compressed into it, or download a .npkn file.

The compression is the browser’s own; no compression library ships in the bundle, which would have cost more than it saved. A 300-line sheet goes from a ~6,500-character URL to about 1,500. Past roughly 700 lines the link gets too long to survive being pasted into a chat client, so the button turns itself off and points you at the download instead.

The document rides in the URL fragment (the part after the #) rather than as a query parameter. Fragments are never sent to the server, so a calculation you share stays out of my web server’s logs entirely. That was the whole reason for the choice.

Getting a file back in works two ways: drop it anywhere on the window, or use the menu. I’d cut the file picker when the app went single-document, on the theory that drag-and-drop was the better shape. That was right, but only on a desktop. Phones can’t drag anything, so the download had quietly become a backup with no restore path.

Either route treats an incoming file exactly the way it treats a shared link: it opens as its own document rather than replacing what’s on screen. Nothing you arrive with should be able to cost you what you already had.

The Flight

I built the bulk of Napkin’s parser over the Atlantic Ocean while flying home from Amsterdam.

The trip had two purposes: to visit someone I was seeing and, less romantically, to scope out work. A few weeks earlier, my role had been “impacted as part of this reduction,” as the email put it, under the subject line Important Update Regarding Your Position. Which is to say: fired. For weeks I’d been circling the same itch: some small thing I could build to get a little momentum back.

I hadn’t planned to make that thing on the plane. I’d meant to read, or maybe watch a movie. But I was sort of sick—achy and unfocused, the kind where your eyes slide right off the page—and I couldn’t get into any of it. What I found I could still do, oddly, was build. I fly cheap, always, so this was economy, knees against the seatback. At least I’d landed the aisle. I stood, pulled my laptop from the overhead bin, and wedged it open on the tray table.

I wasn’t starting cold, exactly. I’d built a Pratt Parser once before, and that project was still on the machine, so I began by carving its core out into a shared library for Napkin to stand on. The plan was to rebuild the rest from memory. That held until the first thing I genuinely needed to look up, and I caved and paid for the wifi. Five or six hours later, in a bright cabin full of other people’s movies, the little language mostly worked.

And then I spent a few weeks on everything else. The calculator was a flight. The notepad was months.

The parser is still, in its bones, what I wrote over the Atlantic. Everything around it—the editor, the cursor, the parts that turned out to be the actual work—I’ve rebuilt since coming home. I’m still figuring the rest out; there’s no job yet, no neat ending where the small thing becomes the big thing. But the small thing got finished, and finishing it got the hamster wheel spinning again. I keep opening the laptop for the next idea, and the next. I’m back in the habit of making the computer work for me, instead of the other way around.

Where It Is Now

Napkin is live at napkin.plus. It’s a Progressive Web App, so it installs to a home screen and works offline, and everything stays on your device, stored in IndexedDB in the browser.

That last part is the best thing about Napkin and the thing that nags at me most. Your calculations never touch a server, which is exactly how I want it. It also means my own sheets are stranded on whatever machine I typed them on, and I keep reaching for the one that’s in the other room. How to sync them without betraying what makes Napkin Napkin is the problem I’m chewing on now.