Cookbook — Weekly Meal Planner (drag recipes)
An editorial weekly meal planner where you drag recipe cards from a pantry tray into a 7-day grid of Breakfast, Lunch, and Dinner slots. Drop highlights, a fully keyboard-accessible pick-and-place alternative, per-day time and serving tallies, localStorage persistence, a clear-week reset, and a one-click shopping list that counts ingredients across the week. Warm cream surfaces, gradient food photos, and serif headings give it a real cookbook feel.
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;
--shadow-sm: 0 1px 2px rgba(43, 38, 34, 0.1);
--shadow-lg: 0 10px 30px rgba(43, 38, 34, 0.1);
--serif: "Fraunces", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: var(--sans);
line-height: 1.6;
font-size: 15px;
}
h1,
h2,
h3 {
font-family: var(--serif);
font-weight: 600;
line-height: 1.15;
margin: 0;
}
.kicker {
font-family: var(--sans);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
font-weight: 600;
color: var(--tomato);
margin: 0 0 0.4rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
kbd {
font-family: var(--sans);
font-size: 0.75em;
background: var(--cream);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 5px;
padding: 0.05em 0.4em;
color: var(--ink-2);
}
/* ---------- Masthead ---------- */
.masthead {
max-width: 1280px;
margin: 0 auto;
padding: 2.4rem 1.5rem 1.6rem;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1.2rem;
border-bottom: 1px solid var(--line);
}
.masthead h1 {
font-size: clamp(2rem, 4.5vw, 3.1rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.masthead__sub {
margin: 0.5rem 0 0;
max-width: 46ch;
color: var(--ink-2);
}
.masthead__actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
/* ---------- Buttons ---------- */
.btn {
font-family: var(--sans);
font-weight: 600;
font-size: 0.86rem;
border-radius: var(--r-md);
padding: 0.62rem 1.05rem;
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn--primary {
background: var(--tomato);
color: #fff;
box-shadow: var(--shadow-sm);
}
.btn--primary:hover {
background: var(--tomato-d);
}
.btn--ghost {
background: var(--paper);
color: var(--ink);
border-color: var(--line-2);
}
.btn--ghost:hover {
background: #fff;
box-shadow: var(--shadow-sm);
}
:focus-visible {
outline: 2.5px solid var(--saffron);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Layout ---------- */
.layout {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem;
display: grid;
grid-template-columns: 300px 1fr;
gap: 1.5rem;
align-items: start;
}
/* ---------- Tray ---------- */
.tray {
position: sticky;
top: 1rem;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 1.1rem;
box-shadow: var(--shadow-sm);
}
.tray__head h2 {
font-size: 1.4rem;
}
.tray__hint {
font-size: 0.8rem;
color: var(--muted);
margin: 0.3rem 0 0.9rem;
}
.tray__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
max-height: 62vh;
overflow-y: auto;
padding-right: 0.2rem;
}
.tray__note {
font-size: 0.75rem;
color: var(--muted);
margin: 0.9rem 0 0;
text-align: center;
}
/* ---------- Recipe card ---------- */
.card {
position: relative;
display: grid;
grid-template-columns: 52px 1fr;
gap: 0.7rem;
align-items: center;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 0.6rem;
cursor: grab;
box-shadow: var(--shadow-sm);
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--line-2);
}
.card:active {
cursor: grabbing;
}
.card.is-dragging {
opacity: 0.45;
}
.card.is-picked {
border-color: var(--saffron);
box-shadow: 0 0 0 2px var(--saffron);
}
.card__photo {
width: 52px;
height: 52px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 1.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.card__body {
min-width: 0;
}
.card__title {
font-family: var(--serif);
font-weight: 600;
font-size: 0.95rem;
line-height: 1.2;
}
.card__meta {
font-size: 0.74rem;
color: var(--muted);
margin-top: 0.15rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.card__meta .dot::before {
content: "•";
margin-right: 0.5rem;
color: var(--line-2);
}
/* gradient "photo" placeholders */
.ph-tomato {
background: radial-gradient(circle at 30% 25%, #ff7a52, #d6452b 70%);
}
.ph-saffron {
background: radial-gradient(circle at 30% 25%, #f6c66a, #e8a33d 70%);
}
.ph-sage {
background: radial-gradient(circle at 30% 25%, #a4b389, #7c8a6b 70%);
}
.ph-clay {
background: radial-gradient(circle at 30% 25%, #e3a487, #c8775a 70%);
}
.ph-plum {
background: radial-gradient(circle at 30% 25%, #a8708f, #794c66 70%);
}
.ph-ocean {
background: radial-gradient(circle at 30% 25%, #6fb3b8, #3d7e84 70%);
}
/* ---------- Board / grid ---------- */
.board {
min-width: 0;
}
.board__scroll {
overflow-x: auto;
border-radius: var(--r-lg);
}
.grid {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
min-width: 760px;
}
.grid th,
.grid td {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 0;
vertical-align: top;
}
.grid th:last-child,
.grid td:last-child {
border-right: none;
}
.grid tbody tr:last-child td {
border-bottom: none;
}
.day-th {
padding: 0.7rem 0.6rem;
text-align: left;
background: linear-gradient(180deg, #fff, var(--paper));
}
.day-th__name {
font-family: var(--serif);
font-weight: 600;
font-size: 1rem;
}
.day-th__sum {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.1rem;
}
.day-th__sum b {
color: var(--ink-2);
font-weight: 600;
}
.grid__corner {
width: 96px;
background: var(--cream);
}
.grid__corner span {
display: block;
padding: 0.7rem 0.6rem;
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-weight: 600;
}
.meal-th {
width: 96px;
background: var(--cream);
text-align: left;
padding: 0.7rem 0.6rem;
font-size: 0.78rem;
letter-spacing: 0.04em;
color: var(--ink-2);
font-weight: 600;
text-transform: uppercase;
}
.meal-th .emoji {
display: block;
font-size: 1rem;
margin-bottom: 0.15rem;
}
/* ---------- Slot ---------- */
.slot {
min-height: 86px;
padding: 0.4rem;
}
.slot__drop {
min-height: 70px;
height: 100%;
border: 1.5px dashed transparent;
border-radius: var(--r-sm);
display: grid;
place-items: stretch;
transition: background 0.15s ease, border-color 0.15s ease;
}
.slot__drop:empty::before {
content: "+";
color: var(--line-2);
font-size: 1.4rem;
font-weight: 600;
display: grid;
place-items: center;
border: 1.5px dashed var(--line);
border-radius: var(--r-sm);
}
.slot__drop.is-over {
background: rgba(232, 163, 61, 0.14);
border-color: var(--saffron);
}
/* placed chip */
.placed {
position: relative;
display: grid;
grid-template-columns: 34px 1fr;
gap: 0.45rem;
align-items: center;
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0.4rem 1.6rem 0.4rem 0.4rem;
cursor: grab;
box-shadow: var(--shadow-sm);
}
.placed:active {
cursor: grabbing;
}
.placed.is-dragging {
opacity: 0.45;
}
.placed__photo {
width: 34px;
height: 34px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 1rem;
}
.placed__title {
font-family: var(--serif);
font-weight: 600;
font-size: 0.82rem;
line-height: 1.15;
}
.placed__meta {
font-size: 0.68rem;
color: var(--muted);
}
.placed__remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--muted);
font-size: 0.85rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.placed__remove:hover {
background: var(--danger);
color: #fff;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 1.4rem;
transform: translateX(-50%) translateY(140%);
background: var(--ink);
color: var(--cream);
padding: 0.7rem 1.2rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
opacity: 0;
transition: transform 0.25s ease, opacity 0.25s ease;
z-index: 60;
pointer-events: none;
}
.toast.is-show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
/* ---------- Shopping list sheet ---------- */
.overlay {
position: fixed;
inset: 0;
background: rgba(43, 38, 34, 0.4);
display: grid;
place-items: center;
padding: 1.2rem;
z-index: 80;
}
.overlay[hidden] {
display: none;
}
.sheet {
background: var(--paper);
border-radius: var(--r-lg);
width: min(460px, 100%);
max-height: 84vh;
overflow: auto;
box-shadow: var(--shadow-lg);
border: 1px solid var(--line);
}
.sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem 1.3rem 0.8rem;
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
background: var(--paper);
}
.sheet__head h2 {
font-size: 1.5rem;
}
.iconbtn {
border: 1px solid var(--line-2);
background: var(--cream);
border-radius: 50%;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 0.9rem;
color: var(--ink-2);
}
.iconbtn:hover {
background: #fff;
}
.sheet__body {
padding: 1rem 1.3rem 1.4rem;
}
.shop-empty {
color: var(--muted);
text-align: center;
padding: 1.5rem 0;
}
.shop-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.shop-list li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.7rem;
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-sm);
}
.shop-list .qty {
font-weight: 600;
color: var(--tomato);
font-variant-numeric: tabular-nums;
}
.shop-total {
margin-top: 0.9rem;
font-size: 0.85rem;
color: var(--ink-2);
text-align: right;
}
/* ---------- Responsive ---------- */
@media (max-width: 920px) {
.layout {
grid-template-columns: 1fr;
}
.tray {
position: static;
}
.tray__list {
grid-template-columns: 1fr 1fr;
max-height: none;
}
}
@media (max-width: 720px) {
.masthead {
padding-top: 1.8rem;
}
.tray__list {
grid-template-columns: 1fr;
}
/* stack the grid into day blocks */
.grid,
.grid thead,
.grid tbody,
.grid th,
.grid td,
.grid tr {
display: block;
min-width: 0;
}
.grid {
border: none;
background: transparent;
}
.grid thead {
display: none;
}
.grid tbody {
display: grid;
gap: 1rem;
}
.grid tr {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.meal-th {
width: auto;
display: flex;
align-items: baseline;
gap: 0.5rem;
background: var(--cream);
border-bottom: 1px solid var(--line);
}
.meal-th .emoji {
display: inline;
margin: 0;
}
.slot {
border-bottom: 1px solid var(--line);
}
.grid td:last-child {
border-right: none;
}
}
@media print {
.masthead__actions,
.tray,
.toast {
display: none !important;
}
body {
background: #fff;
}
.grid {
min-width: 0;
}
}(function () {
"use strict";
/* ---------------- Data (fictional) ---------------- */
var RECIPES = [
{ id: "shak", title: "Saffron Shakshuka", emoji: "🍳", ph: "ph-tomato", time: 25, serves: 2,
items: ["Eggs", "Crushed tomatoes", "Bell pepper", "Saffron", "Feta"] },
{ id: "ribo", title: "Tuscan Ribollita", emoji: "🥘", ph: "ph-sage", time: 50, serves: 4,
items: ["Cannellini beans", "Kale", "Stale bread", "Carrots", "Onion"] },
{ id: "gnoc", title: "Brown-Butter Gnocchi", emoji: "🍝", ph: "ph-saffron", time: 30, serves: 3,
items: ["Potato gnocchi", "Butter", "Sage", "Parmesan"] },
{ id: "tagi", title: "Apricot Lamb Tagine", emoji: "🍲", ph: "ph-clay", time: 75, serves: 4,
items: ["Lamb shoulder", "Dried apricots", "Onion", "Cumin", "Almonds"] },
{ id: "porr", title: "Maple Oat Porridge", emoji: "🥣", ph: "ph-saffron", time: 12, serves: 1,
items: ["Rolled oats", "Milk", "Maple syrup", "Walnuts"] },
{ id: "ceas", title: "Charred Caesar", emoji: "🥗", ph: "ph-sage", time: 18, serves: 2,
items: ["Romaine", "Sourdough", "Parmesan", "Anchovy", "Lemon"] },
{ id: "ramn", title: "Miso Mushroom Ramen", emoji: "🍜", ph: "ph-ocean", time: 35, serves: 2,
items: ["Ramen noodles", "Miso paste", "Shiitake", "Scallion", "Soft egg"] },
{ id: "pana", title: "Vanilla Panna Cotta", emoji: "🍮", ph: "ph-clay", time: 20, serves: 4,
items: ["Cream", "Gelatin", "Vanilla", "Sugar"] },
{ id: "taco", title: "Charred Corn Tacos", emoji: "🌮", ph: "ph-tomato", time: 22, serves: 3,
items: ["Corn tortillas", "Sweetcorn", "Lime", "Cotija", "Cilantro"] },
{ id: "berr", title: "Berry Yogurt Bowl", emoji: "🫐", ph: "ph-plum", time: 6, serves: 1,
items: ["Greek yogurt", "Blueberries", "Granola", "Honey"] }
];
function recipeById(id) {
for (var i = 0; i < RECIPES.length; i++) if (RECIPES[i].id === id) return RECIPES[i];
return null;
}
var DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
var MEALS = [
{ id: "breakfast", label: "Breakfast", emoji: "☀️" },
{ id: "lunch", label: "Lunch", emoji: "🥪" },
{ id: "dinner", label: "Dinner", emoji: "🌙" }
];
var STORE_KEY = "stealthis.mealPlanner.v1";
/* ---------------- State ---------------- */
// plan[dayIndex][mealId] = recipeId | null
var plan = load();
var picked = null; // keyboard "pick up" state: recipe id
function emptyPlan() {
return DAYS.map(function () {
var d = {};
MEALS.forEach(function (m) { d[m.id] = null; });
return d;
});
}
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (!raw) return emptyPlan();
var parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length !== DAYS.length) return emptyPlan();
return parsed;
} catch (e) {
return emptyPlan();
}
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(plan)); } catch (e) {}
}
/* ---------------- DOM refs ---------------- */
var trayEl = document.getElementById("tray");
var gridEl = document.getElementById("grid");
var toastEl = document.getElementById("toast");
var liveEl = document.getElementById("liveRegion");
/* ---------------- Helpers ---------------- */
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-show"); }, 2200);
}
function announce(msg) { liveEl.textContent = ""; liveEl.textContent = msg; }
/* ---------------- Render tray ---------------- */
function renderTray() {
trayEl.innerHTML = "";
RECIPES.forEach(function (r) {
var li = document.createElement("li");
li.className = "card";
li.tabIndex = 0;
li.setAttribute("draggable", "true");
li.dataset.recipe = r.id;
li.setAttribute("role", "button");
li.setAttribute("aria-label",
r.title + ", " + r.time + " minutes, serves " + r.serves + ". Press Enter to pick up.");
li.innerHTML =
'<span class="card__photo ' + r.ph + '" aria-hidden="true">' + r.emoji + "</span>" +
'<span class="card__body">' +
'<span class="card__title">' + r.title + "</span>" +
'<span class="card__meta"><span>⏱ ' + r.time + " min</span>" +
'<span class="dot">🍽 serves ' + r.serves + "</span></span>" +
"</span>";
li.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", r.id);
e.dataTransfer.effectAllowed = "copy";
li.classList.add("is-dragging");
});
li.addEventListener("dragend", function () { li.classList.remove("is-dragging"); });
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
pickUp(r.id, li);
}
});
trayEl.appendChild(li);
});
}
/* ---------------- Render grid ---------------- */
function renderGrid() {
var thead = gridEl.querySelector("thead tr");
// remove existing day headers (keep corner)
while (thead.children.length > 1) thead.removeChild(thead.lastChild);
DAYS.forEach(function (day, di) {
var th = document.createElement("th");
th.scope = "col";
th.className = "day-th";
th.id = "day-" + di;
th.innerHTML =
'<div class="day-th__name">' + day + "</div>" +
'<div class="day-th__sum" id="sum-' + di + '"></div>';
thead.appendChild(th);
});
var tbody = gridEl.querySelector("tbody");
tbody.innerHTML = "";
MEALS.forEach(function (meal) {
var tr = document.createElement("tr");
var th = document.createElement("th");
th.scope = "row";
th.className = "meal-th";
th.id = "meal-" + meal.id;
th.innerHTML = '<span class="emoji" aria-hidden="true">' + meal.emoji + "</span>" + meal.label;
tr.appendChild(th);
DAYS.forEach(function (day, di) {
var td = document.createElement("td");
td.className = "slot";
var drop = document.createElement("div");
drop.className = "slot__drop";
drop.dataset.day = di;
drop.dataset.meal = meal.id;
drop.setAttribute("role", "button");
drop.tabIndex = 0;
drop.setAttribute("aria-labelledby", "day-" + di + " meal-" + meal.id);
wireDrop(drop);
td.appendChild(drop);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
renderAllSlots();
}
function wireDrop(drop) {
drop.addEventListener("dragover", function (e) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
drop.classList.add("is-over");
});
drop.addEventListener("dragleave", function () { drop.classList.remove("is-over"); });
drop.addEventListener("drop", function (e) {
e.preventDefault();
drop.classList.remove("is-over");
var id = e.dataTransfer.getData("text/plain");
if (id) place(+drop.dataset.day, drop.dataset.meal, id);
});
drop.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (picked) {
place(+drop.dataset.day, drop.dataset.meal, picked);
clearPicked();
} else {
var cur = plan[+drop.dataset.day][drop.dataset.meal];
if (cur) {
// pick up the placed recipe to move it
pickUp(cur, drop);
announce("Moving " + recipeById(cur).title + ". Choose a destination slot and press Enter.");
} else {
toast("Pick a recipe from the pantry first (press Enter on a card).");
}
}
} else if ((e.key === "Delete" || e.key === "Backspace") &&
plan[+drop.dataset.day][drop.dataset.meal]) {
e.preventDefault();
remove(+drop.dataset.day, drop.dataset.meal);
}
});
}
/* ---------------- Pick-up (keyboard) ---------------- */
function pickUp(id, fromEl) {
clearPicked();
picked = id;
if (fromEl && fromEl.classList) fromEl.classList.add("is-picked");
var r = recipeById(id);
announce(r.title + " picked up. Move to a slot and press Enter to place, Escape to cancel.");
toast("Picked up " + r.title + " — choose a slot.");
}
function clearPicked() {
picked = null;
var p = document.querySelectorAll(".is-picked");
for (var i = 0; i < p.length; i++) p[i].classList.remove("is-picked");
}
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") { if (picked) { clearPicked(); announce("Cancelled."); } closeSheet(); }
});
/* ---------------- Place / remove ---------------- */
function place(dayIdx, mealId, recipeId) {
if (!recipeById(recipeId)) return;
plan[dayIdx][mealId] = recipeId;
save();
renderSlot(dayIdx, mealId);
renderDaySummary(dayIdx);
var r = recipeById(recipeId);
toast(r.title + " → " + DAYS[dayIdx] + " " + mealLabel(mealId));
announce(r.title + " placed in " + DAYS[dayIdx] + " " + mealLabel(mealId) + ".");
}
function remove(dayIdx, mealId) {
var prev = plan[dayIdx][mealId];
plan[dayIdx][mealId] = null;
save();
renderSlot(dayIdx, mealId);
renderDaySummary(dayIdx);
if (prev) toast("Removed " + recipeById(prev).title);
var drop = dropEl(dayIdx, mealId);
if (drop) drop.focus();
}
function mealLabel(mealId) {
for (var i = 0; i < MEALS.length; i++) if (MEALS[i].id === mealId) return MEALS[i].label;
return mealId;
}
function dropEl(dayIdx, mealId) {
return gridEl.querySelector('.slot__drop[data-day="' + dayIdx + '"][data-meal="' + mealId + '"]');
}
/* ---------------- Render a single slot ---------------- */
function renderSlot(dayIdx, mealId) {
var drop = dropEl(dayIdx, mealId);
if (!drop) return;
drop.innerHTML = "";
var id = plan[dayIdx][mealId];
if (!id) {
drop.setAttribute("aria-label", DAYS[dayIdx] + " " + mealLabel(mealId) + ", empty. Press Enter to place a picked recipe.");
return;
}
var r = recipeById(id);
drop.removeAttribute("aria-label");
drop.setAttribute("aria-labelledby", "day-" + dayIdx + " meal-" + mealId);
var chip = document.createElement("div");
chip.className = "placed";
chip.setAttribute("draggable", "true");
chip.dataset.recipe = id;
chip.innerHTML =
'<span class="placed__photo ' + r.ph + '" aria-hidden="true">' + r.emoji + "</span>" +
'<span class="placed__body">' +
'<span class="placed__title">' + r.title + "</span>" +
'<span class="placed__meta">⏱ ' + r.time + " min</span>" +
"</span>" +
'<button type="button" class="placed__remove" aria-label="Remove ' + r.title + '">✕</button>';
chip.addEventListener("dragstart", function (e) {
e.dataTransfer.setData("text/plain", id);
e.dataTransfer.effectAllowed = "move";
chip.classList.add("is-dragging");
// moving: clear origin once dropped elsewhere
chip._origin = { day: dayIdx, meal: mealId };
});
chip.addEventListener("dragend", function () {
chip.classList.remove("is-dragging");
// if recipe still here AND was placed elsewhere, the drop handler placed a copy;
// turn move into a real move by clearing the source if target differs.
});
chip.querySelector(".placed__remove").addEventListener("click", function (ev) {
ev.stopPropagation();
remove(dayIdx, mealId);
});
// Support move semantics: when this chip is dropped on a different slot,
// clear this source slot. We detect via a custom listener on document.
chip.addEventListener("dragstart", function () {
window.__movingFrom = { day: dayIdx, meal: mealId, id: id };
});
drop.appendChild(chip);
}
function renderAllSlots() {
DAYS.forEach(function (_, di) {
MEALS.forEach(function (m) { renderSlot(di, m.id); });
renderDaySummary(di);
});
}
/* ---------------- Day summary ---------------- */
function renderDaySummary(dayIdx) {
var el = document.getElementById("sum-" + dayIdx);
if (!el) return;
var time = 0, serves = 0, count = 0;
MEALS.forEach(function (m) {
var id = plan[dayIdx][m.id];
if (id) {
var r = recipeById(id);
time += r.time;
serves = Math.max(serves, r.serves);
count++;
}
});
if (count === 0) {
el.innerHTML = "<span style='color:var(--muted)'>No meals yet</span>";
} else {
el.innerHTML = "<b>" + time + " min</b> total · serves <b>" + serves + "</b>";
}
}
/* ---------------- Move semantics (clear source on cross-slot drop) ----------------
HTML5 DnD copies the id into the target; to make a *move* we clear the original
source slot after a successful cross-slot drop. We hook the grid's drop. */
gridEl.addEventListener("drop", function (e) {
var src = window.__movingFrom;
if (!src) return;
var dropTarget = e.target.closest ? e.target.closest(".slot__drop") : null;
window.__movingFrom = null;
if (!dropTarget) return;
var tDay = +dropTarget.dataset.day, tMeal = dropTarget.dataset.meal;
if (tDay === src.day && tMeal === src.meal) return; // same slot, no-op
// target was filled by place(); now clear source
plan[src.day][src.meal] = null;
save();
renderSlot(src.day, src.meal);
renderDaySummary(src.day);
}, true);
/* ---------------- Clear week ---------------- */
document.getElementById("clearBtn").addEventListener("click", function () {
var any = plan.some(function (d) { return MEALS.some(function (m) { return d[m.id]; }); });
if (!any) { toast("Your week is already empty."); return; }
plan = emptyPlan();
save();
renderAllSlots();
toast("Cleared the whole week.");
announce("Week cleared.");
});
/* ---------------- Shopping list ---------------- */
var overlay = document.getElementById("overlay");
var sheetBody = document.getElementById("sheetBody");
document.getElementById("listBtn").addEventListener("click", buildShoppingList);
document.getElementById("sheetClose").addEventListener("click", closeSheet);
overlay.addEventListener("click", function (e) { if (e.target === overlay) closeSheet(); });
function buildShoppingList() {
var counts = {};
var meals = 0;
plan.forEach(function (d) {
MEALS.forEach(function (m) {
var id = d[m.id];
if (!id) return;
meals++;
recipeById(id).items.forEach(function (it) {
counts[it] = (counts[it] || 0) + 1;
});
});
});
var keys = Object.keys(counts).sort();
if (!keys.length) {
sheetBody.innerHTML = '<p class="shop-empty">No meals planned yet — drag some recipes into the week, then come back.</p>';
} else {
var html = '<ul class="shop-list">';
keys.forEach(function (k) {
var n = counts[k];
html += "<li><span>" + k + "</span><span class='qty'>×" + n + "</span></li>";
});
html += "</ul>";
html += '<p class="shop-total">' + keys.length + " ingredients across " + meals + " planned meals.</p>";
sheetBody.innerHTML = html;
}
overlay.hidden = false;
document.getElementById("sheetClose").focus();
}
function closeSheet() { overlay.hidden = true; }
/* ---------------- Boot ---------------- */
renderTray();
renderGrid();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Weekly Meal Planner — Cookbook</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>
<header class="masthead" role="banner">
<div class="masthead__inner">
<p class="kicker">The Stealthis Cookbook</p>
<h1>Weekly Meal Planner</h1>
<p class="masthead__sub">
Drag recipes from the pantry into your week. We tally cook time, servings,
and a shopping list as you go.
</p>
</div>
<div class="masthead__actions">
<button type="button" class="btn btn--ghost" id="clearBtn">Clear week</button>
<button type="button" class="btn btn--primary" id="listBtn">Generate shopping list</button>
</div>
</header>
<div class="layout">
<!-- Recipe tray -->
<aside class="tray" aria-label="Recipe pantry">
<div class="tray__head">
<h2>Pantry</h2>
<p class="tray__hint">Drag a card onto a slot, or press <kbd>Enter</kbd> to pick it up.</p>
</div>
<ul class="tray__list" id="tray" role="list"></ul>
<p class="tray__note" aria-hidden="true">🍅🥕🧄🍋🌿 Fictional recipes, for layout demo.</p>
</aside>
<!-- Planner grid -->
<main class="board" role="main" aria-label="Weekly planner">
<div class="board__scroll">
<table class="grid" id="grid">
<thead>
<tr>
<th scope="col" class="grid__corner"><span>Meal</span></th>
<!-- day headers injected -->
</tr>
</thead>
<tbody>
<!-- meal rows injected -->
</tbody>
</table>
</div>
</main>
</div>
<!-- Shopping list dialog -->
<div class="overlay" id="overlay" hidden>
<div class="sheet" role="dialog" aria-modal="true" aria-labelledby="sheetTitle">
<header class="sheet__head">
<div>
<p class="kicker">From your week</p>
<h2 id="sheetTitle">Shopping list</h2>
</div>
<button type="button" class="iconbtn" id="sheetClose" aria-label="Close shopping list">✕</button>
</header>
<div class="sheet__body" id="sheetBody"></div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="visually-hidden" id="liveRegion" aria-live="assertive"></div>
<script src="script.js"></script>
</body>
</html>Weekly Meal Planner (drag recipes)
A two-pane cookbook planner. On the left, a sticky pantry tray holds recipe cards with gradient “photo” thumbnails, cook time, and serving counts. On the right, a seven-day by three-meal grid (Breakfast, Lunch, Dinner) waits for you to drag a card into any slot — drop targets light up in saffron as you hover, and each day’s header keeps a running tally of total cook time and servings.
Everything works without a mouse. Press Enter on a recipe card to pick it up, move to a slot, and press Enter again to place it; placed recipes can be picked up the same way to move them, and Delete clears a focused slot. An aria-live region narrates every pick, place, and removal. The whole week persists to localStorage, so a refresh keeps your plan.
Two header actions round it out: Clear week wipes the grid, and Generate shopping list opens a sheet that aggregates and counts every ingredient across your planned meals. The layout reflows from a side-by-side board into stacked, card-style day blocks under 720px, and hides the chrome for clean printing.
Illustrative UI only — recipes & nutrition data are fictional, not dietary advice.