01 · Projects / Get Curly
A verdict before she puts the bottle down
This one is six years old. Get Curly started as a Spiced Academy project in 2020 — Webpack, Express, Redux, a React 16 alpha — and it has been deployed ever since. In 2026 I rebuilt it: same idea, a new stack, and every product decision taken again by someone who has since shipped and supported software for a living.
Get Curly answers one question: does this bottle fit the Curly Girl method? Point the camera at the barcode and the answer comes back in two seconds; photograph the ingredients list and it is read with OCR, matched against a dictionary of sulfates, silicones, drying alcohols, mineral oils and waxes, and returned as Clear or Skip. No account, no sign-up, and the photo is never stored.
- Role
- Sole designer & developer
- Timeline
- 2020 · rebuilt 2026
- Status
- Live
- Platforms
- Web, mobile-first (camera on the phone)
02 · The problem
The user is standing in a shop, holding a bottle
The Curly Girl method rules out five families of ingredient, and they are spelled like behentrimonium methosulfate — which, despite the name, is fine. Applying that in a drugstore aisle means reading forty ingredients in six-point type and remembering which suffixes matter. People do it with a screenshot of a list saved in their photo roll.
So the constraint wasn’t accuracy in the abstract, it was an answer in one action. Nothing stands between the camera and the verdict: no login, no form, no “which product is this?”. Everything else the app can do — correcting the list, naming the bottle, adding it to the library — is an optional second tap, taken after the question has already been answered.
- Two seconds, or ninety. A barcode says which product; only the label says what is in it today.
- Every answer carries its age. Manufacturers reformulate and keep the barcode, so a stored list is shown with the date someone last checked it.
- Say what we don’t know. A crowdsourced list nobody has verified gets no verdict at all — a wrong Clear is the one failure that really costs.
- Anonymous. No account to scan, correct, or contribute. IPs are hashed before they reach the database.
03 · How it’s built
Two views of the same product
The same stack as the ff-library, pointed at a problem where the hard part is the data, not the interface. Switch between what she sees on the phone and what runs underneath it.
One rule, enforced everywhere
Scan, then answer. The rules that shape the product are written down as fourteen numbered lines in the repo’s CLAUDE.md, and the ones about verdicts, uncertainty and money are the reason the app looks the way it does. A rule that only lives in someone’s head gets relitigated every time a screen is built.
Design system
A plum-charcoal ink on a pale mauve ground, one fuchsia accent, Gabarito for display and Hanken Grotesk for text, both self-hosted. Verdict colour is deliberately separate from the accent — clear is a deep teal, skip a slate-indigo rather than a red, because “not for your curls” is not an error — and it is never the only signal: always a colour, an icon and a word. Every colour is defined in the base :root block first, so nothing can exist in one theme and vanish in the other.
Two cameras, on purpose
Barcodes are read from a live stream — she points the phone and the answer arrives, with no shutter button in between. The label path keeps a plain file input instead, because the phone’s own camera app focuses on six-point print far better than a video preview does. The reticle is short and wide for a barcode, tall for a paragraph, and the stream is torn down whenever the tab goes to the background.
What the wait says
OCR takes a few seconds, so the wait is narrated with the real steps and a live count of ingredients found — and it shows the cleaned list forming, never the raw OCR string with its typos in it. Three failures are told apart and worded differently: a partial read, no text at all, and Vision being down. Only the second one gets a framing hint, because that is the only place a hint is actionable.
Never surfacing doubt per row
No confidence badges, no warning icons, no wavy underlines. How sure the matcher is about one word is a database concern, and dressing the list in yellow triangles would make a correct verdict look unreliable. One sentence covers all of it: computers misread small print — change anything that’s wrong. The single exception is a word we couldn’t read whose two candidates disagree about the verdict; then, and only then, she is asked to pick between them.
The list is editable, and position means something
On the optional list screen she sees resolved names — if OCR read “Cetearyl Alcohoi” and the matcher landed on cetearyl alcohol, that is what shows. Rows can be corrected, deleted, or inserted, and an insert has to say where: label order is roughly concentration, so a sulfate the photo missed is a different bottle at #2 than at #22. Appending everything would have broken the one thing that screen is claiming to show.
When the camera can’t do it
A read that catches fewer than ten ingredients gets no verdict and offers to let her type the list instead. That path publishes from five, not ten — she is reading the bottle rather than a camera guessing at it — and from there it is the photo path exactly: same matcher, same verdict function, same page at the end.
The matcher lives in the database
Normalisation and lookup are Postgres functions — normalize_ingredient and match_ingredient — rather than TypeScript. The app, the Open Beauty Facts backfill script and any query typed by hand then apply exactly the same rules, which is the only way the numbers agree. What is left in the app is the one thing that genuinely isn’t a matching problem: deciding where one ingredient ends and the next begins.
scans— one row per read, append-only, with the verdict it producedscan_ingredients— the raw token, the resolved name, the position, how it matchedingredients— the judgement dictionary: the flagged groups and their aliasesproducts— barcode, brand, name, type, and the stored list with the date it was last confirmed
Four ways to match a misread word
Exact match first, then a recorded alias, then the two cheap repairs that cover most of what OCR does to a label: the same letters with the spacing moved (DIMETH ICONE), and the glyphs a camera confuses — l/i, 0/o, 1/l, 5/s (DIMETHIC0NE). Trigram similarity is the last resort, at a threshold of 0.62. Each tier scores below the one above it, so a real alias always wins over a repaired guess.
The verdict is computed in one place
compute_verdict in Postgres decides Clear or Skip, and nothing in the app is allowed a second opinion. There is one display-only mirror of the rule in TypeScript — it labels the two candidates on the pick card with what each would mean — and it is commented as a mirror so the next person knows it has to move when the SQL does.
Scans are append-only
An edit doesn’t update a scan, it forks one. The pair that leaves behind — what the machine read, what a person corrected — is the training data for the alias dictionary, and it is how a misread that happens on a thousand bottles gets fixed once. She never sees any of that; she sees her corrected list and a recomputed verdict.
Knowing what we don’t know
The barcode lookup works on day one because tens of thousands of rows were imported from Open Beauty Facts — crowdsourced, ODbL, and only as current as whenever somebody last looked. Every one is written as a seed row, and product_confidence() reports it as seed_only, which means no verdict pill at all: we have a list, nobody has checked it, read the label. Confidence is asked for before anything renders, and a failed lookup counts as no.
Recognising a bottle by its ingredients
After a label scan the app can ask 36 of 37 ingredients identical — is this Smooth Shine Shampoo? Each product’s list is stored pre-normalised and kept in step by a trigger, so the comparison is one pass rather than 60,000 normalisations per scan. Scoring is symmetric — matched over the longer of the two lists — so a product doesn’t win by having a longer list, and a reformulated bottle drops out rather than being named wrongly.
Costs, limits, and no accounts
Barcodes are decoded on the device, so the cheap path is also the free one; Vision is only called when there is a label to read, and the photo is downscaled to 2048px on the phone first, which is most of the wait on a mobile connection. Writes hash the IP with a salt before anything is stored, rate-limit against that hash, and the hard ceiling is a daily quota cap set on the Vision API itself — the in-process limiter is a speed bump, and is commented as one.
04 · Decision log
Five bugs that changed how the app is written
Every one of these was a wrong answer delivered confidently, which is the only kind of bug this product really has. They are in the repository as migration comments, because the reasoning is worth more than the diff.
A silicone the app simply could not see
- Cause
- OCR renders a capital O as a zero and an l as an i depending on the typeface and the light. The dictionary had no entry for DIMETHIC0NE, so the ingredient went unmatched — and an unmatched silicone is a false Clear on a bottle that should have been a Skip.
- Fix
- A squash function that folds the confusable glyphs together before comparing, rather than one alias row per word per substitution.
- Result
- Measured against the real data before shipping: no collisions among the dictionary entries, no false positives across 3,036 label tokens from actual scans, and three flagged ingredients that had been invisible started matching.
Line breaks were inventing ingredients
- Cause
- The splitter treated a newline as a separator. On a narrow label OCR breaks names mid-word, so one ingredient arrived as two — neither of which exists, and neither of which matches anything.
- Fix
- In an INCI list the comma is the separator and a newline is only where the column ran out. Lines rejoin — with nothing after a hyphen, with a space otherwise — and the ingredients paragraph is located by its header first, including the repeats a multilingual bottle prints.
- Result
- The counted list stops filling with packaging text and half-words, and positions stop being inflated — which matters, because position is roughly concentration.
A moved space hid an ingredient
- Cause
- Normalisation keeps spaces, so DIMETH ICONE never equals dimethicone. Fuzzy matching only just caught it at 0.67 against a 0.62 threshold; a slightly longer name would have dropped under and disappeared.
- Fix
- Compare the letters with the spacing removed as well, scored just below a real alias and above anything fuzzy.
- Result
- The one thing the 2020 JavaScript matcher did better than the rewrite — it stripped every non-alphanumeric before comparing — is back, this time as a scored tier rather than a blunt instrument.
Naming a bottle stored a one-ingredient list
- Cause
- The function that promotes a scan into a product built the stored list with
string_aggover resolved names.string_aggskips nulls, and the resolved name is null for every ingredient the dictionary doesn’t know — so a 34-ingredient bottle would have been stored with one, silently, and a scan where nothing matched failed outright. - Fix
- Fall back to what the label said — the same coalesce the list screen and the fingerprint comparison already used.
- Result
- An ingredient we don’t recognise is still an ingredient. Caught before anything had been promoted, so there was no data to repair.
The old app asked questions before it answered
- Cause
- The 2020 version gated the verdict behind confirmation steps and decorated individual rows with how unsure it was — which reads as hedging, and is also much harder to use one-handed in a shop.
- Fix
- The rewrite states the fourteen rules first and builds to them: verdict in one action, no per-row confidence, one interruption only where the answer itself would change.
- Result
- The flows got shorter and the copy got more honest at the same time — the uncertainty moved out of the row badges and into one sentence that tells her what to do about it.
05 · Where it stands
Live, rewritten, still growing
Get Curly is live at getcurly.ink. Both flows are shipped end to end — barcode to product page, photo to verdict, correcting the list, naming an unknown bottle, and adding a barcode we’ve never seen. The counts on the home page are queried live rather than hardcoded, including the gap between products we hold and products a real person has confirmed, because that gap is the honest part.
Next