Inspiration

Everyone has stared at a restaurant wine list and ordered the second-cheapest red.

Not because they wanted it. Because the list has forty bottles, none of the names mean anything, the waiter is standing there, and the second-cheapest is the socially safe guess. The problem isn't that people don't care about wine. It's that the information arrives in the wrong format, at the wrong moment, with a clock running.

Existing wine apps solve a different problem. They tell you about a bottle you're already holding — scan the label, see a rating. That's useful in a shop and useless at a table, where you don't hold anything and the question isn't what is this but which of these should I order.

What it does

Photograph a wine list. Text is recognized on the device, and one bottle comes back with three lines of reasoning and a short sentence you can say out loud to the waiter — with pronunciation help when the name fights back.

Refine without a second photo. Tap what you're eating and what kind of night it is, and a different pick comes back from the same list. This costs nothing: a credit buys a photograph's worth of answers, not one answer.

Or bring no list at all. Say what you're cooking and get a wine style plus what to look for at the shop. That path is free, permanently.

How we built it

The architecture follows from one decision: the photograph never leaves the phone.

Apple's Vision framework recognizes text on-device and returns bounding boxes. Only the recognized text is transmitted. That isn't a privacy policy, it's a structural property — there is no upload path, so there is no EXIF to strip, no location to leak, and nothing to retain or breach. It also means we cannot delete your photographs on request, because we never had them.

The boxes do double duty. They drive the scan animation — lines underline in sequence, non-candidates dim, the match lifts off the photo into the card — which begins immediately, on-device, while the network call is still in flight. The animation hides the entire inference latency rather than papering over it.

Behind that: a Cloudflare Worker holding the API key, Claude Haiku 4.5 for the recommendation, RevenueCat for purchases. A scan costs about a third of a cent.

The interface is monochrome bottle-glass, and the only saturated color anywhere in the app is the color of the wine being recommended. Sommeliers assess a wine by tilting the glass and reading rim variation — the gradient from dense core to pale edge, diagnostic of age and grape. The recommendation card's background is that read, rendered, derived from 142 authored wine styles across 66 varietal fields.

Challenges we ran into

Hallucination is the catastrophic failure. If the app recommends a wine that isn't on the list, the user says it out loud to a waiter and gets embarrassed. So the model never generates a wine name — it selects an index, and the server validates that selection against the raw recognizer output before anything reaches the screen. Wine styles come from a bundled enum, not from generation. The model does the matching and the voice; it never supplies the facts.

Wine law has more exceptions than a lookup table holds. We derive grapes from appellations, so a French list resolves even when no varietal is printed. Then a real menu offered a Chablis Pinot Gris — which shouldn't exist, since Chablis is Chardonnay by law. Enforcing the rule would have refused a bottle that had been read perfectly. The user's transaction is with the restaurant, not with French wine law: when the list and the world disagree, follow the list.

A validator can dead-end the user it exists to protect. We made a malformed price inside a wine name a validation failure. The model put the price back on retry, so a menu that had resolved confidently a minute earlier came back as "nothing on this list fits" — the exact failure the validator was written to prevent, caused by the validator. The rule that came out of it: only refuse over something a retry can actually fix, and never over a field the pipeline is already willing to drop.

Getting the palette right took measurement rather than taste. An early version looked correct and turned out to have a 5% luminance delta across four age states — visually identical. Fixing that revealed forty red varietals sharing byte-identical colours. Fixing that revealed the rim hue rotation was pinned flat by a legibility function applied to the wrong element: the parameter the whole design is named after was being held constant by one line of code.

Accomplishments that we're proud of

Every contrast figure in the app is asserted, not estimated. A harness renders each screen twice — once normally, once with text hidden — diffs them to get an exact glyph mask, and samples the real background pixels under those glyphs. Fourteen elements across twenty screens, checked on every build. It caught two shipping accessibility failures that three previous rounds of review had called clean.

A monetization design, not a paywall. Credits rather than a subscription, because wine ordering is episodic and clustered — the usage shape subscriptions churn hardest on. Someone with unused scans is dormant with a balance, not lost. Credits never expire. One credit covers a photograph and every follow-up from it, because charging to correct a recommendation means charging for our own uncertainty. Planning without a photo is free, because it's the weaker output and the top of the funnel. A subscription tier is built and deliberately unopened — one entitlement check covers both models, so it can be switched on from the RevenueCat dashboard with no app update and no re-review. Every balance is enforced server-side, decremented before the model call, and refunded when validation rejects a pick.

The privacy claim is structural. Most apps promise not to misuse your photographs. Bruce Wine can't, because it never receives them.

What we learned

A cost anomaly is often a bug wearing a price tag. Scans were costing 1.8× what they should. We spent days measuring token scaling and concluded retries were a fact of life. They weren't — a single optional field had never once worked in production, failing validation on every call, and the only symptom it could produce was money. Two readers in ten were also getting "nothing on this list fits" because of it.

Ask a model to judge before it generates, not after. Our confidence field fired low zero times in 47 runs. The rubric wasn't the problem; position was. Structured output generates in property order, and the field sat last — so the model was rating a choice it had just spent 200 tokens arguing for. Moving it before the pick took low from 0 to 6.

A test without the thing under test is a measurement mistaken for a result. Five separate times, a fixture certified something by being simpler than production: a fake KV without KV's constraints, an audit route rendering a card missing two of its buttons, a contrast harness asserting a scrim that had been silently deleted. A wrong fixture fails loudly. An incomplete one passes, and reports success about the part it isn't testing.

What's next for Bruce Wine

Shelf scanning at supermarkets, which works when labels face forward and needs capture guidance when they don't. Favorites, so the app learns what you actually like rather than what you tell it. And offline recall, because the restaurant basement is the design case, not the edge case.

Built With

Share this project:

Updates

Submission history