Add field help dialog with cultivar/process reference notes
Test and deploy / test-and-deploy (push) Successful in 1m32s
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:
@@ -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 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.",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user