// Registry for the field-help dialog (see field-help.js). Each topic renders a DocumentFragment // of reference material — tables, worked examples, glossary — pulled from the original paper // worksheet (manual-roast-planner.html) that the app's why-panels don't cover. Why-panels teach // *why* a section works the way it does; these topics are *what are my options and what do they // do*, so keep prose here table-and-example-heavy rather than mechanism-heavy. import { GROUPS, CULTIVARS, PROCESSES, ROAST_LEVELS, MACHINE, SANITY_BANDS, TAPER_TEMPLATE, SYMPTOM_FIXES, CHARGE_SETTINGS, YELLOW_RATIO, findCultivar, } from "/shared/reference-data.js"; // ---- small DOM builders -------------------------------------------------- function p(text) { const el = document.createElement("p"); el.textContent = text; return el; } function h4(text) { const el = document.createElement("h4"); el.textContent = text; return el; } function frag(...nodes) { const f = document.createDocumentFragment(); for (const n of nodes) if (n) f.append(n); return f; } function aside({ tag, text, warn }) { const el = document.createElement("div"); el.className = warn ? "help-aside warn" : "help-aside"; const tagEl = document.createElement("span"); tagEl.className = "tag"; tagEl.textContent = tag; el.append(tagEl, p(text)); return el; } function formula(text, caption) { const wrap = document.createDocumentFragment(); const f = document.createElement("div"); f.className = "help-formula"; f.textContent = text; wrap.append(f); if (caption) { const cap = document.createElement("p"); cap.textContent = caption; cap.style.marginTop = "-4px"; cap.style.fontSize = "11px"; cap.style.textAlign = "center"; cap.style.color = "var(--ink-3)"; wrap.append(cap); } return wrap; } function glossary(pairs) { const dl = document.createElement("dl"); for (const [term, def] of pairs) { const dt = document.createElement("dt"); dt.textContent = term; dt.style.fontWeight = "700"; dt.style.marginTop = "8px"; const dd = document.createElement("dd"); dd.textContent = def; dd.style.margin = "2px 0 0"; dl.append(dt, dd); } return dl; } /** cols: [{label, key, num?, render?(row)}]. rows: plain objects, or {__group: "label"} marker * rows for a group header spanning the full width. isCurrent(row) marks tr.current. */ function buildTable({ cols, rows, isCurrent }) { const wrap = document.createElement("div"); wrap.className = "help-table-wrap"; const table = document.createElement("table"); table.className = "help-table"; const thead = document.createElement("thead"); const headRow = document.createElement("tr"); for (const c of cols) { const th = document.createElement("th"); th.textContent = c.label; headRow.append(th); } thead.append(headRow); table.append(thead); const tbody = document.createElement("tbody"); for (const row of rows) { if (row.__group) { const tr = document.createElement("tr"); tr.className = "grp"; const td = document.createElement("td"); td.colSpan = cols.length; td.textContent = row.__group; tr.append(td); tbody.append(tr); continue; } const tr = document.createElement("tr"); if (isCurrent?.(row)) tr.classList.add("current"); for (const c of cols) { const td = document.createElement("td"); if (c.num) td.classList.add("num"); const content = c.render ? c.render(row) : (row[c.key] ?? ""); if (content instanceof Node) td.append(content); else td.textContent = content; tr.append(td); } tbody.append(tr); } table.append(tbody); wrap.append(table); return wrap; } const cToF = (c) => Math.round((c * 9) / 5 + 32); // ---- topics --------------------------------------------------------------- export const HELP_TOPICS = { cultivar: { title: "Cultivar — every row, and what it tastes like", targets: ['label[data-fid="1.1"] .field-label', 'div[data-fid="1.2"] .field-label'], render(plan) { const match = findCultivar(plan.fields["1.1"]); const rows = []; for (const group of GROUPS) { rows.push({ __group: `${group.label.toUpperCase()} — ${group.tagline}` }); for (const c of CULTIVARS.filter((c) => c.group === group.key)) rows.push(c); } const table = buildTable({ cols: [ { label: "Cultivar", key: "name" }, { label: "First crack", key: "fcAnchor", num: true }, { label: "Profile", num: true, render: (c) => c.profile.join(" | ") }, { label: "Expect in the cup", key: "cup" }, { label: "Sweet spot & what to watch", render: (c) => { if (!c.sweetSpot) return c.watch; const el = document.createElement("span"); const strong = document.createElement("strong"); strong.textContent = `${c.sweetSpot === "narrow" ? "Narrow" : "Wide"}. `; el.append(strong, document.createTextNode(c.watch)); return el; }, }, ], rows, isCurrent: (row) => !row.__group && match && row.name === match.name, }); return frag( p( "Find your cultivar below. If it isn't listed, find its nearest relative on a coffee " + "family tree (World Coffee Research, Café Imports and Royal Coffee all publish one) " + "and use that group's row — the four groups exist so an unlisted cultivar still has " + "an answer. Profile reads charge→yellow | yellow→FC | FC→drop, for a light roast.", ), table, aside({ tag: "Why cultivar sets the clock", text: "Genetics that look purely physical — plant height, yield, bean size — change the cup " + "indirectly but powerfully. Denser, more mature seeds carry more sugar and more organic " + "acid, and bean size changes how heat gets in: a Maragogipe or Pacamara has so much " + "mass behind so little surface that the outside scorches while the inside lags. So " + "“roast a Gesha fast” is not mysticism: this seed browns and develops its " + "aromatics on a schedule set by its own chemistry, and if you take longer, the delicate " + "volatiles are gone by the time you drop.", }), aside({ tag: "The label may be wrong", warn: true, text: "Genetic testing is expensive and rare. Cross-pollination runs roughly 20–25% even in " + "single-variety plots, and lines drift toward one parent over time. Treat the cultivar " + "on the bag as your best hypothesis, not a fact — which is exactly why the " + "After-the-Roast symptom table exists. And if this coffee is more than one cultivar, " + "the Blend section resolves it into a single anchor.", }), ); }, }, "anchor-profile": { title: "Anchor & profile — how one row becomes your plan", targets: [ 'label[data-fid="1.4"] .field-label', 'label[data-fid="1.5"] .field-label', 'label[data-fid="1.7"] .field-label', ], render() { return frag( h4("Reading the shorthand"), formula( "4:15 | 3:15 | 1:30", "charge → yellow · yellow → first crack · first crack → drop", ), p( 'They add up: first crack is 4:15 + 3:15 = 7:30, end of roast is 7:30 + 1:30 = 9:00. ' + "That additivity is the entire trick — it makes a roast plan something you compute " + "rather than guess.", ), h4("Use your cultivar's own row"), p( "The group headings are for finding an unlisted cultivar's nearest relative — they are " + "not themselves the anchor. Papayo's row already says 7:20; Pacamara's already says " + "7:30–9:00. Write that row's own value into FC anchor (pick a point in the range if " + "one is given). Applying both a group number and a “cultivar shift” on top " + "of it double-counts the same adjustment — the row already has it built in.", ), p( "± Refine stays at 0 the first time you roast this coffee; it exists only to carry the " + "correction you wrote after cupping the previous batch.", ), h4("Where ± Dev modifier comes from"), p( "It is the profile's third number compared to the 1:30 default most rows share: Pacamara " + "ends in 1:45 (+0:15), Pink Bourbon and Papayo end in 1:20 (−0:10), Catimor and " + "Sarchimor run 1:30–2:00 (+0:00 to +0:30). If your row's third number is 1:30, the " + "modifier is 0. Selecting a cultivar autofills this.", ), buildTable({ cols: [ { label: "Cultivar", key: "cultivar" }, { label: "3rd number", key: "third", num: true }, { label: "Modifier", key: "mod", num: true }, ], rows: [ { cultivar: "Pacamara", third: "1:45", mod: "+0:15" }, { cultivar: "Pink Bourbon, Papayo", third: "1:20", mod: "−0:10" }, { cultivar: "Catimor, Sarchimor", third: "1:30–2:00", mod: "+0:00 to +0:30" }, { cultivar: "Everything else", third: "1:30", mod: "0" }, ], }), aside({ tag: `Why yellow = ${YELLOW_RATIO} × FC belongs here too`, text: "Rush to yellow and the roast arrives before internal pressure has built; drag it out " + "and you reach first crack already too dark or with no room left for development. " + `${YELLOW_RATIO} places yellow in the middle of that trade-off. It is empirical, not ` + `derived — but it is the ratio implied by every profile row (7:30 × ${YELLOW_RATIO} = ` + `4:12 ≈ 4:15 · 8:45 × ${YELLOW_RATIO} = 4:54 ≈ 4:45–5:00), which is a decent ` + "consistency check.", }), ); }, }, "symptom-fix": { title: "Symptom → cause → the single number to move", targets: [ 'label[data-fid="1.6"] .field-label', "#sec-after .field.accent .field-label", ], render() { return frag( p( "This is the feedback loop. After cupping, find the symptom below, write the fix into " + '"One change next batch" — and the next time you roast this coffee, carry it into ' + "± Refine. First cook of a coffee, ± Refine is always 0: there is no evidence yet.", ), buildTable({ cols: [ { label: "What you taste", key: "symptom" }, { label: "Most likely cause", key: "cause" }, { label: "Change this", key: "change", num: true }, { label: "Notes", key: "note" }, ], rows: SYMPTOM_FIXES, }), aside({ tag: "Change one thing per batch — arithmetic, not patience", text: "Move two variables and there are four possible explanations for whatever the cup " + "does; move three and there are eight, and you cannot separate them without more " + 'batches than you have coffee for. Every number here has one owner, so "development ' + '+0:15, everything else identical" is a plan you can execute and read.', }), aside({ tag: "Before you conclude the roast was wrong", warn: true, text: "A cultivar's reputation is shaped by how people habitually roast it: Maragogipe was " + "called dull and savory for decades largely because the slow approach old machines " + "forced made it dull and savory. And the coffee may simply not be good — no profile " + "rescues a badly picked, fermented or dried lot.", }), ); }, }, "blend-method": { title: "Blends — one plan for more than one coffee", targets: [ "#sec-blend .subhead", 'label[data-fid="2.4"] .field-label', 'label[data-fid="2.5"] .field-label', ], render() { return frag( p( "A blend goes through the same drum on the same clock — you cannot hand a Bourbon " + "component 8:45 and an Ethiopian component 7:30 in the same batch. This resolves the " + "per-cultivar anchors into one anchor, and the per-process modifiers into one " + "modifier. If the exact split isn't on the bag, a label that says “70/30” " + 'or "predominantly X" is telling you the share; if nothing is given, assume equal ' + "shares among the named cultivars rather than guessing a majority that isn't " + "documented.", ), buildTable({ cols: [ { label: "Situation", key: "situation" }, { label: "What to do", key: "action" }, ], rows: [ { situation: "Dominant component — one component ≥ 60% of the batch", action: "Use that component's first-crack anchor and processing modifier unmodified — " + "roast the blend as though it were that one coffee. The minority components " + "ride along; note their character in the cup notes rather than trying to plan " + "for them.", }, { situation: "Weighted average — no component reaches 60%", action: "Convert each component's anchor to seconds, multiply by its share, and sum. Do " + "the same with the processing modifiers if processing differs. The result is an " + "anchor that exists nowhere in nature — that's fine; it's a deliberate " + "compromise, not an error.", }, { situation: "Split-roast, blend after — components disagree by more than about a minute at " + "first crack and neither is dominant", action: "Roast each component separately to its own plan, weigh each roasted output, and " + "blend by weight afterwards. A wide split rarely converges on one profile that's " + "actually good for both halves. Splitting costs two batches; it buys back a plan " + "you aren't guessing at.", }, ], }), aside({ tag: "Worked example — weighted average", text: "55% Ethiopian-heirloom washed (anchor 7:30 = 450 s) with 45% Bourbon washed (8:45 = " + "525 s). Neither reaches 60%, so weight them:", }), formula( "0.55 × 450 + 0.45 × 525 = 483.75 s ≈ 8:04", "That 8:04 goes into ledger line 1 — faster than the Bourbon wants, slower than the " + "Ethiopian wants, and correct for neither alone. That is the nature of a compromise anchor.", ), aside({ tag: "A blend is where the cultivar table is least trustworthy", warn: true, text: "Community lots are rarely genetically verified, and “70/30 Caturra/Catuai” " + "is an exporter's estimate, not a lab result. Treat the shares as a lower-confidence " + "hypothesis and let the symptom table do more of the work after the first batch.", }), ); }, }, process: { title: "Process — development time and drop temperature", targets: ['div[data-fid="3.1"] .field-label', 'label[data-fid="3.2"] .field-label'], render(plan) { const current = plan.fields["3.1"]; return frag( p( "Cultivar told you when to hit first crack. Processing tells you how long to stay past " + "it, and whether to drop hotter or cooler for the same colour. The one-line version: " + "natural and honey lots take 15–30 seconds less development than the equivalent " + "washed lot, and want a slightly earlier drop.", ), buildTable({ cols: [ { label: "Process", key: "label" }, { label: "Dev (light roast)", key: "devBand", num: true }, { label: "Drop temp vs. washed", key: "dropDeltaC" }, { label: "Why, and what it does to the cup", key: "why" }, ], rows: PROCESSES.map((row) => row.key === "anaerobic" ? { ...row, devBand: "−0:10 to −0:15 off its base category" } : row, ), isCurrent: (row) => row.key === current, }), aside({ tag: "When modifiers stack too far", text: "Bourbon anaerobic natural, light: anchor 8:45, base 1:30, natural −0:20, anaerobic " + "−0:10 → development 1:00, DTR 10.3% — below the 12% floor. When stacked modifiers " + "push a sanity check out of band, drop the smaller modifier rather than overriding " + "the check: taking only the anaerobic reduction (1:20) restores DTR to 13.2%.", }), aside({ tag: "A useful piece of scepticism", warn: true, text: "“Washed” guarantees only that pulping, some fermentation and washing " + "happened at some point. Fermentation length, temperature and resident microbes vary " + "enormously between producers. Two washed lots from neighbouring mills can behave " + "differently in the drum — when a lot defies this table, the processing detail you " + "weren't told is a likely culprit.", }), ); }, }, "roast-level": { title: "Roast level — weight loss, development, drop", targets: [], // placed by field-help.js via a `place()` function, see below place() { const anchor = document.querySelector('#plan-form div[data-fid="4.1"]'); const subhead = anchor?.previousElementSibling; return subhead?.classList.contains("subhead") ? [subhead] : []; }, render(plan) { const current = plan.fields["4.1"]; const rows = [ { label: "Under-developed", weightLossBand: "< 11%", devBand: "—", character: "Treat as a warning, not a level. Low-moisture greens occasionally land here legitimately; otherwise assume the roast didn't finish.", sentinel: true }, ...ROAST_LEVELS, { label: "Too far", weightLossBand: "> 22%", devBand: "—", character: "Approaching combustion. Avoid.", sentinel: true }, ]; return frag( p( "Roast level is the one input that is purely your decision. Everything else describes " + "the coffee; this describes what you want from it.", ), buildTable({ cols: [ { label: "Level", key: "label" }, { label: "Weight loss", key: "weightLossBand", num: true }, { label: "Development", key: "devBand", num: true }, { label: "Character", key: "character" }, ], rows, isCurrent: (row) => !row.sentinel && row.key === current, }), (() => { const strong = document.createElement("p"); const b = document.createElement("strong"); b.textContent = "Hard ceiling: development beyond 4:30 mutes even a dark roast. Nothing good is on " + "the other side of that line."; strong.append(b); return strong; })(), aside({ tag: "Why development is a bell curve, not a slider", text: "Shorter development pushes acidity up; longer pulls it down — that part is " + "monotonic. But enjoyment is not. Too short and the acidity has no fruit behind it; " + "too long and the cup goes flat. The pleasant region sits in the middle, and it is " + 'narrower than the acidity curve suggests. This is why "just add development time" ' + "is not a general fix — move in 15-second steps and cup between.", }), h4("Two ways to know where you landed"), p( "Weight loss is the honest instrument: WL% = (green − roasted) ÷ green × 100 — it rolls " + "total time, development, drop temperature and colour into one number your scale can " + "measure. Colour (Agtron) is more precise if you have a meter; light roasts here " + "target roughly 70 whole bean / 110 ground. The two disagree when sugar content is " + "unusual: high-sugar lots — SL cultivars, Sidra, Papayo, many naturals — read darker " + "than their weight loss predicts. Trust weight loss for development, colour for " + "appearance, and the cup over both.", ), aside({ tag: "Where the cultivar table stops applying", warn: true, text: "The first-crack anchors are validated for light and medium roasts. Dark roasting " + "behaves as a style of its own and generally rewards a longer, more relaxed approach " + "— past 17% weight loss, treat the cultivar row as a lower-confidence starting point " + "and let cupping lead.", }), ); }, }, moisture: { title: "Moisture — what the reading changes", targets: ['label[data-fid="5.1"] .field-label'], render() { return frag( buildTable({ cols: [ { label: "Reading", key: "reading" }, { label: "What it does in the drum", key: "effect" }, { label: "Correction", key: "correction" }, ], rows: [ { reading: "> 11.5% (wet)", effect: "Faster start — higher turning point, steeper RoR out of it — then the climb " + "flattens as the bean nears 100 °C, because water is absorbing energy to change " + "phase rather than raising temperature.", correction: "Charge slightly higher, or hold more heat through drying, to keep the same " + "times as a drier lot. Expect the stall; don't panic-add heat when it arrives.", }, { reading: "9.5–11.5%", effect: "The healthy range. Every number in this app assumes it.", correction: "None.", }, { reading: "< 9.5% (dry)", effect: "Less thermal ballast. Heats faster throughout and darkens more readily.", correction: "Charge lower, ease off heat sooner. Watch for a weight loss that under-reads " + "the visible colour.", }, { reading: "> 12%", effect: "A buying problem, not a roasting problem.", correction: "Expect flavour issues and poor longevity. Roast it soon or don't buy it — " + "worth writing landed moisture into your supplier contract.", }, ], }), p("Corrections land in ± Net correction, and rarely exceed ±0:15."), ); }, }, density: { title: "Density & altitude — how hard you can push", targets: ['label[data-fid="5.2"] .field-label', 'label[data-fid="5.3"] .field-label'], render() { return frag( buildTable({ cols: [ { label: "Character", key: "character" }, { label: "What it means", key: "meaning" }, { label: "Correction", key: "correction" }, ], rows: [ { character: "High density / high grown", meaning: "Slow maturation put more sugar and more organic acid into a tighter cell " + "structure. It tolerates a harder push without breaking.", correction: "You can drive it faster and hotter early. Reach for these when you want acidity.", }, { character: "Low density / low grown", meaning: "Softer structure, less sugar. Push it hard and you get defects instead of speed.", correction: "Gentler heat, more airflow. Better suited to blends and darker roasts where you " + "want less acidity.", }, ], }), aside({ tag: "Density beats altitude", text: "Density tracks maturation rate more than altitude itself. Heavy shade and cool " + "temperatures can produce high-density seed at low elevation — a 250 m Galápagos lot " + "has measured comparable to high-grown Kenyan. If you have a density reading, believe " + "it over the altitude on the bag.", }), ); }, }, screen: { title: "Bean size & screen — defect risks", targets: ['label[data-fid="5.4"] .field-label'], render() { return frag( buildTable({ cols: [ { label: "Character", key: "character" }, { label: "Risk", key: "risk" }, { label: "Correction", key: "correction" }, ], rows: [ { character: "Very large (Maragogipe, Pacamara)", risk: "Scorching, tipping and facing. Mass-to-surface ratio means the outside cooks " + "while the core lags, and the beans sit longer against hot metal.", correction: "Reduce batch size and raise airflow. That keeps inlet temperature down and " + "stops any one surface staying in contact too long.", }, { character: "Uneven screen", risk: "Mixed development — small beans finish while large ones are still behind.", correction: "Smaller batch, more airflow, gentler heat. Accept a wider window at first crack.", }, { character: "Delicate / tipping-prone (Gesha especially)", risk: "Dark scorch marks at the bean ends from excessive inlet air temperature.", correction: "Counter-intuitive but correct: to roast fast and safely, use more airflow at " + "lower inlet temperature rather than more heat. Same speed, less thermal shock.", }, ], }), aside({ tag: "Why “just turn up the heat” is the wrong way to go faster", text: "Raising the burner raises both conduction and convection, and the conductive share is " + "exactly what causes scorching and tipping. Raising airflow increases convective " + "transfer while lowering the temperature any single surface reaches. The safe way to " + "hit a 7:30 first crack on a fragile coffee is higher airflow at a lower inlet — more " + "energy delivered, less peak temperature at the contact point. This is the single most " + "useful idea in this section, and it applies to every fast-roasting cultivar.", }), ); }, }, "sweet-spot": { title: "Sweet spot — how much timing error the coffee forgives", targets: ['div[data-fid="5.5"] .field-label'], render() { const narrow = CULTIVARS.filter((c) => c.sweetSpot === "narrow"); const wide = CULTIVARS.filter((c) => c.sweetSpot === "wide"); const rows = [ ...narrow.map((c) => ({ name: c.name, band: "Narrow", miss: c.watch })), ...wide.map((c) => ({ name: c.name, band: "Wide", miss: c.watch })), ]; return frag( p( "Narrow means a miss of ±15 seconds at first crack shows in the cup; wide means the " + "cultivar tolerates a range and different points in it emphasise different characters. " + "The calls below come from the cultivar table's own rows.", ), buildTable({ cols: [ { label: "Cultivar", key: "name" }, { label: "Band", key: "band" }, { label: "What a miss does", key: "miss" }, ], rows, }), p("Rows not listed here don't state a band — assume medium and let the cup tell you."), ); }, }, "net-correction": { title: "± Net correction — judgment, in seconds", targets: ['label[data-fid="5.6"] .field-label'], render() { return frag( p( "This is where the bean-condition observations become one signed number on ledger " + "line 3. It is rarely more than ±0:15, and only for an observation you can name: wet " + "greens (> 11.5%) may earn a few seconds later; very dry (< 9.5%) a few seconds " + "earlier; low density argues for gentleness, not lateness. Moisture and density never " + "create this number automatically — the app deliberately leaves it to you.", ), aside({ tag: "Don't buy speed with heat", warn: true, text: "If the reason you're tempted to correct is a large or fragile bean, the correction " + "usually isn't time at all — it's a smaller batch and more airflow (see Screen). " + "Large or fragile beans: don't buy speed with heat.", }), ); }, }, "batch-size": { title: "Batch size — the strongest lever you don't set", targets: [], place() { const input = document.querySelector('#plan-form input[name="6.4"]'); const label = input?.closest(".ledger-row")?.querySelector(".l-label"); return label ? [label] : []; }, render() { return frag( p( "Batch size is the strongest correlate with first-crack time in your logged data " + "(r = +0.67). Your logs: 225 g lands first crack roughly 1:30–2:00 later than 200 g " + "at similar settings — the 226 g batches sat at 11:16 and 12:24 when nothing at 200 g " + "went past 10:33. Enter the correction for your green weight versus the reference " + "weights below.", ), buildTable({ cols: [ { label: "Batch", key: "batch" }, { label: "Charge probe", key: "chargeC", num: true }, { label: "Heat", key: "heat", num: true }, { label: "Fan", key: "fan", num: true }, { label: "Observed result", key: "observed" }, ], rows: CHARGE_SETTINGS, }), aside({ tag: "Charge temperature is a batch-size function", text: "Higher charge for larger batches is how the reference rows keep their timing. If " + "your turning point lands much past 1:05 at 200 g, you charged cool.", }), ); }, }, "milestones-ror": { title: "Milestones & the RoR budget — your machine's proven numbers", targets: [], place() { const el = document.querySelectorAll("#sec-machine .subhead")[0]; return el ? [el] : []; }, render() { const rows = [ { milestone: "Charge", median: `${MACHINE.charge.medianC} °C`, range: `${MACHINE.charge.rangeC[0]}–${MACHINE.charge.rangeC[1]} °C`, note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." }, { milestone: "Turning point", median: `${MACHINE.turningPoint.medianC} °C @ ${MACHINE.turningPoint.medianTime}`, range: `${MACHINE.turningPoint.rangeC[0]}–${MACHINE.turningPoint.rangeC[1]} °C @ ${MACHINE.turningPoint.rangeTime}`, note: "Falls out of charge temperature and batch mass; you don't set it directly. A TP much past 1:05 at 200 g means you charged cool." }, { milestone: "Yellow", median: `${MACHINE.yellow.medianC} °C`, range: `${MACHINE.yellow.rangeC[0]}–${MACHINE.yellow.rangeC[1]} °C`, note: "Threshold-derived in your logs, not observed. Use as a rough guide and start calling it by eye — grassy smell turns to bread, colour goes straw." }, { milestone: "First crack", median: `${MACHINE.firstCrack.medianC} °C`, range: `${MACHINE.firstCrack.rangeC[0]}–${MACHINE.firstCrack.rangeC[1]} °C`, note: "Tight and reliable across all your lots. This is your most trustworthy single number." }, { milestone: "Drop (light)", median: `${MACHINE.drop.medianC} °C`, range: `${MACHINE.drop.rangeC[0]}–${MACHINE.drop.rangeC[1]} °C`, note: "All your logged roasts are light. For darker levels, build the ladder yourself: raise drop 2–3 °C, weigh, record — don't extrapolate." }, ]; return frag( p( "These bands are from your own logged roasts — the same machine, probe and operator. " + "The times and ratios in this app port between machines; the temperatures do not. " + `Your probe reads first crack at 176–187 °C (${cToF(176)}–${cToF(187)} °F), well below ` + "the ~196 °C a fast, well-immersed probe would show; that doesn't make it wrong, it " + "makes it yours.", ), buildTable({ cols: [ { label: "Milestone", key: "milestone" }, { label: "Median", key: "median", num: true }, { label: "Range", key: "range", num: true }, { label: "Note", key: "note" }, ], rows, }), p( "For each phase, divide the temperature you must gain by the time you have. A phase " + "outside its band means the plan is not reachable — change the ledger, not the roaster.", ), buildTable({ cols: [ { label: "Phase", key: "phase" }, { label: "Machine-proven °C/min", key: "band", num: true }, ], rows: [ { phase: "TP → Yellow", band: `${MACHINE.ror.tpToYellow[0]}–${MACHINE.ror.tpToYellow[1]}` }, { phase: "Yellow → FC", band: `${MACHINE.ror.yellowToFc[0]}–${MACHINE.ror.yellowToFc[1]}` }, { phase: "FC → Drop", band: `${MACHINE.ror.fcToDrop[0]}–${MACHINE.ror.fcToDrop[1]}` }, ], }), h4("Terms"), glossary([ ["Turning point", "the lowest bean-probe reading after charge, where the beans stop cooling the drum and start absorbing heat"], ["Yellow / dry end", "free moisture largely gone, colour green→straw, smell grassy→bread; call it by eye and nose"], ["Maillard", "the browning phase between yellow and first crack — most of the sweetness, body and complexity"], ["First crack", "audible, exothermic; internal pressure ruptures the cell structure"], ["Development", "first crack to drop — the main acidity/sweetness lever and the only phase you can still adjust while roasting"], ["DTR", "development ÷ total roast time"], ["Rate of rise", "how fast bean temperature climbs, °C/min"], ["Exotherm", "around first crack the bean briefly releases its own heat — roughly +1.4 °C/min you did not command"], ["Second crack", "quieter, higher-pitched; dark-roast territory, past everything these numbers are validated for"], ]), ); }, }, actuators: { title: "Heat & fan — the taper, and what actually moves first crack", targets: [], place() { const el = document.querySelectorAll("#sec-machine .subhead")[1]; return el ? [el] : []; }, render() { return frag( buildTable({ cols: [ { label: "Point in roast", key: "point" }, { label: "Heat", key: "heat", num: true }, { label: "Fan", key: "fan", num: true }, { label: "Intent", key: "intent" }, ], rows: TAPER_TEMPLATE, }), h4("Charge settings by batch"), buildTable({ cols: [ { label: "Batch", key: "batch" }, { label: "Charge probe", key: "chargeC", num: true }, { label: "Heat", key: "heat", num: true }, { label: "Fan", key: "fan", num: true }, { label: "Observed result", key: "observed" }, ], rows: CHARGE_SETTINGS, }), aside({ tag: "The first-cut lever", text: "When you make the first heat cut is your main first-crack lever — later cut, earlier " + "crack. Holding 100% to ~3:00 gave 7:55; cutting at ~1:00 gave 9:00. Steps of 3–5% " + "beat single large ones, and the machine has roughly a 60-second dead time: what you " + "change now shows up about a minute from now.", }), aside({ tag: "What your logs will NOT tell you", warn: true, text: '"More heat before yellow gives an earlier first crack" does not hold in your data — ' + "heat delivered before yellow correlates positively with first-crack time (r = +0.43) " + "because it's partly a proxy for elapsed time; average heat rate shows almost none " + "(r = +0.20). The honest correlates are batch size (+0.67) and time to yellow (+0.59) " + "— both descriptive, neither causal. The taper is a starting shape, not a validated " + "control law: for a real lever, vary only the time of the first heat cut across three " + "batches of the same coffee.", }), h4("Fan, measured causally"), p( `Isolated fan steps with the burner held steady give roughly ${MACHINE.fanEffectCPerMinPer10Pct} ` + "°C/min of RoR per +10% fan. Only 5 clean windows across 4 roasts — but it is the one " + "number here from a controlled comparison rather than a correlation.", ), h4("The plant model"), formula("RoR (°C/min) = 0.0903 × (0.988·H + 0.215·F + 167.0 − BT)"), formula("H = (RoR ÷ 0.0903 − 0.215·F − 167.0 + BT) ÷ 0.988"), p("H = heat %, F = fan %, BT = bean temp °C; above 178 °C add ~+1.4 °C/min for the exotherm."), p( "Treat it as a sanity check on direction and rough magnitude, not a setpoint calculator: " + "it carries a ±5 °C band, a 60-second dead time, was fitted on light roasts of " + "200–226 g, and its fan term's sign is wrong for planning (fitted from closed-loop " + "data — use the −1.3 figure instead). Where the equation and your logs disagree, " + "believe your logs.", ), ); }, }, "sanity-checks": { title: "Sanity checks — what a failure is telling you", targets: [".checks-card .dock-card-head h2"], render() { return frag( p( "Four checks stand between an arithmetically valid plan and a roastable one. A fail " + "sends you back to the ledger — never to the machine.", ), buildTable({ cols: [ { label: "Check", key: "check" }, { label: "Compute", key: "compute" }, { label: "Should be", key: "should", num: true }, { label: "What a failure means", key: "meaning" }, ], rows: [ { check: "Drying share", compute: "yellow ÷ drop", should: `${SANITY_BANDS.drying.lo}–${SANITY_BANDS.drying.hi}%`, meaning: "High → yellow is late and you are spending the roast drying instead of " + "browning; Maillard gets starved. Low → you rushed to yellow and the internal " + "pressure never built.", }, { check: "Maillard share", compute: "maillard ÷ drop", should: `${SANITY_BANDS.maillard.lo}–${SANITY_BANDS.maillard.hi}%`, meaning: "Low → too small a browning window for sweetness and complexity to develop. " + "Usually the mirror image of a drying share that ran long.", }, { check: "Development ratio", compute: "dev ÷ drop", should: `${SANITY_BANDS.dtr.lo}–${SANITY_BANDS.dtr.hi}% (light: 12–17)`, meaning: "Below 12% → likely under-developed whatever the clock says. Expect sharp, " + "astringent acidity with no fruit behind it.", }, { check: "Development ceiling", compute: "dev", should: "< 4:30", meaning: "Over 4:30 mutes even a dark roast. There is nothing useful on the other side of that line.", }, ], }), aside({ tag: "Worked check — when modifiers stack", text: "Bourbon anaerobic natural, light: anchor 8:45, base 1:30, natural −0:20, anaerobic " + "−0:10 → development 1:00, DTR 10.3% — below the 12% floor. When stacked modifiers " + "push you out of band, drop the smaller one rather than overriding the check.", }), aside({ tag: "Your own history runs outside these bands — read before dismissing", warn: true, text: "Across your 14 logged roasts the measured shares are drying 65%, Maillard 25%, DTR " + "10.4% — all outside the bands, all in the same direction. Drying and Maillard are " + "partly an artifact: Artisan computes the dry end from a temperature threshold, not " + "an observed colour change, which inflates drying and deflates Maillard in exactly " + "this pattern — call yellow by eye and smell for the next three batches before " + "concluding your roasts are structurally wrong. DTR is not an artifact: both first " + "crack and drop are marked events. Median development 1:00 is defensible for the " + "naturals that dominate your logs, but 0:45–0:52 is below the floor for any roast " + "level; if those batches cupped sharp or thin, that is the first number to move.", }), ); }, }, };