Initial roast planner webapp: fillable worksheet, live ledger, curve, URL prefill, .alog reference curve
- shared/ ports the worksheet's ledger math, time parsing, and reference tables (cultivars/processes/roast-levels/machine bands) as browser-safe ESM, imported by both the server and the browser so the arithmetic can't drift between them. - server/prefill.js runs a zero-tool Pi Coding Agent SDK turn to extract page facts from a bean product URL, then derives worksheet field IDs deterministically from reference-data.js — the model never invents a first-crack anchor or a modifier. - server/alog.js ports the Python alog_parser.py's format handling, including the tokenizer-based Python-dict-literal-to-JSON conversion the real files need. - public/ is the worksheet reproduced as a live HTML form: worksheet.css is a verbatim copy of the paper worksheet's print CSS, print.js bakes values into the print layout so the same DOM renders both on screen and on paper. - Ledger math verified against both of the paper worksheet's worked examples; alog parser verified against all 14 real logs in ref/roasts/. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side
|
||||
// library directory (e.g. wherever the roastetta skill already downloaded logs).
|
||||
|
||||
export function initAlogPanel({ state, recompute }) {
|
||||
const fileInput = document.getElementById("alog-file");
|
||||
const libraryBtn = document.getElementById("alog-library-refresh");
|
||||
const libraryList = document.getElementById("alog-library-list");
|
||||
const resultEl = document.getElementById("alog-result");
|
||||
|
||||
fileInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
resultEl.innerHTML = "<p>Parsing…</p>";
|
||||
try {
|
||||
const content = await file.text();
|
||||
const res = await fetch("/api/alog", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, content }),
|
||||
});
|
||||
const body = await res.json();
|
||||
applyResult(body);
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
libraryBtn.addEventListener("click", async () => {
|
||||
libraryList.classList.remove("hidden");
|
||||
libraryList.innerHTML = "<li>Loading…</li>";
|
||||
try {
|
||||
const res = await fetch("/api/alog/library");
|
||||
const body = await res.json();
|
||||
if (!body.ok || body.files.length === 0) {
|
||||
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>";
|
||||
return;
|
||||
}
|
||||
libraryList.innerHTML = "";
|
||||
for (const f of body.files) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
|
||||
li.addEventListener("click", async () => {
|
||||
resultEl.innerHTML = "<p>Loading…</p>";
|
||||
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`);
|
||||
applyResult(await r.json());
|
||||
});
|
||||
libraryList.appendChild(li);
|
||||
}
|
||||
} catch (err) {
|
||||
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`;
|
||||
}
|
||||
});
|
||||
|
||||
function applyResult(body) {
|
||||
if (!body.ok) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
|
||||
return;
|
||||
}
|
||||
state.plan.reference = body;
|
||||
recompute();
|
||||
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
|
||||
resultEl.innerHTML = `
|
||||
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} ·
|
||||
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} ·
|
||||
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
|
||||
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(seconds) {
|
||||
if (seconds === null || seconds === undefined) return "—";
|
||||
const s = Math.round(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { FIELD_IDS, blankPlan } from "/shared/fields.js";
|
||||
import { computeLedger } from "/shared/ledger.js";
|
||||
import { formatDuration, formatSigned, parseRangeMidpoint } from "/shared/time.js";
|
||||
import { CULTIVARS, findCultivar } from "/shared/reference-data.js";
|
||||
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js";
|
||||
import { initPrefillPanel } from "./prefill-ui.js";
|
||||
import { initAlogPanel } from "./alog-ui.js";
|
||||
import { initPrint } from "./print.js";
|
||||
|
||||
const FIELD_ID_SET = new Set(FIELD_IDS);
|
||||
const STORAGE_KEY = "roastPlannerPlan.v1";
|
||||
|
||||
export const state = { plan: loadFromStorage() ?? blankPlan() };
|
||||
|
||||
const form = document.getElementById("plan-form");
|
||||
|
||||
// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
|
||||
function getPath(obj, path) {
|
||||
return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||
}
|
||||
function setPath(obj, path, value) {
|
||||
const parts = path.split(".");
|
||||
let node = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (node[parts[i]] === undefined || node[parts[i]] === null) node[parts[i]] = {};
|
||||
node = node[parts[i]];
|
||||
}
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function valueForName(name) {
|
||||
if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? "";
|
||||
const v = getPath(state.plan, name);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
function setValueForName(name, value) {
|
||||
if (FIELD_ID_SET.has(name)) {
|
||||
state.plan.fields[name] = value;
|
||||
} else {
|
||||
setPath(state.plan, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dynamic rows: blend components + actuator schedule
|
||||
function renderBlendRows() {
|
||||
const tbody = document.getElementById("blend-rows");
|
||||
tbody.innerHTML = "";
|
||||
state.plan.blendComponents.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActuatorRows() {
|
||||
const tbody = document.getElementById("actuator-rows");
|
||||
tbody.innerHTML = "";
|
||||
state.plan.actuators.forEach((_, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
|
||||
<td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
|
||||
<td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- populate every control in the form from state.plan
|
||||
export function renderFormFromPlan() {
|
||||
for (const el of form.querySelectorAll("input, select, textarea")) {
|
||||
const name = el.name;
|
||||
if (!name) continue;
|
||||
const value = valueForName(name);
|
||||
if (el.type === "radio") {
|
||||
el.checked = el.value === value;
|
||||
} else if (el.type === "checkbox") {
|
||||
el.checked = Boolean(value);
|
||||
} else {
|
||||
el.value = value ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fmtOut(id, text) {
|
||||
const el = document.querySelector(`[data-out="${id}"]`);
|
||||
if (el) el.textContent = text ?? "—";
|
||||
}
|
||||
|
||||
function renderLedger() {
|
||||
const ledger = computeLedger(state.plan);
|
||||
const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
|
||||
const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));
|
||||
|
||||
fmtOut("l1", d(ledger.lines.l1));
|
||||
fmtOut("l2", ds(ledger.lines.l2));
|
||||
fmtOut("l3", ds(ledger.lines.l3));
|
||||
fmtOut("A", d(ledger.A));
|
||||
fmtOut("yellow", d(ledger.yellow));
|
||||
fmtOut("maillard", d(ledger.maillard));
|
||||
fmtOut("l7", d(ledger.lines.l7));
|
||||
fmtOut("l8", ds(ledger.lines.l8));
|
||||
fmtOut("l9", ds(ledger.lines.l9));
|
||||
fmtOut("C", d(ledger.C));
|
||||
fmtOut("D", d(ledger.D));
|
||||
|
||||
const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`);
|
||||
fmtOut("check-drying", pct(ledger.checks.drying.pct));
|
||||
fmtOut("check-maillard", pct(ledger.checks.maillard.pct));
|
||||
fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
|
||||
fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));
|
||||
|
||||
for (const [key, check] of Object.entries(ledger.checks)) {
|
||||
const cell = document.querySelector(`[data-pass="${key}"]`);
|
||||
if (!cell) continue;
|
||||
cell.classList.remove("pass", "fail", "unknown");
|
||||
cell.classList.add(check.pass === null ? "unknown" : check.pass ? "pass" : "fail");
|
||||
}
|
||||
|
||||
// Box 8 / box 11 derived time columns
|
||||
fmtOut("t-charge", "0:00");
|
||||
fmtOut("t-yellow", d(ledger.yellow));
|
||||
fmtOut("t-fc", d(ledger.A));
|
||||
fmtOut("t-drop", d(ledger.D));
|
||||
fmtOut("pa-charge", "0:00");
|
||||
fmtOut("pa-tp", state.plan.temps.tp.time || "—");
|
||||
fmtOut("pa-yellow", d(ledger.yellow));
|
||||
fmtOut("pa-fc", d(ledger.A));
|
||||
fmtOut("pa-drop", d(ledger.D));
|
||||
|
||||
document.getElementById("back-coffee-name").textContent = state.plan.fields["0.1"] || "";
|
||||
|
||||
return ledger;
|
||||
}
|
||||
|
||||
function renderCurve(ledger) {
|
||||
const planPoints = buildPlanCurve(state.plan, ledger);
|
||||
const planGroup = document.getElementById("plan-curve");
|
||||
planGroup.innerHTML = "";
|
||||
if (planPoints.length > 0) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", pointsToPathD(planPoints));
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#1a1512");
|
||||
path.setAttribute("stroke-width", "1.6");
|
||||
planGroup.appendChild(path);
|
||||
for (const p of planPoints) {
|
||||
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
c.setAttribute("cx", tToX(p.timeS).toFixed(1));
|
||||
c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
|
||||
c.setAttribute("r", "3.2");
|
||||
c.setAttribute("fill", "#1a1512");
|
||||
planGroup.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
const ref = state.plan.reference;
|
||||
const refGroup = document.getElementById("ref-curve");
|
||||
refGroup.innerHTML = "";
|
||||
if (ref?.curve?.length) {
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const d = ref.curve
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`)
|
||||
.join(" ");
|
||||
path.setAttribute("d", d);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "#5a5048");
|
||||
path.setAttribute("stroke-width", "1.3");
|
||||
path.setAttribute("stroke-dasharray", "4 3");
|
||||
path.setAttribute("opacity", ".65");
|
||||
refGroup.appendChild(path);
|
||||
for (const m of ref.milestones ?? []) {
|
||||
if (m.tempC === null) continue;
|
||||
const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||
c.setAttribute("cx", tToX(m.timeS).toFixed(1));
|
||||
c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
|
||||
c.setAttribute("r", "2.6");
|
||||
c.setAttribute("fill", "none");
|
||||
c.setAttribute("stroke", "#5a5048");
|
||||
c.setAttribute("stroke-width", "1.2");
|
||||
refGroup.appendChild(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function recompute() {
|
||||
const ledger = renderLedger();
|
||||
renderCurve(ledger);
|
||||
autosave();
|
||||
}
|
||||
|
||||
function autosave() {
|
||||
clearTimeout(autosave._t);
|
||||
autosave._t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.plan));
|
||||
document.getElementById("autosave-status").textContent = `saved ${new Date().toLocaleTimeString()}`;
|
||||
} catch {
|
||||
/* storage unavailable — non-fatal */
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cultivarAutofill(name) {
|
||||
const row = findCultivar(name);
|
||||
if (!row) return;
|
||||
setValueForName("1.2", row.group);
|
||||
setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor)));
|
||||
setValueForName("1.5", row.profile.join("|"));
|
||||
setValueForName("1.7", formatSigned(row.devModS));
|
||||
renderFormFromPlan();
|
||||
}
|
||||
|
||||
function wireCultivarDatalist() {
|
||||
const list = document.getElementById("cultivar-list");
|
||||
list.innerHTML = CULTIVARS.map((c) => `<option value="${c.name}">`).join("");
|
||||
}
|
||||
|
||||
function wireForm() {
|
||||
form.addEventListener("input", (e) => {
|
||||
const el = e.target;
|
||||
if (!el.name) return;
|
||||
if (el.type === "radio" && !el.checked) return;
|
||||
setValueForName(el.name, el.value);
|
||||
if (el.name === "1.1") cultivarAutofill(el.value);
|
||||
recompute();
|
||||
});
|
||||
}
|
||||
|
||||
function wireToolbar() {
|
||||
document.getElementById("btn-toggle-prefill").addEventListener("click", () => {
|
||||
document.getElementById("panel-prefill").classList.toggle("hidden");
|
||||
});
|
||||
document.getElementById("btn-toggle-alog").addEventListener("click", () => {
|
||||
document.getElementById("panel-alog").classList.toggle("hidden");
|
||||
});
|
||||
document.getElementById("btn-save").addEventListener("click", () => {
|
||||
const blob = new Blob([JSON.stringify(state.plan, null, 2)], { type: "application/json" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-load").click());
|
||||
document.getElementById("file-load").addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const loaded = JSON.parse(await file.text());
|
||||
state.plan = { ...blankPlan(), ...loaded };
|
||||
renderBlendRows();
|
||||
renderActuatorRows();
|
||||
renderFormFromPlan();
|
||||
recompute();
|
||||
} catch (err) {
|
||||
alert(`Could not load plan: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
renderBlendRows();
|
||||
renderActuatorRows();
|
||||
wireCultivarDatalist();
|
||||
renderFormFromPlan();
|
||||
wireForm();
|
||||
wireToolbar();
|
||||
initPrefillPanel({ state, renderBlendRows, renderActuatorRows, renderFormFromPlan, recompute });
|
||||
initAlogPanel({ state, recompute });
|
||||
initPrint({ form });
|
||||
recompute();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,89 @@
|
||||
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty
|
||||
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches
|
||||
// the form on any error.
|
||||
|
||||
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
|
||||
const urlInput = document.getElementById("prefill-url");
|
||||
const overwriteBox = document.getElementById("prefill-overwrite");
|
||||
const goBtn = document.getElementById("prefill-go");
|
||||
const undoBtn = document.getElementById("prefill-undo");
|
||||
const resultEl = document.getElementById("prefill-result");
|
||||
|
||||
let snapshot = null;
|
||||
|
||||
goBtn.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
goBtn.disabled = true;
|
||||
resultEl.innerHTML = `<p>Fetching…</p>`;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/prefill", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!body.ok) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot = JSON.parse(JSON.stringify(state.plan));
|
||||
const overwrite = overwriteBox.checked;
|
||||
let applied = 0;
|
||||
for (const [id, value] of Object.entries(body.fields ?? {})) {
|
||||
const current = state.plan.fields[id] ?? "";
|
||||
if (!overwrite && current !== "") continue;
|
||||
state.plan.fields[id] = value;
|
||||
applied++;
|
||||
}
|
||||
|
||||
renderFormFromPlan();
|
||||
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
|
||||
recompute();
|
||||
undoBtn.disabled = false;
|
||||
|
||||
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
|
||||
resultEl.innerHTML = `
|
||||
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p>
|
||||
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
|
||||
`;
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
undoBtn.addEventListener("click", () => {
|
||||
if (!snapshot) return;
|
||||
state.plan = snapshot;
|
||||
snapshot = null;
|
||||
undoBtn.disabled = true;
|
||||
renderFormFromPlan();
|
||||
clearPrefilledMarks();
|
||||
recompute();
|
||||
resultEl.innerHTML = "<p>Prefill undone.</p>";
|
||||
});
|
||||
|
||||
function markPrefilled(ids, provenance) {
|
||||
for (const id of ids) {
|
||||
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add("prefilled");
|
||||
const prov = provenance[id];
|
||||
if (prov) el.title = `from: ${prov}`;
|
||||
}
|
||||
}
|
||||
function clearPrefilledMarks() {
|
||||
for (const el of document.querySelectorAll(".prefilled")) {
|
||||
el.classList.remove("prefilled");
|
||||
el.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Bakes live input values into their sibling .pv span before printing (so text doesn't clip
|
||||
// at a fixed .f width the way a raw <input> would), then clears them after. One DOM, one
|
||||
// rendering — see app.css's @media print block for the corresponding display swap.
|
||||
|
||||
export function initPrint({ form }) {
|
||||
document.getElementById("btn-print").addEventListener("click", () => window.print());
|
||||
|
||||
window.addEventListener("beforeprint", () => bakeValues(form));
|
||||
window.addEventListener("afterprint", () => clearBaked(form));
|
||||
}
|
||||
|
||||
function bakeValues(form) {
|
||||
for (const el of form.querySelectorAll("input.ws-input, textarea.ws-input")) {
|
||||
if (el.type === "radio" || el.type === "checkbox") continue;
|
||||
const pv = el.nextElementSibling;
|
||||
if (pv && pv.classList.contains("pv")) pv.textContent = el.value || "";
|
||||
}
|
||||
}
|
||||
|
||||
function clearBaked(form) {
|
||||
for (const pv of form.querySelectorAll(".pv")) pv.textContent = "";
|
||||
}
|
||||
Reference in New Issue
Block a user