Munchable's main job is a barcode and a label: things with a printed ingredients list. Last phase it also learned to read a restaurant menu. You drag a box around a dish on the live camera preview, let go, and it tells you what that dish declares against your conditions.
The interesting part of building it was not the reading. It was that almost every design decision came out as a refusal, and the refusals ended up being the whole feature.
A menu is not a label, so the answer may not look like one
A packaged product has a legally required ingredients list. A menu has "slow braised beef shin, celeriac mash, gremolata". Four of the five things that matter are not printed, and the kitchen has no obligation to print them.
If the app answered a menu the way it answers a barcode, a clean read would render green, and green in this product means something closer to "this fits you" than "no problem found". On a menu that is not a small overstatement. It is the exact failure that makes a health tool dangerous: the user stops reading when they see the colour.
So the display is capped. One line, in the one place:
/** MENU CAP: menu mode can never show green. Applied for display only. */ export function capForMenu(v: VerdictValue): VerdictValue { return v === 'good' ? 'caution' : v; } And the word for a dish that nothing flagged is not the word a product gets:
/** Label for a dish or condition the engine cleared: honest, never "Good fit". */ export const MENU_NOTHING_FLAGGED = 'Nothing flagged'; Plus one note on the screen that explains the gap rather than hiding it:
Menus rarely print full ingredients, so we only check what this menu declares. A clean result here is never a green light. Ask the staff when you are unsure.
"Applied for display only" is the bit I would defend hardest. The check itself is not weakened or special-cased for menus: the same on-device check runs, and the cap sits in front of the rendering. One way to do this would have been a menu-specific path through the logic, and that path would have drifted from the real one inside a month. A cap at the edge is a thing you can read and point at.
The same caution shows up in the vocabulary. Nothing about a menu result ever uses the word verified, and the per-condition guides on the website are written to the same rule: say what was found, never imply the absence of what was not.
A key that is deliberately not stable
Every product in this app is identified by its barcode. That identifier is what the on-device cache uses, what the scan history row points at, and what makes a second scan of the same jar instant.
A dish has no identifier. It has a position in a photograph:
return { barcode: `menu:${index}`, ingredientsText: dish.description || dish.ingredients.join(', '), dataSource: 'menu', }; menu:0 is not an identity, it is a slot. Photograph the same menu twice and the dish you looked at might be menu:2 on the second read. That is intentional, and it is the cheapest possible way to make a whole class of mistakes impossible. Nothing can cache this. Nothing can write a history row for it that would mean anything later. Nothing can treat it as the same dish as the one you looked at last week, because the key cannot support the claim. The screen's own docblock is the spec:
Nothing here is persisted: no history row, no cache, no photo.
In a product that keeps a scan history on the device and counts the month's scans on the phone rather than the server, a feature that writes nothing at all is unusual enough to need a reason. The reason is that a remembered menu answer is an answer you will trust again in a different restaurant, in a different kitchen, with a different recipe behind the same words.
The photo travels in a module level variable
The camera lives on the home screen. The results live on /menu. So one screen has to hand a cropped, downscaled photo to another.
The obvious route is a route param, and it is wrong twice over. Expo Router params are URL serialisable, which on a phone is merely a smell, and in our browser build is literally the address bar: Munchable's app also runs on the web from the same files, so a file URI in a param is a file URI in the user's history. The second problem is lifetime. A param lives as long as the navigation entry, which is longer than the photo should.
So the handoff is four lines of module state:
let pending: Shot | null = null; export function setPendingMenuShot(shot: Shot | null): void { pending = shot; } export function peekPendingMenuShot(): Shot | null { return pending; } export function clearPendingMenuShot(): void { pending = null; } A module level let is the kind of thing that gets flagged in review, and it is right here. There is exactly one camera and exactly one menu screen, the value is overwritten by the next snap, the reader clears it as soon as it has read it, and nothing serialises. peek rather than get because the name should say that reading it is not taking it.
The capture side is just as careful about ordering. The box release is the shutter, and the sequence is: check the remaining scans before taking a photo at all, take one picture, crop to the boxed region, downscale on device, check the screen is still focused (a blurred screen mid-capture must not stack a second /menu route), hand the shot over, then navigate. A menu read counts against your month only once it has actually produced a reading, which matters on exactly the reads that failed.
Seven sentences for seven failures
The read happens over the network, so it can fail in more than one way, and every one of those ways gets its own sentence:
const FAIL_COPY: Record<OcrFailure, string> = { not_signed_in: 'Sign in to read menus.', not_configured: 'Menu reading is not enabled on this build yet.', over_limit: 'You have hit the reading limit for now. Try again in a little while.', unreadable: 'We could not find a dish in that box. Try again and draw the box around the dish name and what is printed under it.', too_large: 'That photo was too large to send. Try again.', network: 'We could not reach Munchable. Check your connection and try again.', unavailable: 'Menu reading is not available right now. Try again later.', }; Record<OcrFailure, string> is doing real work: the failure type is a union, so adding a reason without writing its sentence does not compile. That is the only mechanism I have found that reliably stops the generic error string from creeping back in, because the generic string is always the easiest thing to type at 11pm.
The one I like most is unreadable, because it is the only failure the user can fix, and it says how: box the dish name and what is printed under it. Before that wording, people boxed the dish name alone, got nothing useful, and concluded the feature was broken. It was: the instruction was missing.
The shape of it
Reading the whole feature back, the specification is four sentences, and three of them are negative.
- It may never render green, and a clean dish says "nothing flagged".
- Its key is a slot, not an identity, so nothing may remember it.
- Its photo lives in memory, is never written down, and is cleared on read.
- It costs a scan only when it worked.
If you build anything that gives people health-adjacent answers from partial data, I would spend the design time on the ceiling rather than the accuracy. Accuracy is a long grind. A ceiling is one function, and it is the thing standing between a confident colour and somebody's evening.
You can see the conditions it is checking against on munchable.app/conditions, the per-ingredient answers it draws on at munchable.app/answers, and the app itself at app.munchable.app, where menu mode sits behind the fork and knife toggle on the scan hub.

