${s.welcome_body}
🎙️ ${s.welcome_voice_hint}
`; welcome.onsubmit = (event) => { event.preventDefault(); const fd = new FormData(welcome); this.data.family_profile.adults = Math.max(0, Math.trunc(Number(fd.get("adults") || 0))); this.data.family_profile.children = Math.max(0, Math.trunc(Number(fd.get("children") || 0))); this.commit(); }; main.appendChild(welcome); } main.insertAdjacentHTML("beforeend", `${ICONS.drag} ${s.drag_hint}
`); } const list = document.createElement("div"); list.className = "inv-list"; const draggable = this.settings.inventorySort === "custom" && !this.locationFilter; let hasExpiring = false; items.forEach((item) => { const days = inv.daysToExpiration(item); if (days !== null && days <= EXPIRY_SOON_DAYS) hasExpiring = true; const card = this.renderInventoryCard(item, draggable); list.appendChild(card); }); main.appendChild(list); if (hasExpiring && !this.warnedExpiry) { this.warnedExpiry = true; void this.gestures.concernedTilt(); } // Add-item form. main.insertAdjacentHTML("beforeend", `${s.plan_need_family}
`; const cta = document.createElement("button"); cta.className = "btn primary block"; cta.textContent = s.plan_need_family_cta; cta.onclick = () => { this.tab = "settings"; this.render(); }; gate.appendChild(cta); main.appendChild(gate); return; } // "Who eats what" matrix editor. main.insertAdjacentHTML("beforeend", `${s.plan_who_hint}
`); // Free-text wishes fed to the planner ("Friday is pizza night", …). // Saved without re-rendering so typing never loses focus. const notesLabel = document.createElement("label"); notesLabel.textContent = s.plan_notes_label; const notes = document.createElement("textarea"); notes.className = "plan-notes"; notes.rows = 2; notes.placeholder = s.plan_notes_placeholder; notes.value = this.data.family_profile.plan_notes ?? ""; notes.oninput = () => { this.data.family_profile.plan_notes = notes.value.trim() || null; saveData(this.data); this.scheduleAutoSync(); }; main.append(notesLabel, notes); const generate = document.createElement("button"); generate.className = "btn primary block"; generate.textContent = s.generate_plan; generate.onclick = () => void this.generatePlanSmart(generate); main.appendChild(generate); const plan = this.data.meal_plan; if (!plan) { main.insertAdjacentHTML("beforeend", `${s.shopping_no_usage}
`; this.showModal({ title: planner.displayName(name), body, primaryLabel: s.confirm_yes, onPrimary: () => { /* informational */ }, }); } // ---------- Settings ---------- private renderSettings(main: HTMLElement): void { const s = this.s; main.insertAdjacentHTML("beforeend", `${s.hf_sync_hint}
`); const tokenLabel = document.createElement("label"); tokenLabel.textContent = s.hf_token_label; const tokenInput = document.createElement("input"); tokenInput.type = "password"; tokenInput.placeholder = "hf_…"; tokenInput.value = this.settings.hfToken ?? ""; tokenInput.oninput = () => { this.settings.hfToken = tokenInput.value.trim() || null; saveSettings(this.settings); }; // Save / Load side by side. const syncRow = document.createElement("div"); syncRow.className = "sync-row"; const saveCloud = document.createElement("button"); saveCloud.className = "btn primary"; saveCloud.textContent = s.hf_save_to_cloud; saveCloud.onclick = async () => { const token = this.settings.hfToken; if (!token) return this.toast(s.hf_sync_err); const sum = summarizeData(this.data); const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping); if (!(await this.confirm(s.hf_save_confirm(summary)))) return; await this.pushToCloud(false); void this.gestures.happyDance(); }; const loadCloud = document.createElement("button"); loadCloud.className = "btn"; loadCloud.textContent = s.hf_load_from_cloud; loadCloud.onclick = async () => { const token = this.settings.hfToken; if (!token) return this.toast(s.hf_sync_err); try { const remote = await downloadData(token); if (!remote) return this.toast(s.hf_sync_err); const sum = summarizeData(remote); const summary = s.data_summary(sum.inventory, sum.hasPlan, sum.shopping); if (!(await this.confirm(s.hf_load_confirm(summary)))) return; this.data = remote; // Bring over synced preferences too (robot name, voice, activation…). const rs = await downloadSettings(token); if (rs) { this.settings = applySyncableSettings(this.settings, rs); saveSettings(this.settings); } this.commit(); this.toast(s.import_ok); } catch (err) { console.error("[cookaiware] HF download failed", err); const msg = err instanceof Error ? err.message : String(err); this.toast(`${s.hf_sync_err} — ${msg.slice(0, 140)}`); } }; syncRow.append(saveCloud, loadCloud); // Auto-sync toggle. const autoRow = document.createElement("label"); autoRow.className = "switch-row"; autoRow.innerHTML = `${s.hf_autosync}${s.voice_key_hint}
`); const nameLabel = document.createElement("label"); nameLabel.textContent = s.voice_name_label; const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.autocomplete = "off"; nameInput.value = this.settings.robotName; nameInput.oninput = () => { this.settings.robotName = nameInput.value.trim() || "Reachy"; saveSettings(this.settings); }; voiceCard.append(nameLabel, nameInput); voiceCard.insertAdjacentHTML("beforeend", `${s.voice_name_hint}
`); // Robot voice picker (OpenAI gpt-realtime voices). const voiceLabel = document.createElement("label"); voiceLabel.textContent = s.voice_voice_label; const voiceSelect = document.createElement("select"); for (const v of REALTIME_VOICES) { const opt = document.createElement("option"); opt.value = v; opt.textContent = v.charAt(0).toUpperCase() + v.slice(1); opt.selected = this.settings.voice === v; voiceSelect.appendChild(opt); } voiceSelect.onchange = () => { this.settings.voice = voiceSelect.value; saveSettings(this.settings); }; voiceCard.append(voiceLabel, voiceSelect); voiceCard.insertAdjacentHTML("beforeend", `${s.voice_voice_hint}
`); // Proactivity: respond to anything vs only when addressed by name. const actLabel = document.createElement("label"); actLabel.textContent = s.voice_activation_label; const actSeg = document.createElement("div"); actSeg.className = "segmented"; for (const [mode, label] of [ ["always", s.voice_activation_always], ["name", s.voice_activation_name], ] as const) { const b = document.createElement("button"); b.className = this.settings.activationMode === mode ? "active" : ""; b.textContent = label; b.onclick = () => { this.settings.activationMode = mode; saveSettings(this.settings); this.render(); }; actSeg.appendChild(b); } voiceCard.append(actLabel, actSeg); voiceCard.insertAdjacentHTML("beforeend", `${s.voice_activation_hint}
`); // Barge-in toggle. const intRow = document.createElement("label"); intRow.className = "switch-row"; intRow.innerHTML = `${s.voice_interrupt_label}CookAIware · ${__BUILD__} · Hugging Face
`, ); } } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** "just now" / "5 min ago" / a local date for older timestamps. */ function relativeTime(iso: string, lang: Lang): string { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return iso; const secs = Math.round((Date.now() - then) / 1000); const es = lang === "es"; if (secs < 60) return es ? "hace un momento" : "just now"; const mins = Math.round(secs / 60); if (mins < 60) return es ? `hace ${mins} min` : `${mins} min ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return es ? `hace ${hrs} h` : `${hrs} h ago`; return new Date(iso).toLocaleDateString(es ? "es-ES" : "en-US"); } /** A locale-formatted expiration date. */ function formatDate(iso: string, lang: Lang): string { const d = new Date(iso + "T00:00:00"); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString(lang === "es" ? "es-ES" : "en-US", { day: "numeric", month: "short" }); }