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)
Coming soon01 · HeroThe verdict screen on a phone — the coil, Clear or Skip, one sentence.2:1 · assets/01.png

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.

Next.js 16 · App RouterReact 19TypeScriptTailwind CSS v4Lucidezxing-wasmNo component library

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.

Coming soon02 · Design systemThe colour tokens with their names, the two typefaces, and the clear / skip / caution pills in both themes.16:7 · assets/02.jpg

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.

Coming soon03 · ScanThe viewfinder with the tall reticle, lined up on an ingredients paragraph.9:16 · assets/03.png
Coming soon04 · The list“What we counted” mid-edit — numbered in label order, one row open.9:16 · assets/04.png

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.

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_agg over resolved names. string_agg skips 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.

Coming soon07 · DemoScreen recording of one scan end to end: camera, the narrated wait, the verdict.16:9 · public/getcurly/demo.mp4 + .webm + poster

Next

LibraryBrowse and search the confirmed products
ReadingArticles on the method and on ingredients
RevenueAlternatives ranked by fit and price, never by commission
MatchingSuggestions rebuilt from every list we hold
ClaritySplit the list into what fails the method and everything else
Edge casesWhat to say when the bottle isn’t a hair product at all