Lemon SqueezyHow it works

Installing it on your phone

The app is a web page that installs like an app: no store, it updates itself, and once installed it opens at the stove with no signal. On an iPhone it installs from Safari, on Android from Chrome.

A web page that installs

The app is a progressive web app: a web page the phone keeps on its home screen and opens like an app, with no store to go through.

From PLATFORMS.md, Installed from the browser, with no app store

asks the phone to open it full screen with no browser bar, from the lemon icon, on home and never on the front door.

From PLATFORMS.md, Installed from the browser, with no app store
Home on a phone: tiles for tomorrow, the shopping and the recipes, and under them tonight’s card listing recipes to choose from, with the button Find a recipe.
Home, where the installed app opens. Photographed at commit c65b9c248, 26 October 2026.

Installed from the browser, with no app store

PLATFORMS.md, the whole section

The app is a progressive web app: a web page the phone keeps on its home screen and opens like an app, with no store to go through. Owner, 25 October 2026: "Also need documentation for what is a PWA and how to install." The manifest (manifest.ts) asks the phone to open it full screen with no browser bar, from the lemon icon, on home and never on the front door.

Once installed, it opens at the stove with no signal. A deliberately small service worker (sw.js) keeps the app's shell, home, on the phone. It keeps no recipe: the recipes and the cooks already live on the phone, in its own database, and a second copy would be the one that goes stale. Every page is asked of the network first, so a new build is picked up at once; after three seconds without an answer the kept shell answers instead, so a kitchen whose wifi has dropped, or only crawls, still opens the app. That was read in Chromium and WebKit by hand; whether an iPhone short of storage still holds what the kept shell needs is the owner's phone's (below).

It updates itself, and never under a pot. A new build is fetched in the background and waits. The app moves onto it only when nothing is cooking, no cursor is in a field and the screen is out of sight (switched away from, or the phone locked), and reloads then, because a reload on a screen somebody is looking at swallows the tap that lands a moment later (use-service-worker-update.ts). It asks each time the app is shown or put away, so an installed app that is only ever resumed still moves on.

To install it on an iPhone, use Safari: open the site, tap Share (the square with an arrow), choose Add to Home Screen, keep or change the name, and tap Add. On Android, use Chrome: tap Install where Chrome offers it, or open the menu (the three dots), choose Add to Home screen and confirm. The lemon is then on the home screen with the phone's other apps. These are the phone's own screens, not the app's, so the docs say them in words and photograph only the app. Which phones it installs on is the baseline above.

At the stove with no signal

after three seconds without an answer the kept shell answers instead, so a kitchen whose wifi has dropped, or only crawls, still opens the app.

From PLATFORMS.md, Installed from the browser, with no app store

These must work with no network, because they are used where there is none. Shopping from a grocery list and ticking it off — a shop is the worst signal a person meets in a week.

From ARCHITECTURE.md, Load-bearing decisions

Load-bearing decisions

ARCHITECTURE.md, the whole section

All session state is a pure fold over an append-only event log. reduceSession(index, events, now) is the only way to learn what's done, what's available, what's on the bench, or how long anything took.

Nothing derived is ever stored. Not "blocked", not elapsed time, not the bench. Storing any of it creates a second truth that drifts from the graph the moment an edit lands. This is a deliberate inversion of the usual read-model advice: the graphs are small, the fold is cheap, and correctness matters more here than a first-paint millisecond. If a recipe ever gets big enough for that to hurt, memoise the fold — do not persist its output.

