From 84fb396c7dc515b2e9a650ee5a057f40bbf8d30b Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Fri, 31 Jul 2026 06:36:27 -0400 Subject: [PATCH] Add field help dialog with cultivar/process reference notes 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 --- Makefile | 24 + public/app.css | 200 +++++++ public/index.html | 18 + public/js/field-help-content.js | 939 ++++++++++++++++++++++++++++++++ public/js/field-help.js | 72 +++ public/js/main.js | 27 +- public/sw.js | 2 + shared/reference-data.js | 197 +++++-- 8 files changed, 1443 insertions(+), 36 deletions(-) create mode 100644 Makefile create mode 100644 public/js/field-help-content.js create mode 100644 public/js/field-help.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2559a2d --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +PORT ?= 8090 + +.PHONY: all install start dev clean restart + +all: install + +## Install dependencies +install: + npm install + +## Start the server in production mode +start: + PORT=$(PORT) npm start + +## Start the server in development mode (auto-restart on file changes) +dev: + PORT=$(PORT) npm run dev + +## Remove node_modules and lockfile +clean: + rm -rf node_modules package-lock.json + +## Restart the server +restart: clean install start diff --git a/public/app.css b/public/app.css index 5811b42..d768cc2 100644 --- a/public/app.css +++ b/public/app.css @@ -430,6 +430,176 @@ label.filebtn input[type="file"] { background: var(--surface-sunken); } +/* ─── Field-help dialog ───────────────────────────────────────────────── */ +.help-dialog { + padding: 0; + border: none; + border-radius: var(--radius-lg); + background: var(--surface-raised); + box-shadow: var(--shadow-pop); + width: min(680px, 94vw); + max-height: min(82vh, 780px); +} +.help-dialog::backdrop { + background: rgba(27, 22, 20, 0.32); +} +.help-card { + display: flex; + flex-direction: column; + max-height: inherit; +} +.help-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--line); +} +.help-kicker { + display: block; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ember); +} +.help-head h3 { + font-size: 15px; + font-weight: 700; + margin-top: 2px; +} +.help-body { + padding: 14px 20px; + overflow-y: auto; + font-size: 12.5px; + line-height: 1.55; + color: var(--ink-2); +} +.help-body h4 { + margin: 14px 0 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ink-2); +} +.help-body h4:first-child { + margin-top: 0; +} +.help-body p { + margin: 0 0 8px; +} +.help-body dl { + margin: 0; +} +.help-table-wrap { + overflow-x: auto; + margin: 6px 0 10px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} +.help-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.help-table th { + text-align: left; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--ink-3); + padding: 7px 10px 5px; + border-bottom: 1px solid var(--line-strong); + background: var(--surface); + white-space: nowrap; +} +.help-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +.help-table tbody tr:last-child td { + border-bottom: none; +} +.help-table td.num { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + white-space: nowrap; + font-size: 11.5px; +} +.help-table tr.grp td { + background: var(--ember-soft); + color: var(--ember-strong); + font-weight: 700; + font-size: 11px; +} +.help-table tr.current td { + background: var(--ember-soft); + box-shadow: inset 3px 0 0 var(--ember); +} +.help-aside { + border-left: 3px solid var(--ember); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + background: var(--ember-soft); + padding: 10px 12px; + margin: 10px 0; +} +.help-aside .tag { + display: block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ember-strong); + margin-bottom: 4px; +} +.help-aside p:last-child { + margin-bottom: 0; +} +.help-aside.warn { + border-left-color: var(--warn); + background: var(--warn-bg); +} +.help-aside.warn .tag { + color: var(--warn); +} +.help-formula { + font-family: var(--font-mono); + font-size: 12px; + text-align: center; + background: var(--surface-sunken); + border-radius: var(--radius-sm); + padding: 8px 10px; + margin: 8px 0; + overflow-x: auto; + white-space: nowrap; +} +.help-attrib { + margin: 0; + padding: 10px 20px 14px; + border-top: 1px solid var(--line); + font-size: 11px; + color: var(--ink-3); +} +@media (max-width: 720px) { + .help-dialog { + width: 100vw; + max-width: none; + margin: auto 0 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + max-height: 88vh; + } +} +@media print { + .help-btn, + .help-dialog { + display: none !important; + } +} + /* ─── Workspace layout ────────────────────────────────────────────────── */ .workspace-grid { display: grid; @@ -592,6 +762,36 @@ label.filebtn input[type="file"] { align-items: center; gap: 4px; } +.help-btn { + flex: 0 0 auto; + width: 15px; + height: 15px; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 50%; + background: none; + color: var(--ink-3); + font: 600 9.5px/1 var(--font-ui); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.help-btn:hover, +.help-btn:focus-visible { + border-color: var(--ember); + color: var(--ember); + background: var(--ember-soft); +} +.help-btn:focus-visible { + outline: 2px solid var(--ember); + outline-offset: 1px; +} +.subhead .help-btn, +.dock-card-head .help-btn { + margin-left: 6px; + vertical-align: 1px; +} /* ─── Teaching layer ───────────────────────────────────────────────────── Replaces the old per-field "?" tooltip system: a collapsible "why" panel per section, a permanent one-line note under only the causally-loaded diff --git a/public/index.html b/public/index.html index ea95e5a..df3c071 100644 --- a/public/index.html +++ b/public/index.html @@ -348,6 +348,24 @@ + +
+
+
+ Field guide +

+
+ +
+
+

+ Timing framework: Rob Hoos, Cultivars: A Practical Guide for Coffee Roasters. + Temperatures, bands and machine settings: your own logged roasts on the Hottop + KN-8828B-2K+ — they transfer to no other machine. +

+
+
+
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