···11+# Recipe Evolution: From Structured to Freeform
22+33+## Problem
44+55+Recipes today are pourover templates. They assume a fixed set of parameters
66+(coffee amount, water amount, grind size, brewer, pours) that don't generalize
77+to espresso, AeroPress, cold brew, milk drinks, or novel methods. Users can't
88+express "steam milk to 65C" or "pull a 1:2 shot in 28s" within the current
99+structure.
1010+1111+The goal: let power users build custom recipes from composable parts, while
1212+keeping simple recipes simple and preserving the ability to filter/compare
1313+across recipes.
1414+1515+## Design Axes
1616+1717+Two tensions shape the design space:
1818+1919+1. **Structure vs. Freedom** — Fixed fields enable queries and comparison.
2020+ Freeform fields enable creativity and method diversity.
2121+2. **Simple vs. Power** — A basic user wants to log "18g in, 300g out, medium
2222+ grind." A power user wants timed pour sequences, temperature profiles, and
2323+ milk steaming steps.
2424+2525+The sweet spot is a design that serves both without forcing either into the
2626+other's workflow.
2727+2828+## The Spectrum
2929+3030+### Level 0: What We Have Now
3131+3232+```
3333+name, brewerRef, coffeeAmount, waterAmount, grindSize, pours[], notes
3434+```
3535+3636+Fixed schema. Works for pourover. Breaks for everything else. Pours are the
3737+only "composable" element, and they're locked to water amount + time.
3838+3939+### Level 1: Core + Extensions
4040+4141+Keep the fields that are universal to nearly all coffee preparation, and add an
4242+open union array for everything else.
4343+4444+**Core fields (always present, queryable):**
4545+- `name` — recipe name
4646+- `coffeeAmount` — dose in tenths of grams (universal to all methods)
4747+- `notes` — freeform text
4848+- `sourceRef` — fork provenance
4949+5050+**Optional structured fields (queryable when present):**
5151+- `waterAmount` — total water (most methods, but not all — e.g., espresso
5252+ yield is measured differently)
5353+- `grindSize` — text or numeric
5454+- `brewerRef` / `brewerType` — gear reference
5555+5656+**Extensions (open union array):**
5757+```json
5858+"extensions": {
5959+ "type": "array",
6060+ "items": {
6161+ "type": "union",
6262+ "refs": [
6363+ "#pourStep",
6464+ "#waitStep",
6565+ "#tempStep",
6666+ "#pressStep",
6767+ "#steamStep",
6868+ "#textParam",
6969+ "#gearRef"
7070+ ]
7171+ }
7272+}
7373+```
7474+7575+This is the most conservative evolution. Simple recipes look identical to
7676+today. Power users bolt on steps and parameters. Filtering works on core
7777+fields. The split between "core" and "extension" can feel arbitrary though —
7878+why is grind size a core field but brew temperature isn't?
7979+8080+**Good for:** incremental migration, backwards compat, keeping simple things
8181+simple.
8282+8383+### Level 2: Minimal Core + Rich Parameters
8484+8585+Shrink the core to just what's truly universal, and push everything else into
8686+typed parameter blocks.
8787+8888+**Core fields:**
8989+- `name`
9090+- `coffeeAmount` — the one thing every coffee recipe has
9191+- `notes`
9292+- `sourceRef`
9393+9494+**Parameters (open union array):**
9595+```json
9696+"parameters": {
9797+ "type": "array",
9898+ "items": {
9999+ "type": "union",
100100+ "refs": [
101101+ "#weightParam",
102102+ "#ratioParam",
103103+ "#tempParam",
104104+ "#timeParam",
105105+ "#textParam",
106106+ "#gearRef",
107107+ "#ingredientRef"
108108+ ]
109109+ }
110110+}
111111+```
112112+113113+Each parameter carries a `label` (user-defined display name) and typed value:
114114+115115+| Type | Fields | Example |
116116+|------------------|-------------------------------|--------------------------------|
117117+| `#weightParam` | `label`, `grams` (int/10ths) | "Water": 3000 (= 300.0g) |
118118+| `#ratioParam` | `label`, `ratio` (float-ish) | "Brew Ratio": 16.7 |
119119+| `#tempParam` | `label`, `celsius` (int/10th) | "Brew Temp": 930 (= 93.0C) |
120120+| `#timeParam` | `label`, `seconds` (int) | "Bloom Time": 45 |
121121+| `#textParam` | `label`, `value` (string) | "Grind": "18 clicks on C40" |
122122+| `#gearRef` | `label`, `ref` (at-uri) | "Brewer": at://did/collection/rkey |
123123+| `#ingredientRef` | `label`, `ref` (at-uri) | "Bean": at://did/collection/rkey |
124124+125125+**Steps (separate open union array for process):**
126126+```json
127127+"steps": {
128128+ "type": "array",
129129+ "items": {
130130+ "type": "union",
131131+ "refs": [
132132+ "#pourStep",
133133+ "#waitStep",
134134+ "#stirStep",
135135+ "#pressStep",
136136+ "#steamStep",
137137+ "#customStep"
138138+ ]
139139+ }
140140+}
141141+```
142142+143143+Steps are ordered and describe the process:
144144+145145+| Type | Fields | Example |
146146+|---------------|-------------------------------------|----------------------------|
147147+| `#pourStep` | `label?`, `grams`, `seconds` | Bloom: 50g at 0:00 |
148148+| `#waitStep` | `label?`, `seconds` | Wait 45s |
149149+| `#stirStep` | `label?`, `technique?` | Rao spin |
150150+| `#pressStep` | `label?`, `seconds?` | Plunge over 30s |
151151+| `#steamStep` | `label?`, `milkType?`, `tempC?` | Steam oat milk to 65C |
152152+| `#customStep` | `label`, `description` | "Swirl the V60 3 times" |
153153+154154+This gives you two dimensions: **what goes in** (parameters) and **what you do**
155155+(steps). A pourover recipe might have 3 parameters and 5 steps. An espresso
156156+recipe might have 6 parameters and 1 step. A milk drink might chain espresso
157157+extraction into steaming into latte art.
158158+159159+**Good for:** method diversity, power users, recipe builder UI.
160160+161161+### Level 3: Everything is a Facet
162162+163163+The most freeform option. No core fields beyond `name`. The recipe is a bag of
164164+typed facets, each one self-describing.
165165+166166+```json
167167+{
168168+ "name": "Morning Espresso",
169169+ "facets": [
170170+ { "$type": "#weightParam", "label": "Dose", "grams": 180 },
171171+ { "$type": "#weightParam", "label": "Yield", "grams": 360 },
172172+ { "$type": "#timeParam", "label": "Shot Time", "seconds": 28 },
173173+ { "$type": "#tempParam", "label": "Brew Temp", "celsius": 930 },
174174+ { "$type": "#gearRef", "label": "Machine", "ref": "at://..." },
175175+ { "$type": "#textParam", "label": "Grind", "value": "Setting 2.5" },
176176+ { "$type": "#steamStep", "label": "Milk", "milkType": "Oat", "tempC": 650 },
177177+ { "$type": "#customStep", "label": "Latte Art", "description": "Tulip" }
178178+ ],
179179+ "notes": "Pull shot first, steam while extracting",
180180+ "sourceRef": "at://..."
181181+}
182182+```
183183+184184+Maximum flexibility. But you lose the ability to query "all recipes with >15g
185185+dose" unless you define conventions about label names, which is fragile. Also
186186+mixes parameters and process into one flat list — rendering order matters but
187187+semantic grouping is lost.
188188+189189+**Good for:** maximum creative freedom. **Bad for:** filtering, comparison,
190190+consistent UI.
191191+192192+## Recommended Approach: Level 2 Hybrid
193193+194194+Level 2 hits the sweet spot. Here's why:
195195+196196+### coffeeAmount stays in core
197197+198198+Every coffee recipe starts with a dose. Keeping it as a fixed field means:
199199+- Explore page can filter by dose range
200200+- Ratio computation works reliably (coffeeAmount + a waterAmount param or a
201201+ ratioParam)
202202+- Simple recipes need zero extensions — just fill in the core
203203+204204+### waterAmount moves to a parameter (with a convention)
205205+206206+Water/yield is method-dependent. For pourover it's total water. For espresso
207207+it's liquid yield. For cold brew it's steep water. Making it a `weightParam`
208208+with a conventional label handles all of these, but you lose trivial ratio
209209+computation unless the UI knows to look for the first `weightParam` or a
210210+`ratioParam`.
211211+212212+**Alternative:** keep `waterAmount` in core too. It's *almost* universal, and
213213+having both dose and water in core makes ratio filtering work everywhere. The
214214+only methods where it's awkward are Turkish coffee (no measured water) and
215215+cupping — edge cases you can ignore for now.
216216+217217+### Parameters are the "what"
218218+219219+Typed building blocks for inputs: grind settings, temperatures, gear
220220+references, ingredient refs. The type system means the UI can render
221221+appropriate inputs (number spinner for weight, temperature picker for temp,
222222+gear selector for refs).
223223+224224+### Steps are the "how"
225225+226226+Ordered process instructions. Pour schedules, wait times, stir techniques,
227227+press actions. Having steps separate from parameters means you can show them
228228+differently in the UI — parameters in a summary card, steps in a timeline.
229229+230230+### The customStep / textParam escape hatches
231231+232232+Power users can always add a `customStep` or `textParam` when the typed
233233+options don't cover their needs. This prevents the system from being limiting
234234+while still encouraging structured data when it fits.
235235+236236+## What This Looks Like in Practice
237237+238238+### Simple Pourover (basic user)
239239+240240+Core fields only, no extensions needed:
241241+242242+```
243243+name: "Daily V60"
244244+coffeeAmount: 180 (18.0g)
245245+waterAmount: 3000 (300.0g) [if kept in core]
246246+grindSize: "Medium-Fine"
247247+brewerRef: at://did/...brewer/abc
248248+notes: "Standard recipe, nothing fancy"
249249+```
250250+251251+Identical to today. Zero learning curve.
252252+253253+### Detailed Pourover (power user)
254254+255255+Core fields plus steps:
256256+257257+```
258258+name: "Hoffmann V60"
259259+coffeeAmount: 150 (15.0g)
260260+waterAmount: 2500 (250.0g)
261261+grindSize: "Medium"
262262+brewerRef: at://did/...brewer/abc
263263+264264+parameters:
265265+ tempParam("Water Temp", 950) (95.0C)
266266+ gearRef("Grinder", at://did/...grinder/xyz)
267267+ textParam("Filter", "Cafec Abaca")
268268+269269+steps:
270270+ pourStep("Bloom", 50g, 0s)
271271+ waitStep("Bloom Wait", 45s)
272272+ pourStep("Main Pour", 200g, 45s)
273273+ stirStep("Swirl")
274274+ waitStep("Drawdown", 60s)
275275+```
276276+277277+### Espresso
278278+279279+```
280280+name: "Morning Shot"
281281+coffeeAmount: 180 (18.0g)
282282+283283+parameters:
284284+ weightParam("Yield", 360) (36.0g)
285285+ timeParam("Shot Time", 28)
286286+ tempParam("Brew Temp", 930)
287287+ gearRef("Machine", at://did/...brewer/abc)
288288+ textParam("Grind", "2.5 on Niche")
289289+290290+steps:
291291+ customStep("Prep", "WDT, tamp level, pull shot")
292292+```
293293+294294+### Oat Latte
295295+296296+```
297297+name: "Oat Flat White"
298298+coffeeAmount: 180 (18.0g)
299299+300300+parameters:
301301+ weightParam("Yield", 360)
302302+ timeParam("Shot Time", 28)
303303+ gearRef("Machine", at://did/...brewer/abc)
304304+ ingredientRef("Bean", at://did/...bean/xyz)
305305+306306+steps:
307307+ extractStep("Pull Shot")
308308+ steamStep("Steam Milk", milkType: "Oat", tempC: 650)
309309+ customStep("Pour", "Flat white dot pattern")
310310+```
311311+312312+### Cold Brew
313313+314314+```
315315+name: "Weekend Cold Brew"
316316+coffeeAmount: 700 (70.0g)
317317+318318+parameters:
319319+ weightParam("Water", 7000)
320320+ textParam("Grind", "Coarse")
321321+ tempParam("Steep Temp", 40) (4.0C / fridge)
322322+ timeParam("Steep Time", 57600) (16 hours)
323323+324324+steps:
325325+ customStep("Combine", "Add grounds to jar, pour water, stir")
326326+ waitStep("Steep", 57600)
327327+ customStep("Filter", "Strain through Chemex filter, dilute 1:1")
328328+```
329329+330330+## Recipe Builder UI
331331+332332+The UI becomes a two-panel recipe builder:
333333+334334+**Left panel: Parameters**
335335+- Coffee amount always visible (core)
336336+- Water amount visible by default (core or auto-added param)
337337+- "Add parameter" dropdown: Weight, Temp, Time, Text, Gear, Ingredient
338338+- Each parameter rendered with appropriate input for its type
339339+- Drag to reorder
340340+341341+**Right panel: Steps (optional)**
342342+- "Add step" dropdown: Pour, Wait, Stir, Press, Steam, Custom
343343+- Each step rendered as a timeline card
344344+- Drag to reorder
345345+- Collapse/expand for complex recipes
346346+347347+**Simple mode:** Just the core fields — name, dose, water, grind, brewer,
348348+notes. No parameters panel, no steps panel. Looks like today's form.
349349+350350+**Power mode:** Toggle or auto-expand when user adds first parameter or step.
351351+Or just always show the "Add parameter" / "Add step" buttons below the core
352352+fields.
353353+354354+## Migration
355355+356356+The current lexicon can evolve to Level 2 without breaking existing records:
357357+358358+1. Existing fields (`coffeeAmount`, `waterAmount`, `grindSize`, `brewerRef`,
359359+ `brewerType`, `pours`) remain and continue to work
360360+2. Add `parameters` and `steps` as new optional array fields
361361+3. Existing `pours` could be deprecated in favor of `steps` with `#pourStep`,
362362+ but old records with `pours` still parse fine
363363+4. New UI writes to both `pours` (backwards compat) and `steps` (new format)
364364+ during transition, then drops `pours` in a future version
365365+366366+No data migration needed. Old records just lack the new fields.
367367+368368+## Lexicon Sketch
369369+370370+```json
371371+{
372372+ "lexicon": 1,
373373+ "id": "social.arabica.alpha.recipe",
374374+ "defs": {
375375+ "main": {
376376+ "type": "record",
377377+ "key": "tid",
378378+ "record": {
379379+ "type": "object",
380380+ "required": ["name", "createdAt"],
381381+ "properties": {
382382+ "name": { "type": "string", "maxLength": 200 },
383383+ "coffeeAmount": { "type": "integer", "minimum": 0 },
384384+ "waterAmount": { "type": "integer", "minimum": 0 },
385385+ "grindSize": { "type": "string", "maxLength": 100 },
386386+ "brewerRef": { "type": "string", "format": "at-uri" },
387387+ "brewerType": { "type": "string", "maxLength": 100 },
388388+ "parameters": {
389389+ "type": "array",
390390+ "maxLength": 20,
391391+ "items": { "type": "union", "refs": [
392392+ "#weightParam", "#ratioParam", "#tempParam",
393393+ "#timeParam", "#textParam", "#gearRef", "#ingredientRef"
394394+ ]}
395395+ },
396396+ "steps": {
397397+ "type": "array",
398398+ "maxLength": 30,
399399+ "items": { "type": "union", "refs": [
400400+ "#pourStep", "#waitStep", "#stirStep",
401401+ "#pressStep", "#steamStep", "#customStep"
402402+ ]}
403403+ },
404404+ "pours": {
405405+ "type": "array",
406406+ "description": "[DEPRECATED] Use steps with #pourStep instead",
407407+ "items": { "type": "ref", "ref": "#pour" }
408408+ },
409409+ "notes": { "type": "string", "maxLength": 2000 },
410410+ "sourceRef": { "type": "string", "format": "at-uri" },
411411+ "createdAt": { "type": "string", "format": "datetime" }
412412+ }
413413+ }
414414+ },
415415+416416+ "pour": {
417417+ "type": "object",
418418+ "description": "[DEPRECATED] Legacy pour format",
419419+ "required": ["waterAmount", "timeSeconds"],
420420+ "properties": {
421421+ "waterAmount": { "type": "integer", "minimum": 0 },
422422+ "timeSeconds": { "type": "integer", "minimum": 0 }
423423+ }
424424+ },
425425+426426+ "weightParam": {
427427+ "type": "object",
428428+ "required": ["label", "grams"],
429429+ "properties": {
430430+ "label": { "type": "string", "maxLength": 50 },
431431+ "grams": { "type": "integer", "minimum": 0,
432432+ "description": "Weight in tenths of grams" }
433433+ }
434434+ },
435435+ "ratioParam": {
436436+ "type": "object",
437437+ "required": ["label", "ratio"],
438438+ "properties": {
439439+ "label": { "type": "string", "maxLength": 50 },
440440+ "ratio": { "type": "integer", "minimum": 0,
441441+ "description": "Ratio in tenths (167 = 1:16.7)" }
442442+ }
443443+ },
444444+ "tempParam": {
445445+ "type": "object",
446446+ "required": ["label", "celsius"],
447447+ "properties": {
448448+ "label": { "type": "string", "maxLength": 50 },
449449+ "celsius": { "type": "integer", "minimum": 0,
450450+ "description": "Temp in tenths of degrees C" }
451451+ }
452452+ },
453453+ "timeParam": {
454454+ "type": "object",
455455+ "required": ["label", "seconds"],
456456+ "properties": {
457457+ "label": { "type": "string", "maxLength": 50 },
458458+ "seconds": { "type": "integer", "minimum": 0 }
459459+ }
460460+ },
461461+ "textParam": {
462462+ "type": "object",
463463+ "required": ["label", "value"],
464464+ "properties": {
465465+ "label": { "type": "string", "maxLength": 50 },
466466+ "value": { "type": "string", "maxLength": 500 }
467467+ }
468468+ },
469469+ "gearRef": {
470470+ "type": "object",
471471+ "required": ["label", "ref"],
472472+ "properties": {
473473+ "label": { "type": "string", "maxLength": 50 },
474474+ "ref": { "type": "string", "format": "at-uri" }
475475+ }
476476+ },
477477+ "ingredientRef": {
478478+ "type": "object",
479479+ "required": ["label", "ref"],
480480+ "properties": {
481481+ "label": { "type": "string", "maxLength": 50 },
482482+ "ref": { "type": "string", "format": "at-uri" }
483483+ }
484484+ },
485485+486486+ "pourStep": {
487487+ "type": "object",
488488+ "required": ["grams", "seconds"],
489489+ "properties": {
490490+ "label": { "type": "string", "maxLength": 50 },
491491+ "grams": { "type": "integer", "minimum": 0 },
492492+ "seconds": { "type": "integer", "minimum": 0 }
493493+ }
494494+ },
495495+ "waitStep": {
496496+ "type": "object",
497497+ "required": ["seconds"],
498498+ "properties": {
499499+ "label": { "type": "string", "maxLength": 50 },
500500+ "seconds": { "type": "integer", "minimum": 0 }
501501+ }
502502+ },
503503+ "stirStep": {
504504+ "type": "object",
505505+ "properties": {
506506+ "label": { "type": "string", "maxLength": 50 },
507507+ "technique": { "type": "string", "maxLength": 200 }
508508+ }
509509+ },
510510+ "pressStep": {
511511+ "type": "object",
512512+ "properties": {
513513+ "label": { "type": "string", "maxLength": 50 },
514514+ "seconds": { "type": "integer", "minimum": 0 }
515515+ }
516516+ },
517517+ "steamStep": {
518518+ "type": "object",
519519+ "properties": {
520520+ "label": { "type": "string", "maxLength": 50 },
521521+ "milkType": { "type": "string", "maxLength": 100 },
522522+ "tempCelsius": { "type": "integer", "minimum": 0,
523523+ "description": "Tenths of degrees C" }
524524+ }
525525+ },
526526+ "customStep": {
527527+ "type": "object",
528528+ "required": ["label", "description"],
529529+ "properties": {
530530+ "label": { "type": "string", "maxLength": 50 },
531531+ "description": { "type": "string", "maxLength": 500 }
532532+ }
533533+ }
534534+ }
535535+}
536536+```
537537+538538+## Open Questions
539539+540540+1. **Should `waterAmount` stay in core?** It makes ratio filtering trivial but
541541+ is awkward for espresso (where "yield" is the output measurement, not input
542542+ water). Could keep it in core with the understanding that for espresso
543543+ recipes, a `weightParam("Yield", ...)` is the meaningful number and
544544+ `waterAmount` is omitted.
545545+546546+2. **Parameter ordering** — should the array order be meaningful (display
547547+ order) or should the UI sort by type? Leaning toward array order = display
548548+ order, since users will arrange their recipe builder intentionally.
549549+550550+3. **Step timing model** — current pours use absolute time (seconds from brew
551551+ start). Steps could use relative time (duration of this step) or absolute.
552552+ Relative is simpler for the user; absolute is easier for a timer UI.
553553+554554+4. **Preset templates** — should there be a `method` or `template` field that
555555+ pre-populates parameters and steps? e.g., selecting "Espresso" auto-adds
556556+ dose/yield/time/temp params. This is purely a UI concern, not a lexicon one.
557557+558558+5. **Brew integration** — brews currently reference a recipe. With freeform
559559+ recipes, should a brew record capture the *snapshot* of parameters used, or
560560+ just reference the recipe? Snapshot is more accurate (recipe might change),
561561+ reference is lighter.
+11
docs/recipes.norg
···7575 For links between a brew and recipe, which should have the optional ref?
7676 Probably brew, but should a recipe contain a ref to the original brew that it
7777 /may/ have been saved from.
7878+7979+** Alternate Recipe Design
8080+8181+ Recipes only contain minimal structure and are more freeform, (build a custom
8282+ recipe), with just a content field in the lexicon that contains arbitrary
8383+ content that can be customized as desired. This leads to more utility that
8484+ what just a pourover recipe provides (e.g. maybe milk drink stuff for
8585+ espresso?).
8686+8787+ This would require some sort of recipe customizer page that allows adding
8888+ arbitrary key-value pairs (or pours/refs to gear/beans).