Fix field-help review findings (Fable review + Opus verification)
Test and deploy / test-and-deploy (push) Successful in 1m25s

Highest severity: injected "?" buttons were hijacking the implicit
<label> association on 14 fields, so clicking the label text opened
the help dialog instead of focusing the input and screen readers lost
the input's accessible name. ensureLabelAssociation() now gives each
affected input an explicit id/for pairing before the button lands.

Also: milestones-ror/actuators topic placement is now text-matched
with a warning fallback instead of blind array indexing; every topic
warns if it resolves zero containers; dialog temperatures (milestone
table, RoR budget, worked example, both charge-setting tables) now
respect the °C/°F toggle instead of being stuck in °C; the print
worksheet's duplicate curve chart was missing the data-tempc
attributes its live counterpart had, so only its title/caption (not
its axis numbers) converted; the "?" trigger's touch target grew from
15px to a WCAG-conformant ~25px without changing the visual dot; two
genuine coverage gaps closed (a roast-log checklist topic, a
curve-reading topic); a worked feasibility example was restored; two
claims that didn't trace back to the source worksheet were corrected
(an invented "±15 seconds" sweet-spot tolerance, a wrong
moisture-to-time mapping); three cultivar cells regained detail lost
in the original transcription; a data-transcription footnote no
longer leaks into an unrelated topic; checklist items keep their list
semantics under VoiceOver.

Co-Authored-By: Fable <[email protected]>
Co-Authored-By: Opus <[email protected]>
This commit is contained in:
2026-07-31 07:17:44 -04:00
co-authored by Fable
parent 84fb396c7d
commit 0a0356b86e
7 changed files with 256 additions and 53 deletions
+178 -33
View File
@@ -81,6 +81,18 @@ function glossary(pairs) {
return dl;
}
function checklist(items) {
const ul = document.createElement("ul");
ul.className = "help-checklist";
ul.setAttribute("role", "list"); // list-style:none strips list semantics in Safari/VoiceOver
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
ul.append(li);
}
return ul;
}
/** cols: [{label, key, num?, render?(row)}]. rows: plain objects, or {__group: "label"} marker
* rows for a group header spanning the full width. isCurrent(row) marks tr.current. */
function buildTable({ cols, rows, isCurrent }) {
@@ -129,6 +141,34 @@ function buildTable({ cols, rows, isCurrent }) {
}
const cToF = (c) => Math.round((c * 9) / 5 + 32);
// Dialog temps respect the Settings & sync °C/°F toggle, same as the live form — `unit` is
// passed in from field-help.js at render time (see initFieldHelp's getTempUnit callback).
function convTemp(c, unit) {
return unit === "F" ? cToF(c) : Math.round(c);
}
// For a SPAN of temperature (a rate's numerator, a band width) rather than an absolute point —
// °F spans scale by 9/5 with no +32 offset. Converting the raw span (not two already-rounded
// absolute conversions subtracted from each other) avoids compounding rounding error.
function convDelta(c, unit) {
return unit === "F" ? Math.round((c * 9) / 5) : Math.round(c);
}
function rateUnitLabel(unit) {
return unit === "F" ? "°F/min" : "°C/min";
}
// Rate = a temperature span over time — convert the precise span first (73°C, not two
// independently-rounded absolute conversions subtracted), then divide, so worked-example
// results don't drift from compounded rounding.
function preciseRate(deltaC, minutes, unit) {
const delta = unit === "F" ? (deltaC * 9) / 5 : deltaC;
return (delta / minutes).toFixed(1);
}
function unitLabel(unit) {
return unit === "F" ? "°F" : "°C";
}
/** "Charge probe" column for CHARGE_SETTINGS rows: unit-aware "lohi °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 23 °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 176187 °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 (1725 °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.",
}),
);
},
},
};
+23 -3
View File
@@ -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 <label>
// without an explicit `for` labels its FIRST labelable descendant in tree order — so an
// unlabeled wrapping <label> would silently make the help button, not the input, the
// label's control. Give the input an explicit id/for pairing before that can happen; a
// no-op wherever the container isn't inside a <label> or is already explicitly paired.
function ensureLabelAssociation(container) {
const label = container.closest("label");
if (!label || label.hasAttribute("for")) return;
const control = label.querySelector("input, select, textarea");
if (!control) return;
if (!control.id)
control.id = `fh-${(control.name || `${Math.random()}`).replace(/[^\w-]/g, "-")}`;
label.setAttribute("for", control.id);
}
function makeButton(key, title) {
const btn = document.createElement("button");
btn.type = "button";
@@ -44,7 +59,12 @@ export function initFieldHelp({ getPlan }) {
for (const sel of topic.targets ?? [])
containers.push(...document.querySelectorAll(`#plan-form ${sel}`));
if (topic.place) containers.push(...topic.place());
for (const container of containers) container.append(makeButton(key, topic.title));
if (containers.length === 0)
console.warn(`field-help: topic "${key}" resolved zero containers — its "?" button is missing.`);
for (const container of containers) {
ensureLabelAssociation(container);
container.append(makeButton(key, topic.title));
}
}
// Deferred: a click that closes the dialog (the ✕ button, or a backdrop click) also runs
+1 -1
View File
@@ -1034,7 +1034,7 @@ async function init() {
renderActuators();
wireCultivarDatalist();
wireWhyPanels();
initFieldHelp({ getPlan: () => state.plan });
initFieldHelp({ getPlan: () => state.plan, getTempUnit: () => tempUnit });
renderBandRanges();
renderFormFromPlan();
wireForm();