A plan, not a build. Written from a full read of index.html, the tools and the data, so the
line numbers and names below are real; everything else is the intended design. Build it on a
beta branch and merge phase by phase — see "Where it ships".
OpenDiet is a static single-file app (index.html + support.js) on GitHub Pages. It has a food
dictionary with pooled "typical" entries (· avg) and a plate (MY FOOD SUMMARY) that sums chosen
foods at chosen grams. The new direction: paste a recipe, have each ingredient line matched to
the nearest library food, and see the recipe's calories and macros — in the same UI as the plate,
titled MY RECIPE, with a SUBMIT / SUBMIT & REVIEW choice and a REVISE affordance per row.
Decided:
- Unit→grams uses a generic unit table now, structured so FDC's full per-food portion table can drop in later with no UI change (the FDC CSVs were not to hand when this was written).
- The recipe is a separate list from the plate; totals shown whole and per serving.
- Mobile-first, reusing the existing designs (donut, steppers, summary panel).
Open: where the AI runs. The matcher needs two things a string match cannot know — that
"caster sugar" is sugar and "double cream" is heavy cream, and that a recipe's "onion" is
Onions, raw rather than the frozen one. Two ways to get that knowledge in:
| Build-time (no worker) | Runtime (Cloudflare worker) | |
|---|---|---|
| How | An alias table generated once by an LLM, tested against the real ranker, committed as JSON | Each paste goes to a worker that calls Claude Haiku with the lines + candidate names and returns structured matches |
| Coverage | The few hundred to couple thousand ingredients people actually cook with; misses the long tail unless added | Anything |
| Rough hit rate | ~85–90% of lines on typical recipes | ~95%+ |
| Cost / infra | None; the site stays a static file | Cloudflare account, wrangler login, a secret, a rate limit; fractions of a cent per recipe; the recipe text leaves the browser |
The plan below is written local-first: the worker is not required, and the parsed-line shape
(Phase 1) is the contract a worker would return, so adding one later is purely additive — a
Phase 9 that swaps matchLine for a fetch with the local matcher as fallback. The worker's setup
is interactive (wrangler login, secrets) and belongs in a terminal session. Either way the
REVISE UI (Phase 5) is what makes the misses tolerable, so it is not optional.
Measured facts that shape the plan:
- A naive word match over 35 typical ingredients (with stemming) misses 9 outright on vocabulary ("caster sugar", "double cream", "bell pepper", "tinned tomatoes"…) and picks the wrong sibling where world knowledge is needed ("onion"→frozen, "chicken breast"→deli slices, "milk"→shakes). Recipes mean raw ingredients; the matcher must prefer raw/plain entries.
- 1,476 foods carry a CUP measure and ~480 a TBSP/TSP one, so volume→grams can use the food's own
density (
pg/pn) first; a generic density table is only the fallback. - The prune removed baking staples:
Sugars, granulated/brown/powdered,Leavening agents, baking powder,Vanilla extract,Syrups, maple,Cornstarch, two vinegars — all foods whose fibre-zero or sugar-zero is real.Salt, tablewas never in the library. Water and black pepper are. Sugar is in every baking recipe, so this must be fixed as part of the feature.
- Work on
beta, reset ontomainfirst (git checkout -B beta origin/main), one commit per step. - Each shippable phase is merged to
mainhidden behindFULL_SITE(index.html:1206): addrecipetoAPPS(2940) andlive()(2968) and opendiet.org never shows it, whileopendiet.org/?full=1, localhost and*.github.iodo. This is the repo's own convention for unfinished apps (game, calc). Pages only deploysmain, so this is the only way the user can open it on a phone without a second site.
| # | Phase | Deliverable | Effort |
|---|---|---|---|
| 0 | Data fix | Put the pruned staples back with a principled exemption | S |
| 1 | Parser | parseRecipe(text) in recipe.js + node tests |
S |
| 2 | Matcher + alias data | rankFoods extraction; data/aliases.json; tools/recipe_aliases.py; matchLine; labelled hit-rate test |
L (highest risk — do first after parser) |
| 3 | Unit conversion | data/units.json; gramsFor(line, fo, units) + tests |
M |
| 4 | Mobile UI | launcher card, tab, compose screen, MY RECIPE panel, servings | L |
| 5 | Review / revise | scoped candidate search, SUBMIT & REVIEW stepping, ghost rows | M |
| 6 | Desktop | table reuse, textarea placement, revise column | M |
| 7 | Persistence + copy | od-recipe localStorage, library-switch rematch, copy text |
S |
| 8 | Docs + harness | README / DESIGN / data README; screenshot harness | S |
| 9 | Worker (optional, terminal) | Cloudflare worker calling Claude Haiku; matchLine falls back to local when it is absent or fails |
M |
Phases 1–3 are pure functions with no UI and are reviewed on test output alone.
The category rule ("SWEETS almost always carry fibre, so a fibre-zero is a gap") is right in general and wrong for a food that has no room for fibre. Add two exemptions, each argued in the docstring like the rest of the file:
- Fibre zero is measured when sugar already accounts for the carbohydrate:
sg >= c - 1.0. Covers sugars, syrups, vanilla extract, honey. - A keep-list for the handful the numbers cannot rule on (
Cornstarch,Leavening agents, baking powder, vinegars): names somebody has looked at, written in the tool with a line each. Rebuild from the pre-prune file (git show a337346^:data/legacy.json→ prune →pool_foods.py) — no FDC CSVs needed. Runtools/audit_data.py. Same forsurvey.json(check which staples it lost). Updatedata/README.mdMissing-figures section. Salt is the one staple that is genuinely absent: the parser treats it (and "salt and pepper to taste") as a zero-energy line, shown as a row with 0 kcal, never a failed match. Nopantry.json— one library, one truth.
One flat object per input line — this is the contract a future Worker step would also return
(it fills through conf; everything after is app-owned):
{ i: 3, raw: "2 tbsp olive oil, extra virgin",
kind: "ing", // "ing" | "head" | "note" | "blank" | "zero" (salt/pepper/water)
qty: 2, qtyText: "2", qtyLo, qtyHi, // ranges "1-2" → qty 1.5
unit: "tbsp", unitText: "tbsp", // canonical key from units.json, or null
ing: "olive oil", prep: ["extra virgin"],
state: null, // "raw"|"cooked"|"canned"|"frozen"|"dried"|null — what the LINE says
flags: { opt: false, taste: false },
food: "Oil, olive, salad or cooking", cands: [...top 8 names...], conf: 0.82,
// app-owned:
grams: 27, gramsHow: "pg"|"mass"|"density"|"count"|"default"|"none", label: "2 tbsp",
review: false, user: false, off: false }
Rows are keyed by i (two lines may match the same food); grams live on the row, not in s.qty.
Per line: normalise (NFKC, unicode/ascii fractions, "1 1/2", bullets, numbering) → classify
(head for "For the sauce:", ALL CAPS, "Serves 4" — which sets servings; note for method steps:
imperative verb without a leading quantity, or >12 words) → quantity (numbers, ranges, number
words, "a handful/pinch/dash/knob/splash" set qty 1 + that unit; "juice of 1 lemon") → unit
(table-driven spellings; "2x400g tins" → 800 g; parenthetical mass overrides; small/medium/large
is a unit only before a noun) → ingredient/prep split at first , ( - ; plus a PREP word
set (chopped, diced, minced…) and a STATE word set → flags (optional, to taste).
Extraction first, own commit. Move stemWord (index.html:1115), the mkFood closure
(2272–2331) and the inline scorer (2819–2891, constants WHOLE_PTS…POOL_PTS) into recipe.js as
OD.stemWord, OD.mkFoods(json), OD.rankFoods(list, q); renderVals calls
src = OD.rankFoods(src, q) at 2848, sort at 2914 unchanged. recipe.js is a classic
<script src="./recipe.js"> after support.js (line 37), exposing window.OD with a
module.exports guard so node can run the identical code. Guard the extraction with the
existing smoke harness: same top-6 for "egg, whole", "kale", "beef" before and after.
data/aliases.json (~300 hand-seeded entries, longest phrase wins, stemmed both sides):
{ "m": ["caster sugar","granulated sugar","white sugar","sugar"], "pin": "Sugars, granulated" }
{ "m": ["bell pepper","capsicum"], "q": "peppers sweet raw", "keep": ["red","green","yellow"] }
{ "m": ["tinned tomatoes","canned tomatoes","chopped tomatoes"], "q": "tomatoes canned", "state": "canned" }
{ "m": ["onion"], "q": "onions raw", "keep": ["red","spring"] }
m phrases; q the query for rankFoods; pin an exact name placed first if present in the
current library; keep line words appended to q (colour, cut), falling back to bare q;
state the state the alias implies; optional cat.
Scoring: alias lookup on ing (else ing minus stopwords: fresh, of, good, quality,
organic…) → rankFoods → recipe re-weighting as a second pass in matchLine (never inside
rankFoods): +8 raw/plain, -10 per process word in the name (frozen, dehydrated, dried,
canned, cooked, boiled, baked, fried, roasted, juice, babyfood, restaurant, fast foods, shake,
mix, powder, with salt…) unless the line's or alias's state names it (+6), +40 pin,
-0.2 × name length. Retry with the head noun only if empty (mark weak).
conf = clamp((h1−h2)/h1) × (aliasHit ? 1 : 0.7);
review = !food || (!aliasHit && conf < .25) || weak || gramsHow === "none" || brand.
Offline tooling tools/recipe_aliases.py: --check runs every alias phrase through the
real ranker (shell out to node -e requiring recipe.js, so the two never drift) and warns
where #1 ≠ pin; --suggest phrases.txt prints the 15 nearest library names per phrase so the
author can ask an LLM offline and paste reviewed rows back. No API call in the repo.
Labelled test set in the repo: tools/recipes/*.txt (3 real recipes: a curry, a bake, a
salad) + tools/recipe_expect.json ({"raw","food","g":[lo,hi]} per line). Pass bar: ≥85%
top-1, ≥95% top-8. Expect a second alias pass after the first run.
{ "mass": {g,kg,oz,lb}, "volume": {ml,l,tsp,tbsp,cup,floz,pint,pinch,dash,splash},
"density": { "word": [["icing sugar",.56],["sugar",.85],["honey",1.42],["flour",.53],["oil",.92],…],
"cat": {"FATS":.92,"DAIRY":1.03,"GRAINS":.6,…} },
"count": { "default": {clove:3, slice:25, can:400, bunch:100, handful:30, sprig:2, medium:110, large:150,…},
"word": { "onion":{small:70,medium:110,large:150,each:110}, "garlic":{clove:3,head:40}, "egg":{…}, … } },
"labels": { "MEDIUM":["medium","each"], "SLICE":["slice","rasher"], "CUP":["cup"], "TBSP":["tbsp"], "TABLESPOON":["tbsp"], "CLOVE":["clove"], "CAN":["can","tin"], … } }
Resolution order (first hit wins, how records it): (1) fo.pt — the future per-food portion
table, element 21, absent today, wired now so tools/add_portions.py --all is a pure data
upgrade; (2) mass unit; (3) volume: if labels[fo.pn] contains the unit → qty × fo.pg × volume[unit]/volume[label] (FDC's own density), else qty × ml × density (word, then
category, then 1.0); (4) count: labels[fo.pn] → fo.pg, else count.word[head], else
count.default; (5) qty with no unit ("2 onions") → each; (6) neither → taste ? 0 : 100,
how:"none", review:true. Cap RCP_MAX = 5000 g (not the plate's 990).
Launcher / tabs: APPS gains { k:"recipe", label:"RECIPE", icon: ICON_RECIPE, note, card };
appCards (4808) and appTabs (4792) pick it up. Four tabs on a 360 px phone: tighten
tabBar gap and hide tabAbout on narrow (ABOUT is on the launcher). Verify at 360×780.
setApp("recipe") lazily fetches aliases.json + units.json into this._rcpData
(pattern: loadDeck() 1561).
Skeleton: new sc-if isRecipe block after the isResults block closes (~1075), mirroring the
search app's mobile column (849): top area, bottom panel, popScrim reused.
Compose (s.rMode === "compose", default when no rows): bar mRcpBar (clone of mSearchRow
at 2.9 rem) with "PASTE A RECIPE" left and a − 4 SERVES + stepper right (built from
stepL/stepR + amtNumBase); a <textarea data-search="c" data-compose> filling the top area
(flex:1; min-height:0; resize:none; overflow:auto) — it scrolls itself, never the document, so
_unscroll (1377) is not fought, and data-search lets _focusIn/_focusOut (1379–1402) set
s.kb so the shell sizes to the visual viewport as it does for search; an action row
mRcpActs (3.3 rem, like mMeasureRow): muted CLEAR left, SUBMIT & REVIEW bordered and
SUBMIT filled (mAddBtn style) right. Submit: blur() the textarea (as toggleSum 2445 does),
runRecipe(review) → parse → match → grams → setState({recipe, rMode, rStep}), panel rises
with data-rise.
Result (rMode === "result"): top area shows the recipe collapsed to a card mRcpPeek
(first head line or "RECIPE" · "12 INGREDIENTS · 3 TO CHECK" · chevron; tap → compose with
text intact). Panel mRcpPanel = mSumPanel (6726) with data-plate data-rise, maxHeight
85%; header = mSumHead grid, title MY RECIPE, tucked peek (sumTuckQty/Val), copyBtn,
plus a REVIEW ring badge with the review count (in --fat when > 0) that starts stepping.
Rows via a new mkRecipeRow(row) beside sumRows (3993), reusing mSumRowBase, SUM_COLS,
sExpand, swipe-to-remove (4120–4170, keyed by row.i), sToggle → s.rOpen,
ctl = amountCtl(fo, row.grams, g => setRowGrams(i, g), s.rGMode, …) (3018, unchanged
signature), vizFor(fo, row.grams, …) for the donut:
- Name cell is two lines: line 1 the ingredient as written ("olive oil", body colour);
line 2 the matched USDA name (
.78rem,--muted, ellipsis) with anAVG nbadge when it is a pool. The user reads their own recipe and spots a wrong match by comparing the lines; the USDA name alone truncates to nothing in the track; and line 2 is precisely what REVISE changes, so it must be visible on the closed row. RowminHeight3.1 rem. - Qty cell: line 1
grams g(or ml), line 2 the original "2 tbsp" (.7rem, chrome face) so the conversion is auditable. Value cell: the chosenmColvalue, as on the plate. - The reserved
borderLeftonmSumRowBasebecomes the status mark:--fatunmatched, cyan.6needs review, transparent confirmed.optrows at.7opacity;taste/zerorows show "to taste" / "0 g". - Ghost rows (
food:null): line 2 "NO MATCH · TAP TO FIND" in--fat, qty/value "—", excluded from totals; tap opens revise for that row; swipe/DEL setsoff:true(folds withdata-gone). - Expanded row: donut +
vTable, thenmMeasureRow(reset / grams stepper /=/ serving stepper whenfo.pg), thenREVISE(bordered, swap icon) andDEL(mBinBtn).
Totals: a rServRow (2.2 rem) above the totals row — left a two-face WHOLE | PER SERVING
switch in the dMeas100S/dMeasOzS style (4593), right the − 4 SERVES + stepper; then
mSumTotRow reused with mTotLabel "RECIPE TOTAL" / "PER SERVING", figures divided by servings
when per-serving (rows rounded first then summed, like tKDisp). Rows always show whole-recipe
amounts — editing a row edits the recipe; per serving is a division of the result shown once.
Totals donut via the foTot pattern (4292) with s.rTotOpen.
Mutual exclusion: rOpen/rTotOpen join the open-surface set — setApp (2973),
toggleRow (3687), sToggle (4055), exitScope (6708) clear them; the recipe's toggles clear
mOpen, sOpen, totOpen.
openRevise(i) (one row) or rMode:"review" stepping through review rows only
(SUBMIT & REVIEW = runRecipe(true) then step from the first). The top area swaps to the
scoped search — the pool drill-in pattern (6706–6720, template 854): mSearchRow with the
scope chip showing the ingredient in caps (and 2 / 5 when stepping), back chevron = keep and
advance (SKIP), input empty with placeholder "SEARCH FOR A BETTER MATCH". The list is the row's
cands through mkRow (2645) under a "CANDIDATES" caption; once the user types, rankFoods
over the whole library replaces them. A tap assigns (assignMatch(i, name) → food,
user:true, review:false, grams recomputed via gramsFor) instead of opening. While a scope is
open the panel is folded to its header as it is when mOpen is set, showing the running total.
After assign: stepping → next review row; single revise → back to result. When the last is done,
rMode:"result" and the badge count is 0.
Inside isRecipe, a desktop block mirroring 713–848: dFrameCol + the same column heads. The
dSearchCell (724) becomes, in result mode, "MY RECIPE · SERVES [input] [−][+] · WHOLE | PER
SERVING"; in revise mode the existing scope chip + input (727–729). dBody (746): compose = the
textarea filling the rows area (padding-right:10px keeps the scrollbar reserve) with
SUBMIT & REVIEW / SUBMIT / CLEAR right-aligned beneath; revise = candidate rows (748–776
unchanged, tap assigns). dRcpWrap = dSumWrap (4498) titled MY RECIPE; rows = the desktop
plate cells (785–803) with the name cell "olive oil — Oil, olive, salad or cooking" (USDA part
in --muted), the typed qty <input> (onQty pattern 4209, cap 5000) followed by the "2 tbsp"
hint, and two action buttons: swap (REVISE, dAddBtn style) and minus (dDropBtn). Totals row
(828–844) label toggles WHOLE/PER SERVING; copy button reused. Mark dRcpWrap data-plate.
localStorage key od-recipe: { v:1, lib, text, servings, per, rows } (never touches
nutri-search-sum); saveRecipe() on every change (pattern 2459), hydrated beside 1290; on
load with rows → rMode:"result". Library switch: rows whose food is absent in the new
library are re-matched (user rows keep food if it exists, else user:false, review:true);
grams recomputed only where gramsHow === "pg". Copy: recipeText beside plateText (4344),
same dash-led voice, whole then per serving, unmatched lines listed as "(no match)".
- New:
recipe.js(stemWord, mkFoods, rankFoods, parseRecipe, matchLine, gramsFor, recipeTotals;window.OD+module.exports);data/aliases.json;data/units.json;tools/recipe_aliases.py;tools/recipes/*.txt;tools/recipe_expect.json. tools/prune_unmeasured.py: the two exemptions; rebuilddata/legacy.jsonanddata/survey.jsonfrom their pre-prune files; re-runpool_foods.py,audit_data.py.- index.html: constants
ICON_RECIPE,RCP_MAX,RCP_CANDSnear 1246; deletestemWordand themkFoodclosure in favour ofOD.*; replace the scorer withOD.rankFoods; state keysrecipe, rMode, rOpen, rGMode, rTotOpen, rStep, rScope, rText; methodsloadRecipeData, runRecipe, assignMatch, setRowGrams, dropRow, openRevise, nextReview, setServings, saveRecipe, copyRecipe(usescopyPlate(text)2479);APPS/live/setApp;renderValsbindings (isRecipe, rRows, rTot, rTotViz, rServ*, rTextarea, mRcpBar, mRcpActs, mRcpPeek, rReviewCount); theisRecipetemplate blocks;data-plateon both panels. - Docs: README "What it does" + layout table (
recipe.js, the three data files); DESIGN.md section "The recipe app" (why local matching, why two-line rows, why per serving is a division, why a second script);data/README.mdsections for aliases/units and the prune exemptions.
- Node:
node tools/recipe_test.jsrequiresrecipe.js, builds foods viaOD.mkFoodsfromdata/legacy.json, loads aliases/units, runs the three recipes, assertskindcounts, per-line match and grams range fromrecipe_expect.json, prints hit rate; fails under 85%/95%. Fixed parser cases for every quantity/unit form. - Browser (existing harness: playwright-core at
/opt/pw-browsers/chromium-1194/chrome-linux/chrome, vendored React in the scratchpadvendor/,setsid python3 -m http.server 8765 --bind 0.0.0.0, pagehttp://127.0.0.2:8765/index.html?full=1): 390×844 @2× mobile — tap RECIPE, fill[data-compose], SUBMIT, assert[data-plate] [data-row]count and totals text; screenshot compose / result / expanded row / review scope; walk SUBMIT & REVIEW to the end; light and dark. Repeat at 1440×900.pageerrormust be empty. - Regression: re-run the existing search smoke scripts after the
rankFoodsextraction (same top results for "egg, whole", "kale", "beef");tools/audit_data.pypasses after the rebuild; the plate is untouched (add/remove/qty still work).
- Match quality is the whole feature; alias coverage is hand-seeded and will need a second pass after the first labelled run.
- The
rankFoods/mkFoodextraction touches the search path — its own commit, smoke-guarded. - iOS textarea inside the
position:fixedshell is a new case for_vv; test on a device before merging Phase 4. - Four tabs on a 360 px phone — verify; hide ABOUT on narrow rather than shorten labels.
- Steppers in tens are coarse for "1 kg flour"; desktop has a typed input; revisit later.
- A pool as the match is preferred by score, which suits recipes; confirm the AVG badge reads on a second line.