diff --git a/.gitignore b/.gitignore index 6dbcd7a..1a3b415 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ data/ *.log .DS_Store dev-harness.mjs +.scratch/ diff --git a/public/app.css b/public/app.css index d768cc2..3d4c1f7 100644 --- a/public/app.css +++ b/public/app.css @@ -493,6 +493,28 @@ label.filebtn input[type="file"] { .help-body dl { margin: 0; } +.help-checklist { + list-style: none; + margin: 0 0 10px; + padding: 0; + display: flex; + flex-direction: column; + gap: 5px; +} +.help-checklist li { + position: relative; + padding-left: 20px; +} +.help-checklist li::before { + content: ""; + position: absolute; + left: 0; + top: 5px; + width: 10px; + height: 10px; + border: 1.5px solid var(--line-strong); + border-radius: 2px; +} .help-table-wrap { overflow-x: auto; margin: 6px 0 10px; @@ -763,6 +785,7 @@ label.filebtn input[type="file"] { gap: 4px; } .help-btn { + position: relative; flex: 0 0 auto; width: 15px; height: 15px; @@ -777,6 +800,14 @@ label.filebtn input[type="file"] { align-items: center; justify-content: center; } +/* Keeps the 15px visual dot but gives touch/mouse a ~25px target (WCAG's 24px minimum) — + matters on a mobile PWA. Kept modest rather than the fuller 44px so it doesn't swallow + clicks meant for the label text 4px away or the input just below. */ +.help-btn::after { + content: ""; + position: absolute; + inset: -5px; +} .help-btn:hover, .help-btn:focus-visible { border-color: var(--ember); @@ -788,7 +819,8 @@ label.filebtn input[type="file"] { outline-offset: 1px; } .subhead .help-btn, -.dock-card-head .help-btn { +.dock-card-head .help-btn, +.panel-head h2 .help-btn { margin-left: 6px; vertical-align: 1px; } diff --git a/public/index.html b/public/index.html index df3c071..8ecfde5 100644 --- a/public/index.html +++ b/public/index.html @@ -2248,15 +2248,15 @@ - - 60 - 80 - 100 - 120 - 140 - 160 - 180 - 200 + + 60 + 80 + 100 + 120 + 140 + 160 + 180 + 200 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 --------------------------------------------------------------- @@ -152,11 +192,21 @@ export const HELP_TOPICS = { { 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)); + 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; }, }, @@ -275,6 +325,10 @@ export const HELP_TOPICS = { '"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" }, @@ -651,9 +705,10 @@ export const HELP_TOPICS = { ]; 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.", + "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: [ @@ -674,11 +729,11 @@ export const HELP_TOPICS = { 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.", + "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", @@ -700,7 +755,7 @@ export const HELP_TOPICS = { const label = input?.closest(".ledger-row")?.querySelector(".l-label"); return label ? [label] : []; }, - render() { + render(_plan, unit) { return frag( p( "Batch size is the strongest correlate with first-crack time in your logged data " + @@ -712,7 +767,7 @@ export const HELP_TOPICS = { buildTable({ cols: [ { label: "Batch", key: "batch" }, - { label: "Charge probe", key: "chargeC", num: true }, + { 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" }, @@ -733,24 +788,30 @@ export const HELP_TOPICS = { title: "Milestones & the RoR budget — your machine's proven numbers", targets: [], place() { - const el = document.querySelectorAll("#sec-machine .subhead")[0]; - return el ? [el] : []; + 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() { + render(_plan, unit) { + const t = (c) => convTemp(c, unit); + const d = (c) => convDelta(c, unit); + const u = unitLabel(unit); + const ru = rateUnitLabel(unit); 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." }, + { milestone: "Charge", median: `${t(MACHINE.charge.medianC)} ${u}`, range: `${t(MACHINE.charge.rangeC[0])}–${t(MACHINE.charge.rangeC[1])} ${u}`, note: "The probe reading when beans go in, i.e. your preheat. Higher for larger batches." }, + { milestone: "Turning point", median: `${t(MACHINE.turningPoint.medianC)} ${u} @ ${MACHINE.turningPoint.medianTime}`, range: `${t(MACHINE.turningPoint.rangeC[0])}–${t(MACHINE.turningPoint.rangeC[1])} ${u} @ ${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: `${t(MACHINE.yellow.medianC)} ${u}`, range: `${t(MACHINE.yellow.rangeC[0])}–${t(MACHINE.yellow.rangeC[1])} ${u}`, 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: `${t(MACHINE.firstCrack.medianC)} ${u}`, range: `${t(MACHINE.firstCrack.rangeC[0])}–${t(MACHINE.firstCrack.rangeC[1])} ${u}`, note: "Tight and reliable across all your lots. This is your most trustworthy single number." }, + { milestone: "Drop (light)", median: `${t(MACHINE.drop.medianC)} ${u}`, range: `${t(MACHINE.drop.rangeC[0])}–${t(MACHINE.drop.rangeC[1])} ${u}`, note: `All your logged roasts are light. For darker levels, build the ladder yourself: raise drop ${d(2)}–${d(3)} ${u}, 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.", + `Your probe reads first crack at ${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 yours.", ), buildTable({ cols: [ @@ -768,14 +829,32 @@ export const HELP_TOPICS = { buildTable({ cols: [ { label: "Phase", key: "phase" }, - { label: "Machine-proven °C/min", key: "band", num: true }, + { label: `Machine-proven ${ru}`, 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]}` }, + { 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"], @@ -796,10 +875,13 @@ export const HELP_TOPICS = { title: "Heat & fan — the taper, and what actually moves first crack", targets: [], place() { - const el = document.querySelectorAll("#sec-machine .subhead")[1]; - return el ? [el] : []; + 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() { + render(_plan, unit) { return frag( buildTable({ cols: [ @@ -814,7 +896,7 @@ export const HELP_TOPICS = { buildTable({ cols: [ { label: "Batch", key: "batch" }, - { label: "Charge probe", key: "chargeC", num: true }, + { 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" }, @@ -936,4 +1018,67 @@ export const HELP_TOPICS = { ); }, }, + + "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 your machine) 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.", + }), + ); + }, + }, }; diff --git a/public/js/field-help.js b/public/js/field-help.js index 55a584b..9346ae1 100644 --- a/public/js/field-help.js +++ b/public/js/field-help.js @@ -4,7 +4,7 @@ import { HELP_TOPICS } from "./field-help-content.js"; -export function initFieldHelp({ getPlan }) { +export function initFieldHelp({ getPlan, getTempUnit }) { const dialog = document.getElementById("field-help-dialog"); if (!dialog) return; const titleEl = document.getElementById("help-dialog-title"); @@ -16,11 +16,26 @@ export function initFieldHelp({ getPlan }) { if (!topic) return; lastTrigger = trigger; titleEl.textContent = topic.title; - bodyEl.replaceChildren(topic.render(getPlan())); + bodyEl.replaceChildren(topic.render(getPlan(), getTempUnit?.())); bodyEl.scrollTop = 0; dialog.showModal(); } + // A button appended into a `.field-label` span is a labelable element, and a