Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
1151 lines
48 KiB
JavaScript
1151 lines
48 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";
|
||
|
||
// ---- 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",
|
||
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.",
|
||
),
|
||
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(_plan, unit, profile) {
|
||
const pace = profile?.pace;
|
||
const paceText =
|
||
pace?.source === "learned"
|
||
? `Your own account is currently running at ${pace.value.toFixed(2)}× the reference ` +
|
||
`pace (learned from ${pace.n} of your logged roasts) — that factor scales your ` +
|
||
"whole first-crack line by how fast your roasts have actually run overall. It's a " +
|
||
"single blanket ratio across your history, not something that adjusts itself by " +
|
||
"batch weight, so this table's specific charge/heat/fan settings are still worth " +
|
||
"reading as the reference operator's own notes, not something the learned pace " +
|
||
"stands in for."
|
||
: "This app now applies a learned per-user pace factor to first crack instead of a " +
|
||
"flat added-seconds guess (below, it defaults to 1× until you've logged at least " +
|
||
"two roasts with an actual first-crack time). That factor tracks how fast your " +
|
||
"machine runs overall — it isn't conditioned on batch weight — so the specific " +
|
||
"settings in this table are still the reference operator's own charge/heat/fan " +
|
||
"choices, not derived from your machine.";
|
||
return frag(
|
||
p(
|
||
"Batch size was the strongest correlate with first-crack time in the reference " +
|
||
"operator's own logged data (r = +0.67): a 226 g batch landed first crack roughly " +
|
||
"1:30–2:00 later than 200 g at similar settings — a ~13% weight increase stretching " +
|
||
"first crack ~40%, a scaling relationship a flat added-seconds guess can't fully " +
|
||
"represent. Field 6.4 below is still that same flat additive guess — you still " +
|
||
"enter it by hand for your own batch, and it's now folded into the line that the " +
|
||
"learned pace factor scales, so it moves by that factor too. What's new is a " +
|
||
"separate learned pace factor (below) that scales your entire first-crack line by " +
|
||
"how fast your account's roasts have actually run — but it's one blanket ratio " +
|
||
"across your whole history, not a per-batch-weight adjustment, so it doesn't replace " +
|
||
"6.4 for batch-to-batch changes.",
|
||
),
|
||
p(paceText),
|
||
buildTable({
|
||
cols: [
|
||
{ label: "Batch", key: "batch" },
|
||
{ label: "Charge probe", num: true, render: (row) => chargeProbeCell(row, unit) },
|
||
{ 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 — proven numbers, learned or reference",
|
||
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(_plan, unit, profile) {
|
||
const t = (c) => convTemp(c, unit);
|
||
const d = (c) => convDelta(c, unit);
|
||
const u = unitLabel(unit);
|
||
const ru = rateUnitLabel(unit);
|
||
const milestoneDefs = [
|
||
{ label: "Charge", ref: MACHINE.charge, learnedKey: "charge", timeSuffix: "", note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." },
|
||
{ label: "Turning point", ref: MACHINE.turningPoint, learnedKey: "tp", timeSuffix: ` @ ${MACHINE.turningPoint.medianTime}`, rangeTimeSuffix: ` @ ${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." },
|
||
{ label: "Yellow", ref: MACHINE.yellow, learnedKey: "yellow", timeSuffix: "", note: "Threshold-derived, not observed. Use as a rough guide and start calling it by eye — grassy smell turns to bread, colour goes straw." },
|
||
{ label: "First crack", ref: MACHINE.firstCrack, learnedKey: "fc", timeSuffix: "", note: "Tight and reliable in the reference log. Usually the single most trustworthy number here." },
|
||
{ label: "Drop (light)", ref: MACHINE.drop, learnedKey: "drop", timeSuffix: "", note: `For darker levels, build the ladder yourself: raise drop ${d(2)}–${d(3)} ${u}, weigh, record — don't extrapolate.` },
|
||
];
|
||
// Each milestone falls back to the reference band independently — a user might have
|
||
// enough of their own First crack readings to trust long before they bother recording
|
||
// Turning point, so this isn't a single all-or-nothing learned/reference switch.
|
||
const rows = milestoneDefs.map(({ label, ref, learnedKey, timeSuffix, rangeTimeSuffix, note }) => {
|
||
const learned = profile?.bands?.[learnedKey];
|
||
const useLearned = learned?.source === "learned";
|
||
const medianC = useLearned ? learned.medianC : ref.medianC;
|
||
const rangeC = useLearned ? learned.rangeC : ref.rangeC;
|
||
return {
|
||
milestone: useLearned ? `${label} (n=${learned.n})` : label,
|
||
median: `${t(medianC)} ${u}${timeSuffix}`,
|
||
range: `${t(rangeC[0])}–${t(rangeC[1])} ${u}${rangeTimeSuffix ?? timeSuffix}`,
|
||
note,
|
||
};
|
||
});
|
||
const anyLearned = profile?.bandsSource === "learned";
|
||
const introText = anyLearned
|
||
? "The rows marked (n=…) below are learned from your own logged roasts on this " +
|
||
"account; the rest still use the reference operator's original Hottop log until " +
|
||
"you've recorded enough actual temperatures of your own. The times and ratios in " +
|
||
"this app port between similar drum roasters; the temperatures never do — they're " +
|
||
"whichever machine and probe actually produced them."
|
||
: "These bands are the reference operator's original 14 Hottop KN-8828B-2K+ roasts — " +
|
||
"not yours, unless you're them. They'll start being replaced by your own account's " +
|
||
"figures automatically once you log actual temperatures at each milestone. The times " +
|
||
"and ratios in this app port between similar drum roasters; the temperatures never do.";
|
||
return frag(
|
||
p(
|
||
`${introText} Reference first crack reads ${t(176)}–${t(187)} ${u}, well below the ` +
|
||
`~${t(196)} ${u} a fast, well-immersed probe would show; that doesn't make it wrong, ` +
|
||
"it makes it that probe's own.",
|
||
),
|
||
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 ${ru}`, key: "band", num: true },
|
||
],
|
||
rows: [
|
||
{ phase: "TP → Yellow", band: `${d(MACHINE.ror.tpToYellow[0])}–${d(MACHINE.ror.tpToYellow[1])}` },
|
||
{ phase: "Yellow → FC", band: `${d(MACHINE.ror.yellowToFc[0])}–${d(MACHINE.ror.yellowToFc[1])}` },
|
||
{ phase: "FC → Drop", band: `${d(MACHINE.ror.fcToDrop[0])}–${d(MACHINE.ror.fcToDrop[1])}` },
|
||
],
|
||
}),
|
||
aside({
|
||
tag: "Worked example — Typica, washed, light, 200 g",
|
||
text:
|
||
"Ledger gives yellow 4:15, first crack 7:30, development 1:30, drop 9:00. Turning " +
|
||
`point lands about 0:55 at ${t(85)} ${u}.`,
|
||
}),
|
||
formula(
|
||
`TP→Yellow: (${t(158)} − ${t(85)}) ${u} ÷ 3.33 min ≈ ${preciseRate(73, 3.33, unit)} ${ru}`,
|
||
`inside ${d(17)}–${d(25)} ✓`,
|
||
),
|
||
formula(
|
||
`Yellow→FC: (${t(183)} − ${t(158)}) ${u} ÷ 3.25 min ≈ ${preciseRate(25, 3.25, unit)} ${ru}`,
|
||
`inside ${d(6)}–${d(10)} ✓`,
|
||
),
|
||
formula(
|
||
`FC→Drop: (${t(190)} − ${t(183)}) ${u} ÷ 1.50 min ≈ ${preciseRate(7, 1.5, unit)} ${ru}`,
|
||
`inside ${d(3)}–${d(9)} ✓ — plan is reachable. Checks: drying 47%, Maillard 36%, DTR 17% — all in band.`,
|
||
),
|
||
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 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(_plan, unit) {
|
||
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", num: true, render: (row) => chargeProbeCell(row, unit) },
|
||
{ 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 the reference operator's own logs do NOT tell you",
|
||
warn: true,
|
||
text:
|
||
'"More heat before yellow gives an earlier first crack" does not hold in the reference ' +
|
||
"operator's 14-roast log — 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 there are batch size " +
|
||
"(+0.67) and time to yellow (+0.59) — both descriptive, neither causal, and specific " +
|
||
"to that one operator's machine. The taper is a starting shape, not a validated " +
|
||
"control law: for a real lever on your own machine, vary only the time of the first " +
|
||
"heat cut across three batches of the same coffee and see what actually moves.",
|
||
}),
|
||
h4("Fan, measured causally"),
|
||
p(
|
||
`On the reference machine, isolated fan steps with the burner held steady gave roughly ` +
|
||
`${MACHINE.fanEffectCPerMinPer10Pct} °C/min of RoR per +10% fan — only 5 clean windows ` +
|
||
"across 4 roasts, but the one number in this reference set from a controlled " +
|
||
"comparison rather than a correlation. Worth re-measuring on your own machine before " +
|
||
"trusting it there.",
|
||
),
|
||
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 the reference " +
|
||
"operator's 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 own logged roasts disagree, believe your logs — this formula is a planning aid " +
|
||
"borrowed from someone else's machine, not a law.",
|
||
),
|
||
);
|
||
},
|
||
},
|
||
|
||
"sanity-checks": {
|
||
title: "Sanity checks — what a failure is telling you",
|
||
targets: [".checks-card .dock-card-head h2"],
|
||
render() {
|
||
return frag(
|
||
p(
|
||
"Two of these are real checks that stand between an arithmetically valid plan and a " +
|
||
"roastable one — a fail sends you back to the ledger, never to the machine. The " +
|
||
"other two (drying share, Maillard share) are shown for context only; see the aside " +
|
||
"below for why.",
|
||
),
|
||
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 (informational)",
|
||
compute: "yellow ÷ drop",
|
||
should: `${SANITY_BANDS.drying.lo}–${SANITY_BANDS.drying.hi}%`,
|
||
meaning:
|
||
"Can't fail independently of DTR (see below) — shown for context. High → " +
|
||
"yellow reads late relative to drop; low → yellow reads early.",
|
||
},
|
||
{
|
||
check: "Maillard share (informational)",
|
||
compute: "maillard ÷ drop",
|
||
should: `${SANITY_BANDS.maillard.lo}–${SANITY_BANDS.maillard.hi}%`,
|
||
meaning:
|
||
"Same derivation as drying share, so the same caveat applies. Mirrors it: " +
|
||
"whichever way drying share leans, this leans the other.",
|
||
},
|
||
{
|
||
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: "Why drying share and Maillard share stopped being pass/fail",
|
||
text:
|
||
"Yellow is never entered independently in this app — it's always derived as " +
|
||
"0.56 × first crack (see Anchor & profile). That means drying% = 0.56 × (A ÷ D) and " +
|
||
"Maillard% = 0.44 × (A ÷ D): both are pure algebra on the DTR check, not separate " +
|
||
"measurements. Whenever DTR passes, the arithmetic guarantees drying lands in " +
|
||
"44.8–49.3% and Maillard in 35.2–38.7% — comfortably inside both bands, every time. " +
|
||
"They used to display green or red checkmarks that implied four independent " +
|
||
"validations when only two ever could fail. They're kept here because the " +
|
||
"percentages themselves are still useful context — just not a verdict.",
|
||
}),
|
||
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: "The reference operator's own history runs outside these bands — read before dismissing",
|
||
warn: true,
|
||
text:
|
||
"Across the reference operator's 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 there: Artisan computes the dry end from " +
|
||
"a temperature threshold, not an observed colour change, which inflates drying and " +
|
||
"deflates Maillard in exactly this pattern — calling yellow by eye and smell is the " +
|
||
"fix, not concluding the roasts were structurally wrong. DTR is not an artifact: both " +
|
||
"first crack and drop are marked events. A median development of 1:00 is defensible " +
|
||
"for the naturals that dominate that log, but 0:45–0:52 is below the floor for any " +
|
||
"roast level; if those particular batches cupped sharp or thin, that's the number " +
|
||
"that should have moved.",
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
|
||
"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(
|
||
"Plot the five milestones — bean temperature against the left axis, rate of rise " +
|
||
"against the right. Draw the plan before the roast, overlay what actually happened " +
|
||
"afterward, so one chart carries both.",
|
||
),
|
||
aside({
|
||
tag: "What a good curve looks like",
|
||
text:
|
||
"The bean-temperature line falls to the turning point in the first minute, climbs " +
|
||
"steeply out of it, then bends progressively flatter all the way to the drop. There " +
|
||
"is no straight segment and no second steepening. If your drawn line needs a kink to " +
|
||
"connect two milestones, the milestones are wrong — not the coffee.",
|
||
}),
|
||
aside({
|
||
tag: "The rate-of-rise line",
|
||
text:
|
||
"Starts near its peak just after the turning point (17–25 °C/min on a similar drum roaster) and " +
|
||
"declines steadily to somewhere between 3 and 9 °C/min at the drop. It never touches " +
|
||
"zero and never turns back up. Watch the region just after first crack in particular: " +
|
||
"the exotherm gives you free heat for a few seconds and then withdraws it, and that " +
|
||
"hand-off is where a crash appears.",
|
||
}),
|
||
);
|
||
},
|
||
},
|
||
};
|