// pantry-vision frontend logic (docs/project-plan.md Phase 17). Vanilla JS, no // framework, no build step — same "vendored, dependency-free" choice as the digest/ // admin canvas SDKs' render.js. // // Config comes from URL query params (?api=...&token=...), set by the // kitchen-display kiosk's own launch command, NOT hardcoded here — this file is a // generic static asset with no secret in it, served read-only by pantry-web to // whatever device points a browser at it. // // FOUR FLOWS, ONE CAMERA, ONE RULE // -------------------------------- // Unload (book in), Consume (book out), Expired (throw away) and Edit (correct) are // the four ways stock moves, and the first three are camera-first: hold the thing up, // the model says what it is, a person confirms, it is written. The rule that shapes // every one of them is server.py's — the camera proposes, the person disposes. // Nothing here calls a write endpoint without a tap in between, including the flows // where that costs an extra tap, because a camera that quietly books out the wrong // brand of yoghurt produces an inventory nobody trusts, and an inventory nobody // trusts is the same as no inventory. "use strict"; const params = new URLSearchParams(location.search); const API = (params.get("api") || "").replace(/\/$/, ""); const TOKEN = params.get("token") || ""; if (!API || !TOKEN) { document.body.innerHTML = '

pantry-vision not configured — missing ' + "?api=&token= in the URL. See hosts/kitchen-display/configs/sway/pantry-kiosk.

"; throw new Error("pantry-vision: missing ?api=/&token= query params"); } function api(path, options) { options = options || {}; options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); return fetch(`${API}${path}`, options).then((res) => { if (!res.ok) { return res.json().catch(() => ({})).then((body) => { throw new Error(body.error || `${res.status} ${res.statusText}`); }); } return res.json(); }); } function postJson(path, body) { return api(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); } const $ = (id) => document.getElementById(id); // --- Navigation ---------------------------------------------------------------- // Three tabs (Home / Inventory / Recipes) plus four flow screens reached from Home. // "scan" is kept as an alias for the unload flow: it is the fragment // kitchen-display-agent's "Show scan" MQTT button has been publishing since Phase 17, // and a button in Home Assistant that stops working is a worse outcome than an // old name living on here. const SCREENS = ["home", "unload", "consume", "expired", "edit", "inventory", "recipes"]; const TABS = ["home", "inventory", "recipes"]; let currentScreen = "home"; function show(name) { if (name === "scan") name = "unload"; if (!SCREENS.includes(name)) name = "home"; // Leaving a flow always releases the camera and cancels its loop: the kiosk has one // webcam and a scan loop left running behind another screen would keep firing // /identify at the LLM host with nobody watching the answers. if (currentScreen !== name) stopScanner(); currentScreen = name; document.querySelectorAll(".panel").forEach((p) => p.classList.toggle("active", p.id === name)); document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name)); // A flow screen is not a tab; keep Home lit while one is open so the tab bar never // shows nothing selected. if (!TABS.includes(name)) document.querySelector('.tab[data-tab="home"]').classList.add("active"); if (name === "inventory") loadInventory(); if (name === "recipes") loadRecipes(); if (name === "unload") startUnload(); if (name === "consume") startConsume(); if (name === "expired") startExpired(); if (name === "edit") loadEditInventory(); } document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => show(t.dataset.tab))); document.querySelectorAll("[data-goto]").forEach((b) => b.addEventListener("click", () => show(b.dataset.goto))); function toast(message, kind) { const el = $("toast"); el.textContent = message; el.className = kind || ""; el.hidden = false; clearTimeout(toast._timer); toast._timer = setTimeout(() => { el.hidden = true; }, 2600); } // --- The camera ---------------------------------------------------------------- // One