Test and deploy / test-and-deploy (push) Successful in 1m26s
Replaces the flat additive batch-size correction with a per-user multiplicative pace factor learned from each account's own logged roasts (shared/learn.js, GET /api/machine-profile), plus a lot-scoped "last refine" auto-suggestion. Also fixes the reference data being framed as "your own roasts" on a now-multi-user product, and reclassifies the drying/Maillard sanity checks as informational since they're algebraically derived from the DTR check rather than independent (yellow = 0.56 x first crack, not entered separately). Bug fixes found by an adversarial Opus review of the first pass: - Unicode minus sign (U+2212) broke duration parsing against the app's own generated refine-suggestion text - Printed sanity-checks table still showed a bare pass/fail glyph for the now-informational drying/Maillard rows - Printed time-ledger box didn't show the pace multiplication step, so it stopped reconciling by hand once pace != 1 - Field 6.4 (manual batch correction) was double-counted: excluded from the learned-pace fit but added back after the multiplication - Learned pace had no outlier rejection or hard clamp - computeLedger had no test coverage - Batch-size help copy overstated what the pace factor models (it's a single blanket ratio, not conditioned on batch weight) A follow-up Opus pass also caught the per-user profile cache surviving logout/account-switch in a shared browser; fixed by sweeping it alongside the existing plan-draft cleanup. Co-Authored-By: Claude Sonnet 5 <[email protected]>
93 lines
3.7 KiB
JavaScript
93 lines
3.7 KiB
JavaScript
// 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, getTempUnit, getMachineProfile }) {
|
|
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(), getTempUnit?.(), getMachineProfile?.()));
|
|
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";
|
|
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 <label> elements — stop the click from also
|
|
// focusing/toggling the label's control (radio chips especially).
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openTopic(key, btn);
|
|
});
|
|
return btn;
|
|
}
|
|
|
|
for (const [key, topic] of Object.entries(HELP_TOPICS)) {
|
|
const containers = [];
|
|
for (const sel of topic.targets ?? [])
|
|
containers.push(...document.querySelectorAll(`#plan-form ${sel}`));
|
|
if (topic.place) containers.push(...topic.place());
|
|
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
|
|
// the browser's own click-focuses-the-target step, which can otherwise land after — and
|
|
// override — a synchronous focus() call made while handling that same click.
|
|
function returnFocus() {
|
|
const trigger = lastTrigger;
|
|
setTimeout(() => trigger?.focus(), 0);
|
|
}
|
|
|
|
dialog.querySelector("[data-close-help]")?.addEventListener("click", () => {
|
|
dialog.close();
|
|
returnFocus();
|
|
});
|
|
dialog.addEventListener("click", (e) => {
|
|
if (e.target === dialog) {
|
|
dialog.close();
|
|
returnFocus();
|
|
}
|
|
});
|
|
// Covers every other close path (Esc, or dialog.close() called directly) — native <dialog>
|
|
// already restores focus to the pre-showModal() element on close in current browsers; this
|
|
// listener is the explicit fallback for engines where that isn't reliable.
|
|
dialog.addEventListener("close", returnFocus);
|
|
}
|