Files
snowspeederandClaude Sonnet 5 2237c199c1
Test and deploy / test-and-deploy (push) Successful in 1m26s
Add per-user learned machine profile; fix methodology honesty issues
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]>
2026-07-31 12:40:03 -04:00

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);
}