Add field help dialog with cultivar/process reference notes
Test and deploy / test-and-deploy (push) Successful in 1m32s

Adds a help-dialog UI (field-help.js/field-help-content.js) surfacing
per-field guidance, expands cultivar and process reference data with
cup notes, sweet-spot width, and watch-fors, and adds a Makefile for
common npm tasks.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-07-31 06:37:33 -04:00
co-authored by Claude Sonnet 5
parent df5c4aef48
commit 84fb396c7d
8 changed files with 1443 additions and 36 deletions
+939
View File
@@ -0,0 +1,939 @@
// 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 2025% 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:309: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:302: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:302: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:455: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 1530 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.511.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:302: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 23 °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 176187 °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 35% " +
"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 " +
"200226 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: 1217)`,
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:450:52 is below the floor for any roast " +
"level; if those batches cupped sharp or thin, that is the first number to move.",
}),
);
},
},
};
+72
View File
@@ -0,0 +1,72 @@
// Field-level "?" reference dialogs. Sibling to why-panels.js: why-panels are always-visible
// section prose ("why this works"); this is an on-demand, per-field reference library ("what are
// my options"). See FIELD_HELP_SPEC.md (deleted after implementation) for the design rationale.
import { HELP_TOPICS } from "./field-help-content.js";
export function initFieldHelp({ getPlan }) {
const dialog = document.getElementById("field-help-dialog");
if (!dialog) return;
const titleEl = document.getElementById("help-dialog-title");
const bodyEl = document.getElementById("help-dialog-body");
let lastTrigger = null;
function openTopic(key, trigger) {
const topic = HELP_TOPICS[key];
if (!topic) return;
lastTrigger = trigger;
titleEl.textContent = topic.title;
bodyEl.replaceChildren(topic.render(getPlan()));
bodyEl.scrollTop = 0;
dialog.showModal();
}
function makeButton(key, title) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "help-btn";
btn.dataset.helpTopic = key;
btn.setAttribute("aria-label", `Field guide: ${title.split(" — ")[0]}`);
btn.setAttribute("aria-haspopup", "dialog");
btn.textContent = "?";
// Several triggers live inside <label> elements — stop the click from also
// focusing/toggling the label's control (radio chips especially).
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openTopic(key, btn);
});
return btn;
}
for (const [key, topic] of Object.entries(HELP_TOPICS)) {
const containers = [];
for (const sel of topic.targets ?? [])
containers.push(...document.querySelectorAll(`#plan-form ${sel}`));
if (topic.place) containers.push(...topic.place());
for (const container of containers) container.append(makeButton(key, topic.title));
}
// Deferred: a click that closes the dialog (the ✕ button, or a backdrop click) also runs
// the browser's own click-focuses-the-target step, which can otherwise land after — and
// override — a synchronous focus() call made while handling that same click.
function returnFocus() {
const trigger = lastTrigger;
setTimeout(() => trigger?.focus(), 0);
}
dialog.querySelector("[data-close-help]")?.addEventListener("click", () => {
dialog.close();
returnFocus();
});
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
dialog.close();
returnFocus();
}
});
// Covers every other close path (Esc, or dialog.close() called directly) — native <dialog>
// already restores focus to the pre-showModal() element on close in current browsers; this
// listener is the explicit fallback for engines where that isn't reliable.
dialog.addEventListener("close", returnFocus);
}
+26 -1
View File
@@ -5,7 +5,12 @@ import {
formatSigned,
parseRangeMidpoint,
} from "/shared/time.js?v=__ASSET_VERSION__";
import { CULTIVARS, findCultivar } from "/shared/reference-data.js?v=__ASSET_VERSION__";
import {
CULTIVARS,
findCultivar,
findProcess,
findRoastLevel,
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
@@ -14,6 +19,7 @@ import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_PREFIX = "roastPlannerPlan.v2";
@@ -597,6 +603,22 @@ function cultivarAutofill(name) {
renderFormFromPlan();
}
// Mirrors cultivarAutofill(): picking a process/roast level should feed the same reference
// numbers into the ledger that selecting a cultivar does, not just record the choice as text.
function processAutofill(key) {
const row = findProcess(key);
if (!row) return;
setValueForName("3.2", formatSigned(row.devModS));
renderFormFromPlan();
}
function roastLevelAutofill(key) {
const row = findRoastLevel(key);
if (!row) return;
setValueForName("4.3", formatDuration(parseRangeMidpoint(row.devBand)));
renderFormFromPlan();
}
function wireCultivarDatalist() {
const list = document.getElementById("cultivar-list");
list.replaceChildren(
@@ -619,6 +641,8 @@ function wireForm() {
);
if (el.name === "1.1") cultivarAutofill(el.value);
if (el.name === "2.1") updateBlendVisibility(el.value);
if (el.name === "3.1") processAutofill(el.value);
if (el.name === "4.1") roastLevelAutofill(el.value);
if (el.name.startsWith("blendComponents.")) updateBlendTotal();
recompute();
});
@@ -1010,6 +1034,7 @@ async function init() {
renderActuators();
wireCultivarDatalist();
wireWhyPanels();
initFieldHelp({ getPlan: () => state.plan });
renderBandRanges();
renderFormFromPlan();
wireForm();