Lemon SqueezyHow it works

The list by aisle

The grocery list is walked by aisle, the way you walk a shop, and the recipe page groups its ingredients the same way. A line finds its aisle from its name and the food it names, and a cook’s correction holds for the whole kitchen.

Walked like a shop

The ingredients are a shopping list, walked by aisle, never in the graph’s order. The owner, reading an import: "categorise all the ingredients like a shopping list, instead of order of use since that doesn’t matter here"

From SCREENS.md, Aisles and the original source

so a recipe and the list it lands on cannot walk the shop differently, and a cook’s aisle corrections on the list reach here through the plan.

From SCREENS.md, Aisles and the original source
The recipe page for Bucatini all’Amatriciana on a phone: the ring of its two parts above, and its ingredients grouped in blocks, each block an aisle with its name at the head.
A recipe’s ingredients, in aisle blocks. Photographed at commit 3a0740f09, 26 October 2026.

Aisles and the original source

SCREENS.md, the whole section
  • The ingredients are a shopping list, walked by aisle, never in the graph's order. The owner, reading an import: "categorise all the ingredients like a shopping list, instead of order of use since that doesn't matter here" — this reading answers what do I need to have in, and a shopping list is walked, not cooked. groupByAisle (libs/core) is the grocery list's own grouping, so a recipe and the list it lands on cannot walk the shop differently, and a cook's aisle corrections on the list reach here through the plan. Each aisle is a block with its heading at the head (.brief__aisle, the serif italic in the accent ink with the count after, above); one aisle draws no heading. The swap ladder still opens under the row that asked, inside that row's aisle, with the aisles after it carrying on below.
    • How a thing lands in an aisle. aisleOf (libs/core/src/shopping/aisles.ts) reads the name against a keyword table, one list of phrases per aisle, and the longest phrase that matches wins — so "chicken stock" is pantry before "chicken" can make it meat, and "tomato paste" is pantry before "tomato" is produce. The name is read before its purpose clause (in, for, to, at, from), so "salt for the potatoes" is salt and a spice, with the whole name read only where its head says nothing ("bone in chicken"). A herb the name says is fresh goes to produce even though its dried self is a spice. A name nothing matches is read again through the line's food, when an import filled one, and only a line whose food says nothing either is Other; the name always outranks the food. What the table gets wrong, a cook corrects once on the list's own line (§1d's edit mark), and the correction reaches here through the plan's aisles; the table itself is fixed, not learned.
  • At the foot of Details, under Previous cooks, up to three rows open the original an import was made from — and only on an import. They stood under the last aisle of the ingredients until the owner's ruling of 23 October 2026: "move the original ingredients, original steps, and original notes sections from the ingredients tab to the bottom of the details tab" (Previous cooks to the bottom of Details too, above them). Owner ruling, 18 August 2026: "it has to be presented in the same way as the original recipe. Under ingredients, I think you can select to expand the original recipe. I think the same CTA should essentially be there for things that we imported, but the original in that case is the image or whatever we had to import." And 20 October: "I want to see the original steps all written out as well. It should be as close to the raw input as possible", then "steps first then ingredients under original so its confusing. Do we know what's what and can we have original ingredients and original steps be seperate dropdowns at the bottom?" So two disclosures, Original ingredients then Original steps (and a third under them, below), in the notes' own dress and the cook screen's Details idiom (§3), each shut by default — reference for a cook who suspects the graph got something wrong, not the point of the screen. What they open depends only on what the import was made from, and on whether the source said which lines are which: a URL whose page states recipe data opens the page's own ingredient lines and steps, verbatim, a step section's name kept as a heading over its steps; a paste is cut at the headings the cook wrote (Ingredients, Directions and the table of their words beside them in libs/core/src/import/source-headings.ts), so a paste that wrote both opens the same two rows with its lines in the order it wrote them, and a recipe pasted with its linked second text does so when each text wrote both. Where the headings cannot cut it, the import's own reading does (owner, 20 October 2026: "Merge, then split by lines"): the lines it quoted for an ingredient (IngredientNode.sourceText, read when the original is drawn and never stored) are Original ingredients, and, where the paste named no steps heading, the lines before its first heading that the import did not quote — the owner's enchiladas call their method "Description:" — are Original steps, in place. Every line that is neither an ingredient nor a step is in a third disclosure, Original notes, under the two (owner, 20 October 2026: "Third row: Original notes"): the notes and the nutrition panel under the heading they stood under, the yield, a time, a byline, whatever stood above the first heading — in the source's order, in the same row, with the same words table (brief-facts-words.ts, French row beside it), and only when there is such a line, never an empty row. The one line left out is the title the import took for the recipe's name, which already heads the page; so every line of a cut source is in exactly one row. This holds for both cuts, the cook's headings and the import's quotes. A source neither can cut — a fetched page with no usable recipe data, whose text is the page's furniture and every section run together, or a paste in which no line is one the import quoted — is not guessed at: it opens as one plain Original text, the lines in the order they came. Every line is verbatim and drawn as a quotation ruled down the left so it can never read as squeezy's prose. A photo import opens the photographs, in order, at the panel's width and nothing more. Recipe.sourceSays is what it reads — the recipe as written, copied off the job when it is saved — and not the graph's sourceSteps, which six bundled recipes carry as a reconstruction of a method nobody quoted; an authored recipe has no original and draws no row, never an empty one. It is not a viewer: no gallery, no zoom, no carousel — the way to the page itself is the credit's link. And it is not the credit, which stays at the foot of the method with nothing to open first (below): the day the two collapse into one component, the credit is behind a disclosure again, which is the exact thing the owner objected to. Nothing in it travels — a share is built name by name and carries neither the lines nor a photograph (IMPORTING.md rule 4, toSharedRecipe's own test). components/brief/original-disclosure.tsx is the one home, views/original-source.ts the fold; the deck draws the rows shut and open at 390 and 360. The photographs' addresses are the one thing nothing supplies yet: a photo job's sourceRefs are storage keys on the server that no route serves back, so a photo import's recipe opens its transcript as lines until one does — a limit, said here rather than dressed as the picture.
  • Ingredients wear the step cards' own ruling: amount in its own column, abbreviated, name beside it — the same facts the cook meets at the stove must not be dressed two ways one screen apart. IngredientRows is the one home for the format, and the format is all this screen takes from it: the panel passes no reveal, so a row here carries no grip and answers no swipe. The gesture belongs to the cook with a pan in front of them (§13), and a row that looks identical but does nothing when pulled would teach the gesture wrongly on the screen a cook meets first. The source's own spelling of an amount lives only where the page is quoted whole — the brief's own rows that open the original (above) — so "1/4 cup" as the page wrote it appears there, verbatim, while the Ingredients reading says "1/4 cup" like every other screen, in digits at the figure's own size (§3). Fidelity is a disclosure, not a format leaking into the most-read type in the product.
  • One amount per ingredient, and the cook chooses which. "Three measurements per ingredient" was the verdict on printing every declared form at once. The list shows one; three pills above it pick the reading — Cups and Grams here, and on the grocery list GS too (grocery store: objects and hands, "1 can", "2 sticks", else the US reading, drawn as the app's own lemon): the owner's rulings of 18 October 2026, which replaced OG, and 19 October, "let's only offer the lemon on grocery lists". Cups is pressed by default, so each is one tap and none toggles off. What Grams leaves as written, and what may honestly be converted at all, is AMOUNTS' rule. The choice is the device's (localStorage) and one per phone: a screen that does not offer GS reads a stored GS as Cups, pressing Cups, and a pick of Cups or Grams here is the pick on the list too. A phone that never chose, or chose OG, reads Cups.
  • "Add to list" sits on the list's own row, at its right end, with the list it acts on. It began in the appbar and earned "that makes no sense": site chrome is for going places, not for acting on what is on the screen.
  • Adding to the list adds shopping, never a dinner: the dish joins home's grocery list on its own, and the calendar still holds only what someone chose. It is stored beside the meal plan (MealPlan.lists) because it is the same kind of fact — a decision, not a consequence — and its ticks live on the entry for the same reason a meal's live on the meal.
  • The scale reads the list in whole recipes, and lands on amounts a kitchen can use. It is a standing fact about the dish, not a device setting and not this visit's own state — see the scale's bullet, above, for where it is written. Metric mass rounds to whole units (to the nearest 5 once large enough that 337.5 g would fake a precision no scale honours); phrases never scale, because they were never numbers, but words that are an amount do ("2 tbsp", "about 4 medium") and a range is two numbers and each end scales ("1 1/2 - 2 lb" doubled reads "3–4 lb"); and a vibe measure stays whole and never falls below one — a fraction of a can is not a thing on a shelf. You buy the can and have some left. The same scale reaches "Add to list", not only this screen's own render — a doubled recipe you never re-opened the control for still ships doubled amounts onto the grocery list (sq-igw).

How a food finds its aisle

reads the name against a keyword table, one list of phrases per aisle, and the longest phrase that matches wins — so "chicken stock" is pantry before "chicken" can make it meat, and "tomato paste" is pantry before "tomato" is produce.

From SCREENS.md, Aisles and the original source

A name nothing matches is read again through the line’s food, when an import filled one, and only a line whose food says nothing either is Other; the name always outranks the food.

From SCREENS.md, Aisles and the original source

What is left over — "guanciale is a cured pork fat, pancetta is close, oil is not" — is true of guanciale in every recipe and owned by none of them. It belongs above the graph, keyed by ingredient and shared across recipes

From DOMAIN.md, Roles, identity and substitution

The conclusions drawn from the id — the aisle, what joins with what, what may substitute — stay exactly as above: folded at read, never copied into the graph.

From DOMAIN.md, Roles, identity and substitution

Roles, identity and substitution

DOMAIN.md, the whole section

A role is the only new fact substitution needs; the graph already carries the rest. "Could I use pancetta?" breaks into three questions, and two of them are already answered. What the share is for is the role, declared on the allocation — on the allocation because a divided ingredient splits along exactly the line where the jobs differ: the pecorino tossed into the pan emulsifies the sauce, the pecorino at the table garnishes it, and those are separate allocations already. What swapping it changes is everything downstream, which is what the graph is: an IngredientNode is the thing as bought, an OperationNode is the transformation, a PreparationNode is the state it leaves behind. trajectoryOf walks it and needs no stored anything.

What is left over — "guanciale is a cured pork fat, pancetta is close, oil is not" — is true of guanciale in every recipe and owned by none of them. It belongs above the graph, keyed by ingredient and shared across recipes — the key is canonicalIngredientKey in libs/core/src/knowledge/, derived from the source words at read so an alias learned next month improves every recipe already stored, and every claim above it carries its rung of the provenance ladder. Copying it into each graph would give every recipe its own private opinion about what guanciale is, and they would diverge the first time one of them was corrected.

An identity citation is the one exception, ruled in 27 September 2026: it makes sense to store connections and citations even though a conclusion should stay derived. An import may record the Wikidata id canonicalIngredientKey matched — IngredientNode.qid, beside a food and form split off name where the source stated them. A citation does not rot the way a resolved key would if it were frozen instead: deriveGraph recomputes qid on every derivation, so a row the vendored table learns tomorrow still replaces it next time the recipe is opened and saved, never by a sweep touching a recipe nobody opened. The conclusions drawn from the id — the aisle, what joins with what, what may substitute — stay exactly as above: folded at read, never copied into the graph.

Corrected once, for the kitchen

What the table gets wrong, a cook corrects once on the list’s own line (§1d’s edit mark), and the correction reaches here through the plan’s aisles; the table itself is fixed, not learned.

From SCREENS.md, Aisles and the original source

it is knowledge about milk rather than about Tuesday’s milk, so a correction one member makes is one the other never has to make.

From KITCHENS.md, The plan, the list and the hob

The plan, the list and the hob

KITCHENS.md, the whole section

The plan and the shopping list belong to the kitchen, and this is the case that is broken today rather than merely unmodelled: two people in a house shop from one list, and if she adds milk and he cannot see it, the feature has failed exactly the household it exists for.

Which forces one thing that was already queued and now stops being optional: a grocery list is rows, not part of the plan document. One document per tenant with two shoppers in it means one of them ticks an item in an aisle and the other's write erases it, and a lost tick costs a second bottle of milk — the failure a shopping list exists to prevent. The plan stays one document: two people rarely edit the same night, a calendar is read whole and is small, and a clobbered dinner is visible and re-typed in seconds where a clobbered tick is silent. And what actually stops either being clobbered is a merge, not the rows: a device pushes what it changed since the plan it last took, and the kitchen folds that in by night, by aisle, by list and by line — what a side changed is its answer, everything else the other's (ARCHITECTURE, "A push carries what changed since the plan it took"). aisles stays on the plan and becomes the kitchen's, which is a small gift — it is knowledge about milk rather than about Tuesday's milk, so a correction one member makes is one the other never has to make.

The hob belongs to the kitchen. Kitchen is the tenant row itself — kitchenSchema in libs/model, carrying its id, its name, when it was made and what it can cook on. It became that from a bare burner-and-oven count in libs/core, which is what the count was always waiting to be: you join a kitchen and it says what it has, so several people cooking in one place agree about the burners they are sharing. Two people who share a kitchen only to share recipes will sometimes be standing at different hobs, and then the count is wrong for one of them. That is tolerable, and worth saying why rather than leaving as a hole: kitchenLoad only ever reports overflow — it never refuses a step and never reschedules — so a wrong count is a slightly wrong warning, never a wrong dinner. A per-cook override is additive if it bites.