diff --git a/public/js/field-help-content.js b/public/js/field-help-content.js
new file mode 100644
index 0000000..4b10201
--- /dev/null
+++ b/public/js/field-help-content.js
@@ -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.",
+ }),
+ );
+ },
+ },
+};
diff --git a/public/js/field-help.js b/public/js/field-help.js
new file mode 100644
index 0000000..55a584b
--- /dev/null
+++ b/public/js/field-help.js
@@ -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