Munchable is a barcode scanner for people managing gut conditions: you pick the conditions you live with, you scan a product, and you get an answer with the reasons under it. The seven it covers are listed on the conditions page, and each one has its own guide, for example IBS and low FODMAP.

It is used in a supermarket, one handed, with a trolley in the other hand. So it makes no noise whatsoever. There is no expo-av, no expo-audio, no sound file in the repo, nothing to mute. Everything the app communicates without the screen goes through one dependency:

"expo-haptics": "~57.0.3", 

Three files import it. Seven calls use it. That is the entire non-visual channel, and the interesting part is how small the vocabulary is.

Six of the seven are the same tick

Haptics.selectionAsync() is the one that fires for anything the user chose:

const toggle = (id: Id) => { void Haptics.selectionAsync().catch(() => {}); ... onToggle(id); }; 

That is the condition picker, the component behind picking what you manage. The same tick fires when the hub flips between reading labels and reading a restaurant menu, and when a drag over the live preview is released to grab a dish.

It also fires at a moment the user did not touch anything at all: when the scanner has accepted a barcode.

// Confirmed: same code, more than one frame, held for long enough. pendingScanRef.current = null; lastScanRef.current = now; void Haptics.selectionAsync().catch(() => {}); void handle(decision.code); 

That is the one I would defend hardest. A camera that is hunting for a barcode and a camera that has found one look almost identical at arm's length over a trolley. The tick is what tells you to stop holding the phone still, and it is the difference between pointing at a jar for one second and pointing at it for four.

The seventh one carries the answer

The result screen's hero has a tap that matches the answer rather than the interaction:

/** The tap that goes with each answer. `unknown` is not a failure, so it is not an error buzz. */ function buzz(verdict: VerdictValue): Promise<void> { if (verdict === 'unknown') { return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } const type = verdict === 'good' ? Haptics.NotificationFeedbackType.Success : verdict === 'caution' ? Haptics.NotificationFeedbackType.Warning : Haptics.NotificationFeedbackType.Error; return Haptics.notificationAsync(type); } 

The comment is the whole design decision. The app has an outcome for "we could not answer this", and the lazy mapping sends that to the error buzz, because there are three good outcomes and an error type sitting right there. But an error haptic is a report about the app failing, and this is the app being honest. It gets a light impact: something happened, nothing went wrong.

The same screen used to spring the disc in and float the word up a beat later. That animation was built on layout animations that hoisted the word out of the flow, where it painted over the line beneath it, so the movement is gone and only a fade is left. The haptic survived that deletion on purpose. For somebody running Reduce Motion, a tap that matches the answer is worth more than it is to everybody else, not less. The visual side carries the same rule: colour, icon and word together, never colour alone.

Every call is fire and forget

Note the shape, which is identical at every call site except inside the hero:

void Haptics.selectionAsync().catch(() => {}); 

void because nothing waits for it, and .catch(() => {}) because haptics are not guaranteed anywhere. The app runs on the web through react-native-web, where there is no taptic engine and the promise rejects. Some Android devices have the vibrate permission and a disabled motor. A device in a silent-and-still mode will no-op.

An unhandled rejection from a confirmation tick is a crash report about a feeling. Worse, an await in front of it puts the feedback on the critical path of the thing it is reporting on, so a scan would wait for the tick that announces the scan. Fire and forget is the correct shape for the entire category: feedback about an action never gates the action.

What stayed out

No haptic on screen transitions, no haptic on a button press that already looks pressed, no haptic for errors that come with a sentence on screen. The budget is small deliberately, because a phone that buzzes at everything teaches you to ignore the buzz, and then the one buzz that means "this product is a problem for you" lands in the same bucket as a navigation tick.

Seven calls, one dependency, no audio. If you want to see what the app is actually answering in those moments without installing anything, the ingredient answer pages run the same check on a single ingredient in the browser.