Inspiration

Dietitian Abbey Sharp teaches a simple idea: a meal holds you when it contains two or more of protein, fibre and healthy fat. No counting, no tracking, no restricting.

Almost every nutrition app does the opposite. It asks what you ate after you ate it, converts it into a number, and compares that number to a target. That is a tool that can only ever tell you what you did wrong — and the Shipaton's Nutrition brief asks, in as many words, for an experience that works "without calorie counting, macro tracking, or restrictive meal plans".

The name came before the code. κρᾶσις is Greek for the blending of elements in due proportion; in Hippocratic medicine, health was a good krasis. It is the same idea applied to a plate, and it turned out to be the product thesis as well as the wordmark — which is why the app opens by explaining its own name rather than by asking for anything.

What it does

You type what you're about to eat. Krasis reads it for the three compounds, fills three vessels, and tells you whether it will hold you. If it won't, it names one thing to add — chosen only from staples you told it you actually own, so it never suggests quinoa to someone who has never owned quinoa.

Then, about two hours later, it asks one question: still full? One tap — starving, fine, stuffed. After a few answers it stops guessing and starts telling you what actually holds you, which is different for everyone and is the one thing no calorie count can tell you.

There are no calories in it. No macros, no logging, no streaks, no goals, no score.

How we built it

SwiftUI and SwiftData on iOS 18, Swift 6 with strict concurrency on, plus a WidgetKit extension and a watchOS app. The project is generated by XcodeGen (project.yml is the source of truth, the .xcodeproj is git-ignored), and GitLab CI turns a pushed tag into a TestFlight build in about seven minutes with nothing pressed.

The food table is the actual work

The app does not call a nutrition API. It ships 274 foods, each reduced to three booleans, built by merging four openly licensed national databases:

Source Country Licence
BLS 4.0 Germany CC BY 4.0
Ciqual 2025 France Etalab 2.0
CoFID 2021 UK OGL v3.0
USDA SR Legacy USA Public domain

That is 21,304 rows of evidence collapsed into 173 derived foods, plus 101 hand-curated dishes the databases have no row for. Two findings were worth the trouble:

  • Composition barely differs between countries. Chicken breast is 22.5 / 23.4 / 23.25 / 23.3 g of protein across the four. For a boolean, the source is almost irrelevant.
  • Coverage differs enormously. USDA contains exactly one Greek cheese. That is why four sources, and why the hand-curated set exists at all.

The threshold that made it honest

The interesting part is deciding what "contains protein" means. A gram threshold alone does not work. Let $p$ be grams of protein per 100 g, $s$ the typical serving in grams, and $E$ the energy in kcal per 100 g:

$$ \text{protein} = \begin{cases} \text{true}, & p \ge 10 \[4pt] \text{true}, & \dfrac{p\,s}{100} \ge 6 \;\wedge\; p \ge 3 \;\wedge\; \dfrac{4p}{E} \ge 0.20 \[4pt] \text{false}, & \text{otherwise} \end{cases} $$

The second branch is the one that took a bug report to find. Pasta was reading as a protein source. At $p = 5.4$ on a 200 g plate it clears the per-serving bar easily:

$$\frac{5.4 \times 200}{100} = 10.8 \ge 6$$

Meanwhile bread — the same flour, with more protein per 100 g at $p = 8.7$ — fails, because a slice is 60 g:

$$\frac{8.7 \times 60}{100} = 5.2 < 6$$

So the food with more protein was the one being rejected. And no gram threshold fixes it. Lowering the serving bar to admit bread admits pasta too. Raising the density floor above pasta's 5.4 throws out whole milk, which is only 3.4 g per 100 g and is unarguably a protein source in a 250 ml glass:

$$\frac{3.4 \times 250}{100} = 8.4 \ge 6, \qquad \frac{4 \times 3.4}{63} \approx 0.215 \; \ge \; 0.20$$

Milk and pasta sit on opposite sides of a line that no gram threshold can draw between them, because the thing that separates them is not how much protein is there — it is what fraction of the food is protein.

The energy share separates them. Protein supplies 4 kcal/g, and pasta is 147 kcal per 100 g:

$$\frac{4 \times 5.4}{147} \approx 0.147 \; < \; 0.20$$

0.20 is EU Regulation 1924/2006's line for "high protein". Applying it demotes pasta, wholemeal pasta, couscous, bulgur, quinoa and buckwheat — and nothing else.

