Personal project · Solo design & engineering · 2026
Carb Kun Carb
Point the camera at a dish. It comes back as a sticker, with its macros on it.
Work in progressThe problem
Food logging dies at the search box. Nobody types a dish name and picks from nine results at a hawker centre, so the habit never survives week one.
What I did
Designed logging as one gesture — point the camera, get a die-cut sticker with macros attached — and derived the whole identity from that sticker.
Where it landed
In active development, 2026: identity system complete, bilingual from the first commit, working recreations on this page.
The idea
Strava turned a run into something worth posting; Instagram made the photo the whole point. Carb Kun Carb does that for what's on your plate. You snap the dish, it gets cut out of its background, poofs into a sticker, and lands in your day with the macros already attached — so logging is the fun part rather than the tax you pay for it. Built for the food of Asia and cutesy on purpose: a hawker plate should be as easy to log as a protein shake.
Overview
One Expo / React Native codebase for iOS and Android, TypeScript strict, English and 简体中文 from the first commit. The camera, the cutout and the animation live on the client; a small Node worker handles segmentation, dish naming and recap video rendering; Supabase Postgres and Storage sit behind row-level security. It's a personal project I'm building the way I'd build a production app — generated design tokens, an evaluation harness for the model, and a test that proves the access rules.
My role
Everything: the brand and visual identity, the interaction design, the React Native app, the worker service, the database schema and its policies, and the tooling that regenerates the design system from one source of truth.
The design decisions behind it
The sticker is the idea, but the reason it works is a set of decisions about friction — where to remove it, and where a little of it is worth keeping.
Logging in one gesture
Point the camera at the plate. No search field, no portion picker, no scale. Every step removed from logging is a day the habit survives, and search was the step that killed it — nobody wants to type “char kway teow” and pick between nine results at a hawker centre.
Macros ranked, not listed
Calories first at display size, then the split bar as the shape of the meal, then the individual chips. Four numbers presented flat is a spreadsheet; ranked, it's a glance. The bar is reinforcement, never the only carrier — each chip still holds its own label and figure.
Bilingual from the first commit
English and 简体中文 were both first-class from the start rather than a localization pass at the end, because half the dish names in this app are CJK. That constrained the type stack, the line lengths and the chip widths — retrofitting any of those later would have meant redrawing the layout.
Designed down to the smallest surface
The mark was tuned against the 29px app icon, not the hero lockup, and every nav glyph lives in a 44dp target with an accessibility label that is the control's only name. If a design only works at the size you drew it, it isn't finished.
The mark: a camera wearing its lunch
The camera reads the product, the bowl reads the subject, and the face reads the tone — young enough for a teenager, warm enough for a grandmother.
A bowl of rice, worn like a hat
The subject, not a garnish. The bowl stays much wider than the mound it holds — that ratio is the only thing keeping it from reading as a mushroom at 29px.
A lens that looks back
The face lives inside the lens, so the camera and the character are one object rather than a mascot standing next to a product.
The sparkle is the flash
It doubles as the moment the sticker pops out — the app's one piece of delight, borrowed by the icon.
Keyline first, fill second
A cream lens on a persimmon body is only 2.57:1. The cocoa outline carries the separation, which is why it never thins below 1.5dp.
The wordmark flips in the dark
Cocoa on espresso is 1.47:1 — the words simply vanish. The dark lockup swaps them to milk while the mark itself is unchanged, because its cocoa keyline still reads against its own bright fills. That's why a separate dark lockup file exists rather than a CSS filter.
It survives the shrink
Every size below is the same file. Past 40px the face becomes a suggestion and the silhouette does the work — bowl, body, sparkle.
iOS applies its own corner mask, so the shipped icon is a full-bleed square with no transparency. Android gets the mark and ground as separate adaptive layers, plus a single-colour themed icon.
What I've built so far
The sticker maker
Photo → subject cut from the background → a pixel-dust dissolve → named and saved. Three cutout tiers that degrade instead of failing: a server pass returning a trimmed sticker and a registered mask, an on-device pass through ML Kit and Apple Vision that works offline, and a plain photo if neither is reachable. Every native module is lazy-loaded behind a cached try/catch, so one JS bundle runs even on binaries where the native side isn't linked.
Dish identification behind a seam
A vision-language model names the dish and attaches its macros, sitting behind an interface with a stub fallback so the provider is swappable — and it needed to be. I picked the model on measured availability rather than version number, after a newer, faster one answered two probes out of eight. Reasoning models also spend their token budget before answering and emit the result twice around a closing think tag; both are handled in the parser rather than wished away.
Measured, not assumed
There's an eval harness over my own labelled food photos, because the failure modes aren't equal: a wrong name is cosmetic and gets retyped, but a wrong dish ID silently attaches another dish's macros to someone's food. Below 0.72 confidence the interface asks instead of asserting — it never presents a guess about what you ate as fact.
Local-first, then sync
Zustand and AsyncStorage are the source of truth on the device; Supabase Postgres and Storage back it up in the background. Row-level security scopes every row to its owner, and a SQL test suite proves the visibility rules rather than trusting them. Google sign-in, pull-down restore onto a new device, and worker-side account export and deletion where the deletion order is load-bearing.
A day you can share
The worker composes the day's stickers into scenes with sharp, renders them to video with ffmpeg, and serves the result from object storage behind a signed token — a short recap of what you ate, made to leave the app.
The dish poofs into a sticker
The one piece of delight in the app, and the most constrained thing in the codebase. Solid dots in the page colour pop in over the photo and eat the background, while the cut-out dish sits on a layer above, untouched. This is a web recreation of the real thing — scroll it into view, or replay it.
Solid-colour views only
Two images and about a hundred tiny circles, total. No Skia, no image tiles — a few hundred image-backed views choke a mid-range GPU and the animation snaps to its end state after a handful of frames.
Every dot owns its own clock
No shared progress value. Reanimated is timestamp-based, so one central driver makes the whole grid jump together after a heavy frame. A hundred independent timings degrade gracefully — a dropped frame costs one dot a few milliseconds and nothing else.
The dots ARE the background
Their colour must equal the page behind the component. A mismatch of even a shade exposes the trick, because the dots aren't dust — they're the background arriving early.
Exactly one segmentation pass
A second inference during the reveal starves the render pipeline. This survived three rewrites of the animation before the real culprit turned out to be upstream, in the capture handler.
Timeline: dots start at a random point in an 800ms scatter and take 350ms each to pop, so the last lands at 1150ms. The photo then fades out beneath them as a coverage safety net — the dots never have to achieve perfect coverage on their own — and a short hold lets the sticker sit alone before the screen moves on.
The identity, running
Built from the app's own tokens and icon geometry, so these can't drift from what ships.
⚠ Recreations, not device captures
Nav buttons are 44dp and sit on their own line. Four of them plus gaps is about 200dp, which will not share a row with a long date on a 360dp phone without truncating it — and the date is the more useful thing.
The Google button is deliberately outside the palette. Their brand guidelines require their own mark on a white or near-black surface, so persimmon is not an option. That exception is what makes it read instantly as "the Google one" — every other button on the screen is ours.
The design system is generated, not hand-maintained
One tokens file is the single source of truth for colour, type and the sticker geometry. A build step turns it into the app's theme module, the SVG logo and icon set, and every PNG the two app stores want — so the identity and the running app can't drift apart. Nothing in the theme or the brand SVGs is edited by hand.
Milky ground, one loud tone
Milk
#FAF4EA
Primary ground
Oat
#EFE1CB
Sunken surfaces, sticker sheet
Cocoa
#4A3B32
Text and sticker keyline · 9.78:1 on milk
Persimmon
#FF6B4A
The one loud tone · fills and primary actions
Matcha
#7FB069
Fibre and vegetables
Ube
#9B7EDE
Protein
Butter
#FFC94D
Fat, and the sparkle
Sakura
#FF9EB5
Decorative only · never text
The beige does the holding and persimmon does the talking. Persimmon is 2.57:1 on milk — right for a filled button, illegible as body copy — so every bright hue ships an -ink variant that clears WCAG AA on both light grounds.
Every dish comes back as a sticker
A saved dish uses the same anatomy as the logo — cocoa keyline, cream die-cut edge, offset shadow — so the sticker and the app icon read as the same family of object.
- Carbs 79g
- Protein 17g
- Fat 28g
- Fibre 4g
- Carbs 76g
- Protein 23g
- Fat 38g
- Fibre 3g
- Carbs 21g
- Protein 3g
- Fat 4g
Values shown here are illustrative placeholders, not nutrition data.
Four glyphs, drawn not licensed
An icon font would be a few hundred kilobytes of glyphs to use four of them, and no stock set has a "sticker playground" that reads as one. Drawn in react-native-svg on a 24 grid, they follow the mark's own rules.
- Calendar — The monthly grid. Three filled days, because the point of that screen is that some days have stickers and some don't.
- Playground — Loose stickers mid-tumble — the gyro toy, not a generic sparkle.
- Recap — Play triangle in a rounded frame: a video of the day, not another camera.
- Account — The only one a stock icon set would have got right.
Stroke-only, never filled. These sit inside a 44dp circle, and a filled glyph loses its counters at 22dp and turns into a blob. Stroke scales with the glyph but is floored at 1.5dp — the same floor as the sticker keyline, for the same reason: the outline carries the shape, not the fill.
Icon-only means the accessibility label is the only name the control has. It's a required prop, not an optional one — without it a screen reader announces "button" four times and the navigation is unusable.
Round enough to be a sticker
Display
ui-rounded 800
Char Kway Teow
Body
system-ui
Point the camera at your plate. Carb Kun Carb cuts the dish out, names it, and files the macros — no searching, no weighing.
Data
ui-monospace, tabular
640 kcal · 79 C · 17 P · 28 F
ui-rounded is a real CSS generic and maps to SF Pro Rounded on Apple platforms at zero download cost. Android needs a shipped face: M PLUS Rounded 1c is the pick — a rounded sans with full kana and kanji coverage, which matters when half the dish names in this app are CJK. Baloo 2 extends the same rounded skeleton into Devanagari, Bengali and Tamil.
Rules that came out of the design work
Colour is reinforcement, never the signal
Carbs, protein and fibre sit within 0.09 relative luminance of each other and aren't separable under red–green colour blindness, so every macro chip carries its label and its number.
Below 0.72 confidence, ask instead of assert
Never present a guess about someone's food as fact. The threshold is a product decision before it's a model one.
Prose lives in exactly one place
Every user-facing string sits in the locale files; nothing in the screens contains copy. Traditional Chinese locales fall back to English on purpose — showing Simplified glyphs to a Traditional reader reads as sloppy, not helpful.
Where it stands
Carb Kun Carb is a work in progress and isn't in the stores. It runs as a development build today, with capture, cutout, the dissolve, identification, the day view, the calendar, the recap video and sync all working end to end. What's left is mostly the unglamorous half.
Known gaps, listed on purpose
- No feed yet. The schema and the security policies already support public and friends-only visibility; the browsing surface that would make it social doesn't exist.
- The nutrition figures are placeholders. Real numbers need a licensed regional database — Singapore's HPB tables, Malaysia's MyFCD, or a commercial platform — not a hard-coded list.
- No moderation tooling, which has to exist before anyone but me puts photos in it.
- Android still has no rounded display face that also covers Simplified Chinese properly, and the identity leans on one.