859 lines
35 KiB
JavaScript
859 lines
35 KiB
JavaScript
// 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?v=__ASSET_VERSION__";
|
||
|
||
// ---- 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;
|
||
}
|
||
|
||
function checklist(items) {
|
||
const ul = document.createElement("ul");
|
||
ul.className = "help-checklist";
|
||
ul.setAttribute("role", "list"); // list-style:none strips list semantics in Safari/VoiceOver
|
||
for (const item of items) {
|
||
const li = document.createElement("li");
|
||
li.textContent = item;
|
||
ul.append(li);
|
||
}
|
||
return ul;
|
||
}
|
||
|
||
/** 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);
|
||
// Dialog temps respect the Settings & sync °C/°F toggle, same as the live form — `unit` is
|
||
// passed in from field-help.js at render time (see initFieldHelp's getTempUnit callback).
|
||
function convTemp(c, unit) {
|
||
return unit === "F" ? cToF(c) : Math.round(c);
|
||
}
|
||
// For a SPAN of temperature (a rate's numerator, a band width) rather than an absolute point —
|
||
// °F spans scale by 9/5 with no +32 offset. Converting the raw span (not two already-rounded
|
||
// absolute conversions subtracted from each other) avoids compounding rounding error.
|
||
function convDelta(c, unit) {
|
||
return unit === "F" ? Math.round((c * 9) / 5) : Math.round(c);
|
||
}
|
||
function rateUnitLabel(unit) {
|
||
return unit === "F" ? "°F/min" : "°C/min";
|
||
}
|
||
// Rate = a temperature span over time — convert the precise span first (73°C, not two
|
||
// independently-rounded absolute conversions subtracted), then divide, so worked-example
|
||
// results don't drift from compounded rounding.
|
||
function preciseRate(deltaC, minutes, unit) {
|
||
const delta = unit === "F" ? (deltaC * 9) / 5 : deltaC;
|
||
return (delta / minutes).toFixed(1);
|
||
}
|
||
function unitLabel(unit) {
|
||
return unit === "F" ? "°F" : "°C";
|
||
}
|
||
/** "Charge probe" column for CHARGE_SETTINGS rows: unit-aware "lo–hi °X". */
|
||
function chargeProbeCell(row, unit) {
|
||
return `${convTemp(row.chargeCLo, unit)}–${convTemp(row.chargeCHi, unit)} ${unitLabel(unit)}`;
|
||
}
|
||
|
||
// ---- 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) => {
|
||
const el = document.createElement("span");
|
||
if (c.sweetSpot) {
|
||
const strong = document.createElement("strong");
|
||
strong.textContent = `${c.sweetSpot === "narrow" ? "Narrow" : "Wide"}. `;
|
||
el.append(strong);
|
||
}
|
||
el.append(document.createTextNode(c.watch));
|
||
if (c.sourceNote) {
|
||
const note = document.createElement("small");
|
||
note.style.display = "block";
|
||
note.style.color = "var(--ink-3)";
|
||
note.style.marginTop = "3px";
|
||
note.textContent = c.sourceNote;
|
||
el.append(note);
|
||
}
|
||
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",
|
||
// The After-the-Roast section moved to the Roasts page; this help stays reachable from
|
||
// the ± Refine field, which is the planner-side half of the feedback loop.
|
||
targets: ['label[data-fid="1.6"] .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.",
|
||
),
|
||
p(
|
||
"Cup the same coffee, the same brew, at the same age — a difference across two brew " +
|
||
"methods tells you nothing about the roast.",
|
||
),
|
||
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 the cultivar reads a timing miss quickly — go outside its band and the " +
|
||
"cup shows it; wide means the cultivar tolerates a range, with different points in it " +
|
||
"emphasising different characters rather than simply going wrong. The calls below come " +
|
||
"from the cultivar table's own rows, not a fixed number of seconds.",
|
||
),
|
||
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 a bean-condition observation becomes one signed number on ledger line 3 " +
|
||
"— judgment, rarely more than ±0:15, and only for something you can name and defend. " +
|
||
"Moisture and density don't map to a fixed number of seconds here: they're corrected " +
|
||
"mostly through charge temperature and heat, not through this field (see Moisture, " +
|
||
"Density). Net correction is what's left over after that — never automatic.",
|
||
),
|
||
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 can change roast behavior, but the app never applies machine pace or batch corrections automatically. Field 6.4 is an optional time adjustment you enter from your own observations. Change one variable at a time and compare the cup."));
|
||
},
|
||
},
|
||
|
||
"milestones-ror": {
|
||
title: "Milestones & rate of rise — planning references",
|
||
targets: [],
|
||
place() {
|
||
const subheads = [...document.querySelectorAll("#sec-machine .subhead")];
|
||
const byText = subheads.find((el) => el.textContent.trim().startsWith("Temperatures"));
|
||
if (byText) return [byText];
|
||
console.warn('field-help: "milestones-ror" fell back to positional lookup — subhead text changed?');
|
||
return subheads[0] ? [subheads[0]] : [];
|
||
},
|
||
render() {
|
||
return frag(
|
||
p("Blank temperature fields draw an illustrative curve. Your entered readings take precedence. Roaster selection and historical measurements never adjust the plan."),
|
||
p("Preheat setpoint, environmental temperature and bean-probe temperature are different measurements. Record preheat separately. Mark yellow by color and aroma, and first crack by sustained audible cracking."),
|
||
p("Aim for controlled progression with declining RoR after its recovery peak. The smooth curve is interpolation, not a physical prediction. Phase-average RoR cannot show every brief crash or flick. Inconsistent entered milestones are flagged without rewriting your targets."),
|
||
p("Timing, DTR and turning point alone cannot diagnose cup quality. Evaluate color, aroma, the actual trace and tasting results together.")
|
||
);
|
||
},
|
||
},
|
||
|
||
actuators: {
|
||
title: "Heat & fan — the taper, and what actually moves first crack",
|
||
targets: [],
|
||
place() {
|
||
const subheads = [...document.querySelectorAll("#sec-machine .subhead")];
|
||
const byText = subheads.find((el) => el.textContent.trim().startsWith("Actuator"));
|
||
if (byText) return [byText];
|
||
console.warn('field-help: "actuators" fell back to positional lookup — subhead text changed?');
|
||
return subheads[1] ? [subheads[1]] : [];
|
||
},
|
||
render() {
|
||
return frag(
|
||
p("Enter heat and airflow steps that you can repeat, with the reason for each change. No machine-specific heat/fan percentages or response formula is applied by this planner."),
|
||
p("Use your manufacturer's preheat routine and log the setpoint separately from charge BT. Anticipate the machine's response delay and make controlled changes before excessive momentum builds."),
|
||
p("Fan changes can affect heat transfer as well as exhaust. Their effect depends on the equipment and airflow path. Do not infer a universal effect from another operator's percentages."),
|
||
p("Compare one deliberate change across repeat batches and judge the cup as well as the trace. A correlation in a small roast history is not a validated control law.")
|
||
);
|
||
},
|
||
},
|
||
|
||
"sanity-checks": {
|
||
title: "Sanity checks — what a failure is telling you",
|
||
targets: [".checks-card .dock-card-head h2"],
|
||
render() {
|
||
return frag(
|
||
p("These checks describe a planning range, not a verdict on roast quality. DTR is development divided by total time; a ratio inside 12–20% does not guarantee good coffee, and one outside it does not prove a failure."),
|
||
p("Drying and Maillard shares are informational because the planner derives yellow as 0.56 × first crack. They are not independent measurements."),
|
||
p("For light plans, modifier suggestions below 1:15 are raised to 1:15 with a warning. This is a starting heuristic. Enter a direct development target to override it; your choice remains visible even outside the planning range."),
|
||
p("Check color, aroma, actual RoR, and tasting results together. Longer development may suit a different roast intent; the 4:30 check is a worksheet review threshold, not a universal physical limit.")
|
||
);
|
||
},
|
||
},
|
||
|
||
"roast-log": {
|
||
title: "At the machine — before charging, and during the roast",
|
||
targets: ["#sec-roastlog .panel-head h2"],
|
||
render() {
|
||
return frag(
|
||
p(
|
||
"The Roast Log's why-panel covers recording discipline — observed, not planned. These " +
|
||
"are the mechanical checks that make that discipline possible: two checklists, one for " +
|
||
"before the beans go in, one for while they're roasting.",
|
||
),
|
||
h4("Before charging"),
|
||
checklist([
|
||
"Green weighed and recorded",
|
||
"Preheat soaked at charge temperature, stable",
|
||
"Chaff collector emptied",
|
||
"Cooling tray clear, fan working",
|
||
"Timer and log ready before the beans go in",
|
||
"Machine Plan filled in and visible",
|
||
]),
|
||
h4("During the roast"),
|
||
checklist([
|
||
"Mark observed milestones, not planned ones",
|
||
"Heat changes in small steps — expect ~60 s lag",
|
||
"RoR declining, never flat, never negative",
|
||
"If yellow is off by more than 20 s, adjust and note it",
|
||
"Listen for first crack; if quiet, use smell",
|
||
"Drop on the clock, then handle the beans",
|
||
]),
|
||
);
|
||
},
|
||
},
|
||
|
||
curve: {
|
||
title: "Reading the curve — what good looks like",
|
||
targets: [".curve-card .dock-card-head h2"],
|
||
render() {
|
||
return frag(
|
||
p("The graph labels milestone times and Fahrenheit temperatures directly, with drying, Maillard and development durations. An attached reference trace appears dashed on the live chart."),
|
||
p("The smooth plan line interpolates your milestones; it is not a predicted sensor trace. Blank temperatures are marked illustrative. Enter actual probe references when you have them."),
|
||
p("Aim for controlled progression and declining RoR after the recovery peak. Phase-average RoR alone cannot rule out a brief crash or flick. The graph plots BT, not a separate measured RoR trace.")
|
||
);
|
||
},
|
||
},
|
||
};
|