It gates the per-serving path only — the first branch of that formula takes no further test, and that is not a shortcut. Applied everywhere, the share demoted every nut and seed: almonds are 21 g of protein per 100 g but 598 kcal of mostly fat, so $4 \times 21 / 598 \approx 0.14$, comfortably under the line. Almonds are a protein source. The rule has to know that a food can be dense in protein and dense in fat.

Healthy fat has a pleasingly small rule — unsaturated must beat saturated:

$$\text{fat} = \Big(u \ge 5 \;\;\vee\;\; \big(\tfrac{u\,s}{100} \ge 5 \;\wedge\; u \ge 2\big)\Big) \;\wedge\; u \ge \sigma$$

where $u$ is unsaturated fat per 100 g and $\sigma$ is saturated.

That single comparison excludes butter, ghee and hard cheese while including olive oil, nuts and avocado.

Monetization

RevenueCat, with a live A/B experiment scored on revenue per customer rather than conversion rate — the two arms sell a €49.99 one-off against a €24.99/yr subscription behind a trial, so the arm that converts fewer people can still earn more. The app never picks an arm: it asks for the current offering and draws what comes back, so shipping the winner is a dashboard change rather than a build.

Challenges we ran into

Four builds went out for one crash before anyone symbolicated the log. The frame offsets were byte-identical across two very different builds — the tell that the culprit had not moved while three fixes went past it — and that was sitting in the crash report a day before anyone noticed. The cause, once atos had resolved it in one line: an Objective-C delegate method written in Swift's async form. The compiler's @objc thunk calls the completion handler on whatever thread the continuation resumes on — the cooperative pool — and UIKit asserts main thread inside it. Reading the log was right; stopping at the log was not.

The same trap has a second, different shape. When the Watch app landed, its WCSessionDelegate crashed on launch with "Block was expected to execute on queue [com.apple.main-thread]". UNUserNotificationCenterDelegate callbacks arrive on the main queue, so the @MainActor + @preconcurrency pattern that is correct there is wrong for WatchConnectivity, whose callbacks arrive on its own background queue. @preconcurrency silences the compiler; it does not move the thread.

A settings switch nobody could switch. Every row carried .contentShape(Rectangle()) so the navigating rows would be tappable full-width — which took the hit area of the toggle rows too. The switches drew, read their stored value, animated nothing and could not be changed. It compiles, it screenshots perfectly, and no unit test reaches it. Found by tapping the thing.

The app answered for a dish it had never heard of. παστίτσιο matched nothing, so the reading was empty, so the verdict was "This won't hold you long." — evidence of absence from absence of evidence, on the one screen whose entire job is being trustworthy. Now an unread meal gets no vessels, no verdict and no rescue; it gets a question instead, and the answer is remembered.

"None of these" had to become sayable. The unknown-food card's confirm button was disabled until a compound was picked — so a food that genuinely has none of them (ketchup, a coffee, a spoon of jam) could never be taught. The only exit stored nothing, so the app asked again on the next plate and forever. That was most of what "the food database is poor" felt like.

A user report that the table was poor turned out to be four foods that were all present and all correctly empty. A cucumber will not hold you. But four blanks in a row reads as ignorance whether or not each blank is right, so the fix belonged on the read screen rather than in the data.

Accessibility was worse than it looked. VoiceOver labelling was good, but Font.system(size:) does not scale — and every size in the app is a literal point size from the design spec, so the whole thing was frozen at the default text size, including at all five accessibility sizes. Fixed by mapping each design size to the text style it most resembles: at AX5 body text grows about 3.1× while large titles grow about 1.9×, so scaling a 31pt wordmark off body would put it past 95pt and off the screen.

What we learned

Deriving a boolean is a completely different problem from deriving a number, and much more tractable. "Does this contain protein?" survives disagreement between four national databases; "how much protein is in this?" does not.

A food with none of the three is an answer, not a gap. Half the reported "missing food" bugs were foods that were present and correctly empty.

Thresholds need a worked example that is wrong, or you cannot tell whether they are right. The pasta-versus-bread pair did more to shape the data pipeline than any amount of reading the regulations.

The design decisions that survived are the ones with a reason attached. "No second colour, ever" sounds arbitrary until you notice that a green vessel would import exactly the pass/fail grammar the product argues against. Writing the reason down is what stopped it being relitigated at 1am.

What's next for Krasis

Voice entry — the entry screen was designed around it from the start. And closing the loop between the check-back answers and the rescue ranking, so the app suggests what has actually held this person rather than what the table says should.

Built With

Share this project:

Updates

Submission history