Cookbook — Auto Shopping List (from selected recipes)
A meal-prep shopping list that builds itself. Toggle recipe chips and every ingredient folds into one categorized grocery list — Produce, Meat, Dairy, and Pantry — with duplicate quantities summed per unit, so three pinches of garlic across two dishes become a single tidy line. Check items off against a live progress bar, edit or remove a line inline, watch the X items by Y aisles summary update, and copy, print, or share the finished list. Warm editorial cookbook styling, fully responsive, and print-friendly.
MCP
Code
:root {
--cream: #faf6ef;
--paper: #fffdf8;
--ink: #2b2622;
--ink-2: #5c534a;
--muted: #8a7f73;
--tomato: #d6452b;
--tomato-d: #b8351e;
--saffron: #e8a33d;
--sage: #7c8a6b;
--clay: #c8775a;
--line: rgba(43, 38, 34, 0.12);
--line-2: rgba(43, 38, 34, 0.2);
--ok: #3f8f5f;
--warn: #d98a2b;
--danger: #c8412b;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-1: 0 1px 2px rgba(43, 38, 34, 0.1);
--sh-2: 0 10px 30px rgba(43, 38, 34, 0.1);
--serif: "Fraunces", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--sans);
line-height: 1.6;
color: var(--ink);
background: var(--cream);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image:
radial-gradient(620px 320px at 12% -8%, rgba(232, 163, 61, 0.14), transparent 60%),
radial-gradient(560px 300px at 105% 4%, rgba(214, 69, 43, 0.1), transparent 60%);
}
.page {
max-width: 1060px;
margin: 0 auto;
padding: clamp(20px, 4vw, 52px) clamp(16px, 4vw, 40px) 64px;
}
/* ---------- Masthead ---------- */
.masthead { text-align: left; margin-bottom: 30px; }
.kicker {
font-family: var(--sans);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 12px;
font-weight: 600;
color: var(--tomato-d);
margin: 0 0 8px;
}
.masthead h1 {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(34px, 6vw, 54px);
line-height: 1.05;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
.lede {
margin: 0;
max-width: 60ch;
color: var(--ink-2);
font-size: clamp(15px, 1.6vw, 17px);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-1);
padding: 22px;
}
.recipes { position: sticky; top: 20px; }
.panel-head { margin-bottom: 16px; }
.panel-head h2,
.list-head h2 {
font-family: var(--serif);
font-weight: 600;
font-size: 21px;
margin: 0 0 2px;
}
.panel-sub { margin: 0; color: var(--muted); font-size: 13.5px; }
/* ---------- Recipe chips ---------- */
.chips { display: flex; flex-direction: column; gap: 10px; }
.chip {
--accent: var(--clay);
display: grid;
grid-template-columns: 46px 1fr 22px;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--line);
background: var(--paper);
border-radius: var(--r-md);
padding: 10px 12px;
cursor: pointer;
font-family: var(--sans);
color: var(--ink);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.08s, background 0.15s;
}
.chip:hover { border-color: var(--line-2); box-shadow: var(--sh-1); }
.chip:active { transform: translateY(1px); }
.chip-thumb {
width: 46px;
height: 46px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 22px;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
background:
radial-gradient(120% 120% at 28% 18%, rgba(255, 255, 255, 0.5), transparent 50%),
linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #000 14%));
}
.chip-body { min-width: 0; }
.chip-title { font-weight: 600; font-size: 14.5px; line-height: 1.25; }
.chip-meta { font-size: 12px; color: var(--muted); }
.chip-tick {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid var(--line-2);
display: grid;
place-items: center;
color: transparent;
font-size: 13px;
font-weight: 700;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chip[aria-pressed="true"] {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, var(--paper));
box-shadow: 0 0 0 1px var(--accent) inset, var(--sh-1);
}
.chip[aria-pressed="true"] .chip-tick {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* ---------- List panel ---------- */
.list-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.summary { margin: 0; color: var(--ink-2); font-size: 13.5px; font-weight: 500; }
.list-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
font-family: var(--sans);
font-weight: 600;
font-size: 13px;
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--paper);
color: var(--ink);
transition: background 0.15s, border-color 0.15s, transform 0.08s, color 0.15s;
}
.btn:hover { background: color-mix(in srgb, var(--saffron) 14%, var(--paper)); border-color: var(--saffron); }
.btn:active { transform: translateY(1px); }
/* ---------- Progress ---------- */
.progress-wrap { margin: 4px 0 18px; }
.progress-bar {
height: 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--sage) 16%, var(--cream));
overflow: hidden;
}
.progress-bar span {
display: block;
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--sage), color-mix(in srgb, var(--sage) 70%, var(--saffron)));
transition: width 0.35s ease;
}
.progress-label { margin: 6px 0 0; font-size: 12.5px; color: var(--muted); }
/* ---------- Groups ---------- */
.groups { display: flex; flex-direction: column; gap: 18px; }
.group { border-top: 1px solid var(--line); padding-top: 14px; }
.group:first-child { border-top: 0; padding-top: 0; }
.group-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.group-emoji { font-size: 16px; }
.group-title {
font-family: var(--serif);
font-weight: 600;
font-size: 16px;
margin: 0;
}
.group-count {
margin-left: auto;
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.item {
display: grid;
grid-template-columns: 22px 1fr auto;
align-items: center;
gap: 12px;
padding: 9px 4px;
border-bottom: 1px dashed var(--line);
}
.item:last-child { border-bottom: 0; }
.item-check {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border: 1.5px solid var(--line-2);
border-radius: 6px;
cursor: pointer;
display: grid;
place-items: center;
background: var(--paper);
transition: background 0.15s, border-color 0.15s;
}
.item-check::after {
content: "✓";
color: #fff;
font-size: 13px;
font-weight: 700;
opacity: 0;
transform: scale(0.6);
transition: opacity 0.12s, transform 0.12s;
}
.item-check:checked { background: var(--ok); border-color: var(--ok); }
.item-check:checked::after { opacity: 1; transform: scale(1); }
.item-name { font-size: 14.5px; line-height: 1.3; }
.item-qty {
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 13.5px;
color: var(--ink-2);
white-space: nowrap;
}
.item-from { color: var(--muted); font-size: 12px; }
.item-actions { display: flex; align-items: center; gap: 6px; }
.icon-btn {
border: 0;
background: transparent;
cursor: pointer;
border-radius: 8px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
color: var(--muted);
font-size: 15px;
transition: background 0.15s, color 0.15s;
}
.icon-btn:hover { background: color-mix(in srgb, var(--tomato) 12%, var(--paper)); color: var(--tomato-d); }
.item.done .item-name,
.item.done .item-qty { text-decoration: line-through; color: var(--muted); }
.item-edit {
font-family: var(--sans);
font-size: 14px;
border: 1px solid var(--saffron);
border-radius: var(--r-sm);
padding: 4px 8px;
width: 100%;
background: var(--paper);
color: var(--ink);
}
/* ---------- Empty ---------- */
.empty {
text-align: center;
padding: 40px 20px;
color: var(--muted);
}
.empty-art { font-size: 44px; display: block; margin-bottom: 10px; }
.empty p { margin: 0 auto; max-width: 38ch; }
/* ---------- Focus ---------- */
:focus-visible {
outline: 2px solid var(--tomato);
outline-offset: 2px;
border-radius: 6px;
}
/* ---------- Footer ---------- */
.foot {
margin-top: 36px;
text-align: center;
color: var(--muted);
font-size: 12.5px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 18px);
background: var(--ink);
color: var(--paper);
padding: 11px 20px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 500;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.layout { grid-template-columns: 1fr; }
.recipes { position: static; }
.list-head { flex-direction: column; }
}
/* ---------- Print ---------- */
@media print {
body { background: #fff; }
body { background-image: none; }
.recipes, .list-actions, .progress-wrap, .toast, .foot, .item-actions { display: none !important; }
.panel { box-shadow: none; border: 0; padding: 0; }
.layout { display: block; }
.masthead h1 { font-size: 28px; }
.lede { display: none; }
.item { break-inside: avoid; }
}(function () {
"use strict";
/* ---------- Data: fictional recipes ---------- */
// Categories drive aisle grouping & order.
const CATEGORY_META = {
produce: { label: "Produce", emoji: "🥕", order: 1 },
meat: { label: "Meat & Fish", emoji: "🍖", order: 2 },
dairy: { label: "Dairy & Eggs", emoji: "🧀", order: 3 },
pantry: { label: "Pantry", emoji: "🫙", order: 4 },
};
const RECIPES = [
{
id: "orzo",
title: "Charred Tomato & Saffron Orzo",
meta: "Dinner · 35 min · serves 4",
accent: "var(--tomato)",
emoji: "🍅",
ingredients: [
{ name: "Cherry tomatoes", qty: 500, unit: "g", cat: "produce" },
{ name: "Garlic", qty: 4, unit: "clove", cat: "produce" },
{ name: "Yellow onion", qty: 1, unit: "", cat: "produce" },
{ name: "Orzo pasta", qty: 300, unit: "g", cat: "pantry" },
{ name: "Saffron threads", qty: 1, unit: "pinch", cat: "pantry" },
{ name: "Vegetable stock", qty: 750, unit: "ml", cat: "pantry" },
{ name: "Olive oil", qty: 3, unit: "tbsp", cat: "pantry" },
{ name: "Pecorino", qty: 60, unit: "g", cat: "dairy" },
{ name: "Fresh basil", qty: 0, unit: "to taste", cat: "produce" },
],
},
{
id: "shakshuka",
title: "Smoky Red Pepper Shakshuka",
meta: "Brunch · 30 min · serves 3",
accent: "var(--clay)",
emoji: "🍳",
ingredients: [
{ name: "Eggs", qty: 6, unit: "", cat: "dairy" },
{ name: "Red bell pepper", qty: 2, unit: "", cat: "produce" },
{ name: "Yellow onion", qty: 1, unit: "", cat: "produce" },
{ name: "Garlic", qty: 3, unit: "clove", cat: "produce" },
{ name: "Cherry tomatoes", qty: 400, unit: "g", cat: "produce" },
{ name: "Smoked paprika", qty: 2, unit: "tsp", cat: "pantry" },
{ name: "Olive oil", qty: 2, unit: "tbsp", cat: "pantry" },
{ name: "Feta", qty: 100, unit: "g", cat: "dairy" },
{ name: "Fresh parsley", qty: 0, unit: "to taste", cat: "produce" },
],
},
{
id: "chicken",
title: "Lemon-Herb Roast Chicken Thighs",
meta: "Dinner · 50 min · serves 4",
accent: "var(--saffron)",
emoji: "🍗",
ingredients: [
{ name: "Chicken thighs", qty: 8, unit: "", cat: "meat" },
{ name: "Lemon", qty: 2, unit: "", cat: "produce" },
{ name: "Garlic", qty: 5, unit: "clove", cat: "produce" },
{ name: "Baby potatoes", qty: 700, unit: "g", cat: "produce" },
{ name: "Olive oil", qty: 3, unit: "tbsp", cat: "pantry" },
{ name: "Dried thyme", qty: 1, unit: "tbsp", cat: "pantry" },
{ name: "Butter", qty: 40, unit: "g", cat: "dairy" },
],
},
{
id: "soup",
title: "Tuscan White Bean & Kale Soup",
meta: "Lunch · 40 min · serves 6",
accent: "var(--sage)",
emoji: "🥬",
ingredients: [
{ name: "Cannellini beans", qty: 2, unit: "can", cat: "pantry" },
{ name: "Kale", qty: 200, unit: "g", cat: "produce" },
{ name: "Carrot", qty: 3, unit: "", cat: "produce" },
{ name: "Celery", qty: 3, unit: "stalk", cat: "produce" },
{ name: "Yellow onion", qty: 1, unit: "", cat: "produce" },
{ name: "Garlic", qty: 3, unit: "clove", cat: "produce" },
{ name: "Vegetable stock", qty: 1000, unit: "ml", cat: "pantry" },
{ name: "Parmesan rind", qty: 1, unit: "", cat: "dairy" },
{ name: "Olive oil", qty: 2, unit: "tbsp", cat: "pantry" },
],
},
{
id: "tacos",
title: "Crispy Black Bean Tacos",
meta: "Dinner · 25 min · serves 4",
accent: "var(--tomato-d)",
emoji: "🌮",
ingredients: [
{ name: "Black beans", qty: 2, unit: "can", cat: "pantry" },
{ name: "Corn tortillas", qty: 12, unit: "", cat: "pantry" },
{ name: "Avocado", qty: 2, unit: "", cat: "produce" },
{ name: "Lime", qty: 2, unit: "", cat: "produce" },
{ name: "Red onion", qty: 1, unit: "", cat: "produce" },
{ name: "Cherry tomatoes", qty: 200, unit: "g", cat: "produce" },
{ name: "Ground cumin", qty: 1, unit: "tsp", cat: "pantry" },
{ name: "Cheddar", qty: 120, unit: "g", cat: "dairy" },
{ name: "Sour cream", qty: 150, unit: "g", cat: "dairy" },
],
},
];
/* ---------- State ---------- */
const selected = new Set();
// checked + per-item overrides keyed by "cat|name"
const checked = new Set();
const removed = new Set();
const overrides = {}; // key -> display string override
/* ---------- Elements ---------- */
const chipsEl = document.getElementById("recipe-chips");
const groupsEl = document.getElementById("groups");
const emptyEl = document.getElementById("empty");
const summaryEl = document.getElementById("summary");
const pickerSub = document.getElementById("picker-sub");
const progressWrap = document.getElementById("progress-wrap");
const progressFill = document.getElementById("progress-fill");
const progressLabel = document.getElementById("progress-label");
const toastEl = document.getElementById("toast");
/* ---------- Helpers ---------- */
function keyFor(item) {
return item.cat + "|" + item.name.toLowerCase();
}
function fmtNum(n) {
// round to at most 2 decimals, drop trailing zeros
const r = Math.round(n * 100) / 100;
return Number.isInteger(r) ? String(r) : String(r).replace(/\.?0+$/, "");
}
let toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
}
/* ---------- Aggregation ---------- */
// Returns { cat: [ {name, cat, lines:[{qty,unit}], from:Set} ] }
function aggregate() {
const map = new Map(); // key -> entry
selected.forEach((rid) => {
const recipe = RECIPES.find((r) => r.id === rid);
if (!recipe) return;
recipe.ingredients.forEach((ing) => {
const k = keyFor(ing);
if (removed.has(k)) return;
if (!map.has(k)) {
map.set(k, {
key: k,
name: ing.name,
cat: ing.cat,
units: new Map(), // unit -> summed qty (qty 0 = "to taste" style)
from: new Set(),
});
}
const entry = map.get(k);
entry.from.add(recipe.title);
const u = ing.unit;
if (ing.qty === 0) {
// non-numeric (to taste); store as a flag unit with qty 0
if (!entry.units.has(u)) entry.units.set(u, 0);
} else {
entry.units.set(u, (entry.units.get(u) || 0) + ing.qty);
}
});
});
return map;
}
// Build a readable quantity string from a units map.
function qtyString(unitsMap) {
const parts = [];
unitsMap.forEach((qty, unit) => {
if (qty === 0) {
parts.push(unit || "as needed"); // e.g. "to taste"
return;
}
const plural = (unit === "clove" || unit === "stalk" || unit === "can") && qty !== 1 ? "s" : "";
const unitStr = unit ? unit + plural : "";
parts.push(unitStr ? fmtNum(qty) + " " + unitStr : "×" + fmtNum(qty));
});
return parts.join(" + ");
}
/* ---------- Render list ---------- */
function render() {
const map = aggregate();
const items = Array.from(map.values());
// group by category
const byCat = {};
items.forEach((it) => {
(byCat[it.cat] = byCat[it.cat] || []).push(it);
});
const cats = Object.keys(byCat).sort(
(a, b) => CATEGORY_META[a].order - CATEGORY_META[b].order
);
groupsEl.innerHTML = "";
if (items.length === 0) {
emptyEl.hidden = false;
progressWrap.hidden = true;
} else {
emptyEl.hidden = true;
}
let total = 0;
let done = 0;
cats.forEach((cat) => {
const list = byCat[cat].sort((a, b) => a.name.localeCompare(b.name));
const meta = CATEGORY_META[cat];
const group = document.createElement("div");
group.className = "group";
const head = document.createElement("div");
head.className = "group-head";
head.innerHTML =
'<span class="group-emoji" aria-hidden="true">' + meta.emoji + "</span>" +
'<h3 class="group-title">' + meta.label + "</h3>" +
'<span class="group-count">' + list.length + (list.length === 1 ? " item" : " items") + "</span>";
group.appendChild(head);
list.forEach((it) => {
total++;
const isDone = checked.has(it.key);
if (isDone) done++;
const row = document.createElement("div");
row.className = "item" + (isDone ? " done" : "");
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "item-check";
cb.checked = isDone;
cb.setAttribute("aria-label", "Got " + it.name);
cb.addEventListener("change", () => {
if (cb.checked) checked.add(it.key);
else checked.delete(it.key);
render();
});
const body = document.createElement("div");
const qty = overrides[it.key] != null ? overrides[it.key] : qtyString(it.units);
const fromTxt =
it.from.size > 1 ? " · from " + it.from.size + " recipes" : "";
body.innerHTML =
'<div class="item-name"></div>' +
'<div class="item-from"></div>';
body.querySelector(".item-name").textContent = it.name;
body.querySelector(".item-from").textContent =
(qty ? "" : "") + fromTxt.trim();
const right = document.createElement("div");
right.className = "item-actions";
const qtyEl = document.createElement("span");
qtyEl.className = "item-qty";
qtyEl.textContent = qty;
const editBtn = document.createElement("button");
editBtn.className = "icon-btn";
editBtn.type = "button";
editBtn.title = "Edit quantity";
editBtn.setAttribute("aria-label", "Edit quantity for " + it.name);
editBtn.textContent = "✎";
editBtn.addEventListener("click", () => startEdit(it, row, qtyEl));
const rmBtn = document.createElement("button");
rmBtn.className = "icon-btn";
rmBtn.type = "button";
rmBtn.title = "Remove";
rmBtn.setAttribute("aria-label", "Remove " + it.name);
rmBtn.textContent = "✕";
rmBtn.addEventListener("click", () => {
removed.add(it.key);
checked.delete(it.key);
toast(it.name + " removed");
render();
});
right.appendChild(qtyEl);
right.appendChild(editBtn);
right.appendChild(rmBtn);
row.appendChild(cb);
row.appendChild(body);
row.appendChild(right);
group.appendChild(row);
});
groupsEl.appendChild(group);
});
// summary
const aisles = cats.length;
if (total === 0) {
summaryEl.textContent =
selected.size === 0
? "No recipes selected yet."
: "Everything removed — add a recipe or reset.";
} else {
summaryEl.textContent =
total + (total === 1 ? " item" : " items") + " · " +
aisles + (aisles === 1 ? " aisle" : " aisles") + " · " +
selected.size + (selected.size === 1 ? " recipe" : " recipes");
}
// progress
if (total > 0) {
progressWrap.hidden = false;
const pct = Math.round((done / total) * 100);
progressFill.style.width = pct + "%";
progressLabel.textContent = done + " of " + total + " gathered (" + pct + "%)";
} else {
progressWrap.hidden = true;
}
}
function startEdit(it, row, qtyEl) {
const input = document.createElement("input");
input.className = "item-edit";
input.value = qtyEl.textContent;
input.setAttribute("aria-label", "Quantity for " + it.name);
qtyEl.replaceWith(input);
input.focus();
input.select();
const commit = () => {
const v = input.value.trim();
if (v) overrides[it.key] = v;
render();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); commit(); }
if (e.key === "Escape") { render(); }
});
input.addEventListener("blur", commit);
}
/* ---------- Recipe chips ---------- */
function renderChips() {
chipsEl.innerHTML = "";
RECIPES.forEach((r) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chip";
btn.style.setProperty("--accent", r.accent);
btn.setAttribute("aria-pressed", selected.has(r.id) ? "true" : "false");
btn.innerHTML =
'<span class="chip-thumb" aria-hidden="true">' + r.emoji + "</span>" +
'<span class="chip-body">' +
'<span class="chip-title"></span>' +
'<span class="chip-meta"></span>' +
"</span>" +
'<span class="chip-tick" aria-hidden="true">✓</span>';
btn.querySelector(".chip-title").textContent = r.title;
btn.querySelector(".chip-meta").textContent = r.meta;
btn.addEventListener("click", () => {
if (selected.has(r.id)) {
selected.delete(r.id);
} else {
selected.add(r.id);
// re-include any items previously removed if recipe re-added? keep removed sticky.
}
btn.setAttribute("aria-pressed", selected.has(r.id) ? "true" : "false");
render();
updatePickerSub();
});
chipsEl.appendChild(btn);
});
}
function updatePickerSub() {
pickerSub.textContent =
selected.size === 0
? "Tap to add or remove a dish."
: selected.size + (selected.size === 1 ? " dish" : " dishes") + " selected.";
}
/* ---------- Export text ---------- */
function buildText() {
const map = aggregate();
const items = Array.from(map.values());
if (items.length === 0) return "Shopping list is empty.";
const byCat = {};
items.forEach((it) => (byCat[it.cat] = byCat[it.cat] || []).push(it));
const cats = Object.keys(byCat).sort(
(a, b) => CATEGORY_META[a].order - CATEGORY_META[b].order
);
let out = "SHOPPING LIST\n";
out += items.length + " items · " + cats.length + " aisles\n";
cats.forEach((cat) => {
out += "\n" + CATEGORY_META[cat].label.toUpperCase() + "\n";
byCat[cat]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((it) => {
const mark = checked.has(it.key) ? "[x]" : "[ ]";
const qty = overrides[it.key] != null ? overrides[it.key] : qtyString(it.units);
out += mark + " " + it.name + (qty ? " — " + qty : "") + "\n";
});
});
return out;
}
/* ---------- Actions ---------- */
document.getElementById("copy-btn").addEventListener("click", async () => {
const text = buildText();
try {
await navigator.clipboard.writeText(text);
toast("List copied to clipboard");
} catch (e) {
// fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); toast("List copied"); }
catch (_) { toast("Copy not supported"); }
ta.remove();
}
});
document.getElementById("print-btn").addEventListener("click", () => {
if (selected.size === 0) { toast("Select a recipe first"); return; }
window.print();
});
document.getElementById("share-btn").addEventListener("click", async () => {
const text = buildText();
if (navigator.share) {
try {
await navigator.share({ title: "Shopping list", text });
return;
} catch (e) { /* cancelled */ return; }
}
try {
await navigator.clipboard.writeText(text);
toast("Share unavailable — copied instead");
} catch (_) {
toast("Sharing not supported here");
}
});
/* ---------- Init ---------- */
renderChips();
updatePickerSub();
// preselect a couple so the demo isn't empty on load
selected.add("orzo");
selected.add("tacos");
renderChips();
updatePickerSub();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cookbook — Auto Shopping List</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<header class="masthead" role="banner">
<p class="kicker">Cookbook · Meal Prep</p>
<h1>Auto Shopping List</h1>
<p class="lede">Pick the recipes you're cooking this week. We'll fold every ingredient into one
tidy grocery list — summing duplicate quantities, grouping by aisle, and keeping a running tally
so nothing gets left in the cart.</p>
</header>
<main class="layout" role="main">
<!-- Recipe picker -->
<section class="panel recipes" aria-labelledby="recipes-h">
<div class="panel-head">
<h2 id="recipes-h">This week's recipes</h2>
<p class="panel-sub" id="picker-sub">Tap to add or remove a dish.</p>
</div>
<div class="chips" id="recipe-chips" role="group" aria-label="Choose recipes">
<!-- chips injected by JS -->
</div>
</section>
<!-- Shopping list -->
<section class="panel list-panel" aria-labelledby="list-h">
<div class="list-head">
<div>
<h2 id="list-h">Shopping list</h2>
<p class="summary" id="summary" aria-live="polite">No recipes selected yet.</p>
</div>
<div class="list-actions">
<button class="btn ghost" id="copy-btn" type="button">Copy</button>
<button class="btn ghost" id="print-btn" type="button">Print</button>
<button class="btn ghost" id="share-btn" type="button">Share</button>
</div>
</div>
<div class="progress-wrap" id="progress-wrap" hidden>
<div class="progress-bar"><span id="progress-fill"></span></div>
<p class="progress-label" id="progress-label" aria-live="polite"></p>
</div>
<div id="groups" class="groups">
<!-- category groups injected by JS -->
</div>
<div class="empty" id="empty">
<span class="empty-art" aria-hidden="true">🧺</span>
<p>Your list is empty. Select a recipe above and the ingredients will gather here,
sorted by aisle.</p>
</div>
</section>
</main>
<footer class="foot" role="contentinfo">
<p>Illustrative UI · fictional recipes — not dietary advice.</p>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Auto Shopping List (from selected recipes)
A meal-planning panel with a sticky recipe picker on the left and a self-assembling grocery list on the right. Each recipe is a toggle chip with a gradient “photo” thumbnail and a serving line; tapping one folds its ingredients into the list, tapping again pulls them back out. Two dishes come pre-selected so the list is alive on load.
The aggregation does the tedious part. Ingredients are merged by name and summed per unit — overlapping cherry tomatoes from the orzo and the tacos collapse into one weighed line, while to taste items like basil and parsley stay un-quantified. Everything is bucketed into Produce, Meat & Fish, Dairy & Eggs, and Pantry, sorted by aisle so the list reads like a real shopping run. A running summary reports X items · Y aisles · Z recipes.
Each row has a checkbox that strikes the item through and advances a sage progress bar with an aria-live count, plus inline edit (override the quantity) and remove actions. Copy drops a plain-text, aisle-grouped list onto the clipboard, Share uses the Web Share API with a clipboard fallback, and Print hides the controls for a clean paper list. The two-column layout collapses to one at 720px.
Illustrative UI only — recipes & nutrition data are fictional, not dietary advice.