A screen's own last read may sit at module scope, kept for the length of the tab. This is not the exception above: it is stored nowhere the browser remembers (no IndexedDB row, no localStorage key), it holds nothing that was not already going to be re-read, and it is overwritten the moment that re-read lands, so it can only ever be stale by one read, never wrong forever. Reading IndexedDB is real work at hundreds of recipes (391ms tap-to-first-row on the shelf, most of it recipes.list()'s per-row version join), so a return to a screen paints the last answer at once and reconciles in the background. hooks/use-kitchens.ts (lib/kitchens-answer.ts) established the shape; use-meal-plan.ts, use-grocery-plan.ts and use-kitchen-answer.ts have memories of their own (lib/recipes-memory.ts, lib/recipe-titles-memory.ts, lib/kitchen-answers-memory.ts). The plan is held once, not once per screen: lib/plan-memory.ts is the copy both plan hooks seed from and every plan hook overwrites on a read, and lib/write-plan.ts is the one path the plan's writers take (it queues behind lib/plan-write-queue.ts, remembers the change before it lands so the next screen opens on it, and takes what the store holds when it has); two copies, one per screen, let the older paint after a change on the other. Each memory is cleared in lib/forget-this-kitchen.ts, so a kitchen just left cannot paint under the next one; a pull that lands while no hook is mounted (the cook screen) has nobody's listener, so each memory module also listens at module scope and forgets rather than reconciles, trading one skeleton on the next mount for a stale paint.

Painting from memory needs two more things. The clock has to be free of the read: use-meal-plan.ts and use-grocery-plan.ts hand out an openedAt read once in useState's lazy initializer on the first render, never inside the effect that starts the read, because every screen but Recipes gates its whole first paint on it and a seeded plan would otherwise sit painted nowhere (app/meals/page-memory.test.tsx and app/lists/[listId]/page-memory.test.tsx mount the real page twice and assert the second paints with no <Waiting>). And the fresh read must not hand back a new identity for the same content: RecipeStore.list() clones, so each mount's re-read gave every [recipes]-keyed effect downstream (useMyTimes, useActiveCook) a new array, a new session sweep and a new RingMap label solve (1.7 to 3.2s of blocked main thread on a return to home). Both hooks compare the fresh read to what is painted, a recipe by value (the whole row, not id and currentVersionId alone, since title, tags, scale, notes, repeatRating and lastCookedAt are written without minting a version), a title's two fields, the plan by lib/same-plan.ts, and keep the existing identity when they agree. RingMap and RecipeGlyph also memoise their call into ringLayout; RecipeGlyph asks for no labels at all, since it never draws a name and the label solve was most of its cost.

