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
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.tsandpart-stated.ts, both in the browser's bundle);form-of.tsandissue-naming.tsare simply written without it. - A phone browser can refuse storage outright — Safari with every cookie blocked, a managed
phone — and throws on touching
localStorage/sessionStorageat 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.shareis 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 nonavigator.shareand lets a clipboard write through after a wait. crypto.randomUUIDexists only on a secure origin. Production is https andlocalhostis secure; a phone onhttp://<lan-ip>:3100is not, and every bare call throws there (sq-mgew).