The grocery screens read a title, never the shelf. RecipeStore.listTitles() answers with the recipe row alone — id, title and the title in each language the recipe carries (so a name reads through translatedTitle as a recipe's does), everything list() already carries before it pays to join each row's current version on. A shopping line names the dish it came from; it does not cook it, so use-grocery-plan.ts is the one hook in the app that never calls recipes.list(). The one thing a line asks of its dish beyond the title is what the stored line does not keep — the variety and the "optional" of the ingredient it came from — so the hook also gets the recipes that put a line on a list, and only those, and keeps those four facts per ingredient (lib/listed-ingredients.ts). They are read each time the list is drawn and never stored on the line.

Timers are timestamp arithmetic. elapsed = (foodReadyAt ?? endedAt ?? now) - startedAt - paused. React re-renders only when a reading can move: the stove folds its log on an event, on the tab's return, or at the next instant the clock alone changes the reading (useEveningFold), and a kitchen timer ticks only while one is set. Nothing accumulates a counter, because a backgrounded PWA stops firing timers and timestamps don't care.

Client-rendered, deliberately. All data lives in IndexedDB, so the routes are client components: the data is on the device, and a cook mid-recipe never waits on a server.

One source of shape truth. Zod schemas in libs/model; types are z.inferred, never hand-declared.

One source of visual truth. apps/web/src/app/tokens.css, injected into every mock deck at build time so they can't drift from the app. See DESIGN.md.

Offline is a promise about the kitchen, not about the whole app — owner ruling, 1 September 2026, and the one home for where that line falls. It had never been written down, so every design that met the question defaulted to "all of it", which quietly constrained things that did not need constraining.

  • These must work with no network, because they are used where there is none. Shopping from a grocery list and ticking it off — a shop is the worst signal a person meets in a week. Cooking a recipe through: the map, the steps, the taps, the clock, and the notes taken at the hob. A cook mid-recipe never meets a spinner or a failure that is really a network's.
  • Everything clever may require one. The assistant, its knowledge, import, sharing, sync. It is a web app; there is a server; a feature that is better for having one may have one.

What that buys is the thing worth naming: knowledge does not have to be shipped to every device. A vendored, bundled table is bounded by what a phone should download, and that bound was shaping the answer to how deep the assistant can go. Held server-side it is bounded by what is worth curating instead.

What it costs is that a smart feature reaching the network is the first thing this app ever sends anywhere, so the consent promised before that happens covers the assistant as a whole rather than being sidestepped by keeping its cheap tiers on the device. Owner ruling, same day: the assistant is a network feature — "I'd rather have powerful functionality than weak offline functionality." So the free tiers are free of cost, not of the wire; the consent is asked once for the assistant rather than per tier, because a cook cannot be expected to know which tier answered them; and the knowledge it reads may live server-side rather than in the bundle. Decided once, here, not per screen.

It updates itself

It updates itself, and never under a pot. A new build is fetched in the background and waits. The app moves onto it only when nothing is cooking, no cursor is in a field and the screen is out of sight (switched away from, or the phone locked)

From PLATFORMS.md, Installed from the browser, with no app store

How to install it

To install it on an iPhone, use Safari: open the site, tap Share (the square with an arrow), choose Add to Home Screen, keep or change the name, and tap Add.

From PLATFORMS.md, Installed from the browser, with no app store

On Android, use Chrome: tap Install where Chrome offers it, or open the menu (the three dots), choose Add to Home screen and confirm.

From PLATFORMS.md, Installed from the browser, with no app store

These are the phone’s own screens, not the app’s, so the docs say them in words and photograph only the app.

From PLATFORMS.md, Installed from the browser, with no app store

Which phones

iPhone Safari from iOS 16.4; Android Chrome, Firefox and Samsung Internet at their current and previous version; desktop Chrome, Safari, Firefox and Edge as a second thought.

From PLATFORMS.md, The baseline

iOS 16.4 is a knife edge, not a margin.

From PLATFORMS.md, The baseline

The baseline

PLATFORMS.md, the whole section

iPhone Safari from iOS 16.4; Android Chrome, Firefox and Samsung Internet at their current and previous version; desktop Chrome, Safari, Firefox and Edge as a second thought. The one home for the numbers is the browserslist in apps/web/package.json, which the build reads: Lightning CSS flattens nesting and adds prefixes against it, SWC down-levels syntax against it. Neither polyfills a Web API, so the JS rules below are ours to keep.

Four things the floor means, each learned the expensive way:

  • iOS 16.4 is a knife edge, not a margin. Regex lookbehind, dvh, the Screen Wake Lock, :has() all arrived in 16.4. A regex an engine cannot parse fails the whole script — not one match, and no error boundary catches a parse error — so a regex feature newer than 16.4 is never used, and a Web API that arrived at 16.4 is feature-detected (use-wake-lock.ts). Lookbehind is at the floor and not past it, and shipped code does use it (lexicon.ts and part-stated.ts, both in the browser's bundle); form-of.ts and issue-naming.ts are simply written without it.
  • A phone browser can refuse storage outright — Safari with every cookie blocked, a managed phone — and throws on touching localStorage/sessionStorage at all. The public share cook reads its log through a guard (visit-cook.ts); the device preferences do not yet (sq-mgew).
  • A share sheet or a clipboard write must be called in the tap's own turn. iOS Safari refuses both once an await has spent the tap's user activation, so navigator.share is never reached after a network wait if a held value can be used instead. Send mints on the tap (a link nobody asked for is never minted, IMPORTING.md rule 1), so a sheet refused after that wait leaves the link on the screen and the next Send offers that same link from the tap (use-share-recipe.ts). Playwright's WebKit cannot show the refusal: it has no navigator.share and lets a clipboard write through after a wait.
  • crypto.randomUUID exists only on a secure origin. Production is https and localhost is secure; a phone on http://<lan-ip>:3100 is not, and every bare call throws there (sq-mgew).