Cookbook — Recipe Editor (ingredients + steps builder)
A warm, editorial recipe-builder workspace where you compose a dish from scratch: title, description, and meta like prep, cook, servings, difficulty and diet, plus a reorderable ingredients table (quantity, unit, name) and numbered method steps you can drag, nudge or remove. A live preview pane renders a print-ready recipe card that scales quantities by servings as you type, with draft autosave to localStorage and a validated save flow.
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-sm: 0 1px 2px rgba(43, 38, 34, 0.1);
--sh-lg: 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);
background: var(--cream);
color: var(--ink);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 { font-family: var(--serif); font-weight: 600; line-height: 1.2; margin: 0; }
button { font: inherit; cursor: pointer; }
.kicker {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--clay);
}
/* ---------- Topbar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.85rem 1.4rem;
background: rgba(255, 253, 248, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 0.7rem; }
.brand-mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 1.4rem;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #ffd9c4, var(--clay));
box-shadow: var(--sh-sm);
}
.brand-text h1 { font-size: 1.35rem; }
.topbar-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.save-state { font-size: 0.78rem; color: var(--muted); }
/* ---------- Buttons ---------- */
.btn {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.55rem 1.1rem;
font-size: 0.86rem;
font-weight: 600;
transition: transform 0.12s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 3px solid rgba(214, 69, 43, 0.35); outline-offset: 2px; }
.btn-primary { background: var(--tomato); color: #fff; box-shadow: var(--sh-sm); }
.btn-primary:hover { background: var(--tomato-d); }
.btn-ghost { background: transparent; color: var(--ink-2); border-color: var(--line-2); }
.btn-ghost:hover { background: rgba(43, 38, 34, 0.05); }
.btn-add {
background: var(--paper);
border: 1px dashed var(--line-2);
color: var(--ink-2);
border-radius: var(--r-md);
padding: 0.6rem 1rem;
width: 100%;
font-weight: 600;
}
.btn-add:hover { border-color: var(--clay); color: var(--tomato-d); background: #fff; }
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
gap: 1.6rem;
max-width: 1280px;
margin: 0 auto;
padding: 1.6rem 1.4rem 4rem;
align-items: start;
}
/* ---------- Editor cards ---------- */
.editor { display: grid; gap: 1.2rem; min-width: 0; }
.card {
border: 1px solid var(--line);
background: var(--paper);
border-radius: var(--r-lg);
padding: 1.3rem 1.4rem 1.5rem;
margin: 0;
box-shadow: var(--sh-sm);
}
.card-legend {
font-family: var(--serif);
font-size: 1.1rem;
font-weight: 600;
padding: 0 0.5rem 0 0;
color: var(--ink);
}
.card-sub { margin: 0.1rem 0 1rem; font-size: 0.82rem; color: var(--muted); }
.field { display: grid; gap: 0.35rem; margin-bottom: 0.9rem; }
.field:last-child { margin-bottom: 0; }
label { font-size: 0.78rem; font-weight: 600; color: var(--ink-2); }
input, textarea, select {
font: inherit;
font-size: 0.92rem;
color: var(--ink);
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0.6rem 0.7rem;
width: 100%;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--clay);
background: #fff;
box-shadow: 0 0 0 3px rgba(200, 119, 90, 0.18);
}
textarea { resize: vertical; }
.field.invalid input { border-color: var(--danger); box-shadow: 0 0 0 3px rgba(200, 65, 43, 0.16); }
.hint { margin: 0; font-size: 0.76rem; color: var(--danger); min-height: 0.5rem; }
.meta-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.8rem;
}
.meta-grid .field { margin-bottom: 0; }
.field-wide { grid-column: 1 / -1; }
/* ---------- Builder rows ---------- */
.builder { list-style: none; margin: 0 0 0.9rem; padding: 0; display: grid; gap: 0.55rem; }
.row {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: start;
gap: 0.5rem;
background: var(--cream);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 0.55rem 0.6rem;
transition: box-shadow 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
}
.row.dragging { opacity: 0.5; box-shadow: var(--sh-lg); border-color: var(--clay); }
.row.drag-over { border-color: var(--tomato); box-shadow: inset 0 0 0 2px rgba(214, 69, 43, 0.25); }
.ing-row { grid-template-columns: auto 64px 90px 1fr auto; }
.step-row { grid-template-columns: auto auto 1fr auto; }
.handle {
cursor: grab;
align-self: center;
color: var(--muted);
background: transparent;
border: none;
padding: 0.2rem 0.3rem;
font-size: 1rem;
line-height: 1;
border-radius: var(--r-sm);
touch-action: none;
}
.handle:hover { color: var(--ink); background: rgba(43, 38, 34, 0.06); }
.handle:active { cursor: grabbing; }
.step-num {
align-self: start;
width: 26px;
height: 26px;
margin-top: 0.4rem;
display: grid;
place-items: center;
font-family: var(--serif);
font-weight: 600;
font-size: 0.85rem;
color: #fff;
background: var(--tomato);
border-radius: 50%;
}
.row input, .row textarea { background: #fff; }
.row .qty { padding-inline: 0.5rem; }
.row textarea { rows: 2; min-height: 2.6rem; }
.row-controls { display: grid; gap: 0.25rem; align-self: center; }
.icon-btn {
width: 28px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid var(--line);
background: var(--paper);
border-radius: var(--r-sm);
color: var(--ink-2);
font-size: 0.8rem;
line-height: 1;
}
.icon-btn:hover { border-color: var(--line-2); background: #fff; color: var(--ink); }
.icon-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.icon-btn.remove:hover { color: var(--danger); border-color: var(--danger); }
/* ---------- Preview ---------- */
.preview-wrap { position: sticky; top: 80px; display: grid; gap: 0.9rem; min-width: 0; }
.preview-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.scaler {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--paper);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-2);
}
.step-btn {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--line-2);
background: var(--cream);
color: var(--ink);
font-size: 1rem;
line-height: 1;
}
.step-btn:hover { background: #fff; border-color: var(--clay); }
.recipe-card {
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--sh-lg);
}
.hero {
position: relative;
height: 180px;
background:
radial-gradient(circle at 22% 30%, rgba(255, 255, 255, 0.45), transparent 40%),
radial-gradient(circle at 78% 22%, #ffcaa6, transparent 45%),
radial-gradient(circle at 60% 78%, #e7b14b, transparent 50%),
linear-gradient(135deg, var(--tomato), var(--clay) 60%, var(--saffron));
display: grid;
place-items: center;
}
.hero-emoji { font-size: 4rem; filter: drop-shadow(0 6px 10px rgba(43, 38, 34, 0.25)); }
.hero-badge {
position: absolute;
top: 0.8rem;
right: 0.8rem;
background: rgba(255, 253, 248, 0.92);
color: var(--ink);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 0.3rem 0.7rem;
border-radius: 999px;
box-shadow: var(--sh-sm);
}
.card-body { padding: 1.3rem 1.5rem 1.5rem; }
.card-body h2 { font-size: 1.55rem; margin: 0.3rem 0 0.5rem; }
.card-desc { color: var(--ink-2); margin: 0 0 1.1rem; font-size: 0.95rem; }
.card-meta {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
margin: 0 0 1.3rem;
padding: 0.9rem 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.card-meta div { text-align: center; }
.card-meta dt { font-size: 0.66rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.card-meta dd { margin: 0.15rem 0 0; font-family: var(--serif); font-weight: 600; font-size: 1.05rem; }
.card-h {
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--clay);
margin: 0 0 0.6rem;
}
.card-ings { list-style: none; margin: 0 0 1.4rem; padding: 0; display: grid; gap: 0.4rem; }
.card-ings li {
display: flex;
gap: 0.55rem;
align-items: baseline;
font-size: 0.92rem;
padding-bottom: 0.4rem;
border-bottom: 1px dotted var(--line);
}
.card-ings .amt { font-weight: 600; color: var(--tomato-d); white-space: nowrap; min-width: 4.5rem; }
.card-ings .empty, .card-steps .empty { color: var(--muted); font-style: italic; }
.card-steps { margin: 0 0 1.2rem; padding-left: 1.3rem; display: grid; gap: 0.7rem; }
.card-steps li { font-size: 0.93rem; color: var(--ink-2); padding-left: 0.2rem; }
.card-steps li::marker { font-family: var(--serif); font-weight: 600; color: var(--tomato); }
.card-foot { font-size: 0.8rem; color: var(--muted); border-top: 1px solid var(--line); padding-top: 0.9rem; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 1.4rem;
transform: translateX(-50%) translateY(140%);
background: var(--ink);
color: var(--paper);
padding: 0.7rem 1.2rem;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 500;
box-shadow: var(--sh-lg);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.3s ease;
z-index: 50;
max-width: 90vw;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
.toast.err { background: var(--danger); }
.toast.ok { background: var(--ok); }
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
.layout { grid-template-columns: 1fr; }
.preview-wrap { position: static; }
}
@media (max-width: 720px) {
.meta-grid { grid-template-columns: repeat(2, 1fr); }
.ing-row { grid-template-columns: auto 1fr auto; }
.ing-row .qty, .ing-row .unit { grid-column: auto; }
}
@media (max-width: 540px) {
.topbar { padding: 0.7rem 1rem; }
.brand-text h1 { font-size: 1.15rem; }
.card-meta { grid-template-columns: repeat(2, 1fr); }
.ing-row { grid-template-columns: auto 1fr; row-gap: 0.4rem; }
.ing-row .handle { grid-row: span 3; }
.ing-row .name { grid-column: 2; }
.ing-row .qty, .ing-row .unit { grid-column: 2; }
.ing-row .row-controls { grid-column: 2; grid-auto-flow: column; justify-content: start; }
}
@media print {
.topbar, .editor, .preview-bar, .toast { display: none !important; }
body { background: #fff; }
.layout { display: block; padding: 0; }
.recipe-card { box-shadow: none; border: none; }
}(function () {
"use strict";
var STORAGE_KEY = "cookbook.recipe-editor.draft.v1";
var form = document.getElementById("recipeForm");
var ingList = document.getElementById("ingList");
var stepList = document.getElementById("stepList");
var toastEl = document.getElementById("toast");
var saveState = document.getElementById("saveState");
/* ---------- toast ---------- */
var toastTimer;
function toast(msg, kind) {
toastEl.textContent = msg;
toastEl.className = "toast show" + (kind ? " " + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.className = "toast";
}, 2600);
}
/* ---------- state ---------- */
var defaults = {
title: "Sun-Roasted Tomato & Basil Galette",
desc: "A blistered, jammy tomato tart with a buttery rye crust and torn basil.",
prep: 20,
cook: 35,
servings: 4,
difficulty: "Medium",
diet: "Vegetarian · Nut-free",
ingredients: [
{ qty: "300", unit: "g", name: "rye + plain flour blend" },
{ qty: "150", unit: "g", name: "cold butter, cubed" },
{ qty: "6", unit: "", name: "ripe vine tomatoes" },
{ qty: "1", unit: "handful", name: "basil leaves, torn" },
{ qty: "2", unit: "tbsp", name: "olive oil" }
],
steps: [
"Rub butter into the flour until sandy, add iced water and rest the dough 30 min.",
"Slice tomatoes, salt lightly and drain on a towel for 10 minutes.",
"Roll the pastry, layer tomatoes, fold the rim and brush with oil.",
"Bake at 200°C until the crust is deep gold, then scatter with basil."
]
};
var baseServings = defaults.servings;
var scale = defaults.servings;
var idSeq = 0;
function uid() { return "r" + ++idSeq; }
/* ---------- generic row builders ---------- */
function makeIngRow(data) {
var li = document.createElement("li");
li.className = "row ing-row";
li.draggable = false;
li.dataset.id = uid();
li.innerHTML =
'<button type="button" class="handle" aria-label="Drag to reorder" title="Drag to reorder">⠿</button>' +
'<input class="qty" type="text" inputmode="decimal" placeholder="Qty" aria-label="Quantity" />' +
'<input class="unit" type="text" placeholder="Unit" aria-label="Unit" />' +
'<input class="name" type="text" placeholder="Ingredient" aria-label="Ingredient name" />' +
rowControls();
li.querySelector(".qty").value = data.qty || "";
li.querySelector(".unit").value = data.unit || "";
li.querySelector(".name").value = data.name || "";
wireRow(li, ingList);
return li;
}
function makeStepRow(text) {
var li = document.createElement("li");
li.className = "row step-row";
li.dataset.id = uid();
li.innerHTML =
'<button type="button" class="handle" aria-label="Drag to reorder" title="Drag to reorder">⠿</button>' +
'<span class="step-num">1</span>' +
'<textarea rows="2" placeholder="Describe this step…" aria-label="Step instruction"></textarea>' +
rowControls();
li.querySelector("textarea").value = text || "";
wireRow(li, stepList);
return li;
}
function rowControls() {
return (
'<div class="row-controls">' +
'<button type="button" class="icon-btn up" aria-label="Move up" title="Move up">▲</button>' +
'<button type="button" class="icon-btn down" aria-label="Move down" title="Move down">▼</button>' +
'<button type="button" class="icon-btn remove" aria-label="Remove row" title="Remove">✕</button>' +
'</div>'
);
}
/* ---------- row wiring (input, move, remove, drag) ---------- */
function wireRow(li, list) {
li.addEventListener("input", function () { render(); persist(); });
li.querySelector(".up").addEventListener("click", function () {
if (li.previousElementSibling) {
list.insertBefore(li, li.previousElementSibling);
syncAfterMove(list);
}
});
li.querySelector(".down").addEventListener("click", function () {
if (li.nextElementSibling) {
list.insertBefore(li.nextElementSibling, li);
syncAfterMove(list);
}
});
li.querySelector(".remove").addEventListener("click", function () {
li.remove();
syncAfterMove(list);
toast("Row removed");
});
var handle = li.querySelector(".handle");
handle.addEventListener("mousedown", function () { li.draggable = true; });
handle.addEventListener("touchstart", function () { li.draggable = true; }, { passive: true });
li.addEventListener("dragend", function () { li.draggable = false; });
li.addEventListener("dragstart", function (e) {
dragSrc = li;
li.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try { e.dataTransfer.setData("text/plain", li.dataset.id); } catch (_) {}
});
li.addEventListener("dragover", function (e) {
e.preventDefault();
if (dragSrc && dragSrc !== li && dragSrc.parentNode === list) {
li.classList.add("drag-over");
e.dataTransfer.dropEffect = "move";
}
});
li.addEventListener("dragleave", function () { li.classList.remove("drag-over"); });
li.addEventListener("drop", function (e) {
e.preventDefault();
li.classList.remove("drag-over");
if (dragSrc && dragSrc !== li && dragSrc.parentNode === list) {
var rect = li.getBoundingClientRect();
var after = e.clientY > rect.top + rect.height / 2;
list.insertBefore(dragSrc, after ? li.nextElementSibling : li);
syncAfterMove(list);
}
});
li.addEventListener("dragend", function () {
li.classList.remove("dragging");
li.draggable = false;
});
}
var dragSrc = null;
function syncAfterMove(list) {
renumberSteps();
updateMoveButtons(list);
render();
persist();
}
function renumberSteps() {
var nums = stepList.querySelectorAll(".step-num");
for (var i = 0; i < nums.length; i++) nums[i].textContent = i + 1;
}
function updateMoveButtons(list) {
var rows = list.children;
for (var i = 0; i < rows.length; i++) {
rows[i].querySelector(".up").disabled = i === 0;
rows[i].querySelector(".down").disabled = i === rows.length - 1;
}
}
/* ---------- add buttons ---------- */
document.getElementById("addIng").addEventListener("click", function () {
ingList.appendChild(makeIngRow({}));
updateMoveButtons(ingList);
var inputs = ingList.lastElementChild.querySelectorAll("input");
inputs[0].focus();
render(); persist();
});
document.getElementById("addStep").addEventListener("click", function () {
stepList.appendChild(makeStepRow(""));
renumberSteps();
updateMoveButtons(stepList);
stepList.lastElementChild.querySelector("textarea").focus();
render(); persist();
});
/* ---------- form fields ---------- */
["f-title", "f-desc", "f-prep", "f-cook", "f-servings", "f-diff", "f-diet"].forEach(function (id) {
document.getElementById(id).addEventListener("input", function () {
if (id === "f-servings") {
var n = parseInt(document.getElementById("f-servings").value, 10);
if (n > 0) { baseServings = n; scale = n; }
}
render(); persist();
});
});
/* ---------- scaler ---------- */
document.getElementById("scaleUp").addEventListener("click", function () {
scale = Math.min(99, scale + 1); render(); persist();
});
document.getElementById("scaleDown").addEventListener("click", function () {
scale = Math.max(1, scale - 1); render(); persist();
});
function scaleQty(qty) {
if (baseServings <= 0) return qty;
var num = parseFloat(qty);
if (isNaN(num)) return qty;
var scaled = (num * scale) / baseServings;
var rounded = Math.round(scaled * 100) / 100;
return (qty + "").replace(/^[\d.]+/, "" + rounded);
}
/* ---------- render preview ---------- */
function val(id) { return document.getElementById(id).value.trim(); }
function render() {
var title = val("f-title") || "Untitled recipe";
var desc = val("f-desc") || "A delicious dish waiting for its story.";
var prep = parseInt(val("f-prep"), 10) || 0;
var cook = parseInt(val("f-cook"), 10) || 0;
var diff = val("f-diff") || "Medium";
var diet = val("f-diet");
document.getElementById("cardTitle").textContent = title;
document.getElementById("cardDesc").textContent = desc;
document.getElementById("cardPrep").textContent = prep + " min";
document.getElementById("cardCook").textContent = cook + " min";
document.getElementById("cardTotal").textContent = (prep + cook) + " min";
document.getElementById("cardServes").textContent = scale;
document.getElementById("cardDiff").textContent = diff;
var dietEl = document.getElementById("cardDiet");
dietEl.textContent = diet || "Cookbook recipe";
document.getElementById("scaleVal").textContent =
scale + " serving" + (scale === 1 ? "" : "s");
// ingredients
var ingsOut = document.getElementById("cardIngs");
ingsOut.innerHTML = "";
var ingRows = ingList.querySelectorAll(".ing-row");
var any = false;
ingRows.forEach(function (row) {
var qty = row.querySelector(".qty").value.trim();
var unit = row.querySelector(".unit").value.trim();
var name = row.querySelector(".name").value.trim();
if (!qty && !unit && !name) return;
any = true;
var li = document.createElement("li");
var amt = document.createElement("span");
amt.className = "amt";
amt.textContent = [scaleQty(qty), unit].filter(Boolean).join(" ") || "—";
var nm = document.createElement("span");
nm.textContent = name || "(ingredient)";
li.appendChild(amt);
li.appendChild(nm);
ingsOut.appendChild(li);
});
if (!any) ingsOut.innerHTML = '<li class="empty">Add ingredients to see them here.</li>';
// steps
var stepsOut = document.getElementById("cardSteps");
stepsOut.innerHTML = "";
var stepRows = stepList.querySelectorAll(".step-row textarea");
var anyStep = false;
stepRows.forEach(function (ta) {
var t = ta.value.trim();
if (!t) return;
anyStep = true;
var li = document.createElement("li");
li.textContent = t;
stepsOut.appendChild(li);
});
if (!anyStep) stepsOut.innerHTML = '<li class="empty">Write the method, step by step.</li>';
}
/* ---------- persistence ---------- */
function collect() {
var ings = [];
ingList.querySelectorAll(".ing-row").forEach(function (row) {
ings.push({
qty: row.querySelector(".qty").value,
unit: row.querySelector(".unit").value,
name: row.querySelector(".name").value
});
});
var steps = [];
stepList.querySelectorAll(".step-row textarea").forEach(function (ta) {
steps.push(ta.value);
});
return {
title: val("f-title"), desc: val("f-desc"),
prep: val("f-prep"), cook: val("f-cook"),
servings: baseServings, difficulty: val("f-diff"), diet: val("f-diet"),
ingredients: ings, steps: steps
};
}
var persistTimer;
function persist() {
clearTimeout(persistTimer);
persistTimer = setTimeout(function () {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(collect()));
saveState.textContent = "Draft saved · " +
new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
} catch (_) {
saveState.textContent = "Draft not saved (storage off)";
}
}, 350);
}
/* ---------- load ---------- */
function hydrate(data) {
document.getElementById("f-title").value = data.title || "";
document.getElementById("f-desc").value = data.desc || "";
document.getElementById("f-prep").value = data.prep != null ? data.prep : "";
document.getElementById("f-cook").value = data.cook != null ? data.cook : "";
document.getElementById("f-servings").value = data.servings != null ? data.servings : "";
document.getElementById("f-diff").value = data.difficulty || "Medium";
document.getElementById("f-diet").value = data.diet || "";
baseServings = parseInt(data.servings, 10) || 4;
scale = baseServings;
ingList.innerHTML = "";
(data.ingredients && data.ingredients.length ? data.ingredients : [{}]).forEach(function (i) {
ingList.appendChild(makeIngRow(i));
});
stepList.innerHTML = "";
(data.steps && data.steps.length ? data.steps : [""]).forEach(function (s) {
stepList.appendChild(makeStepRow(s));
});
renumberSteps();
updateMoveButtons(ingList);
updateMoveButtons(stepList);
render();
}
function loadDraft() {
var raw = null;
try { raw = localStorage.getItem(STORAGE_KEY); } catch (_) {}
if (raw) {
try { hydrate(JSON.parse(raw)); saveState.textContent = "Draft restored"; return; }
catch (_) {}
}
hydrate(defaults);
}
/* ---------- save with validation ---------- */
document.getElementById("saveBtn").addEventListener("click", function () {
var titleField = document.getElementById("f-title").closest(".field");
var titleErr = document.getElementById("titleErr");
var errors = [];
if (!val("f-title")) {
titleField.classList.add("invalid");
titleErr.textContent = "Give your recipe a title.";
errors.push("title");
} else {
titleField.classList.remove("invalid");
titleErr.textContent = "";
}
var hasIng = false;
ingList.querySelectorAll(".ing-row .name").forEach(function (n) {
if (n.value.trim()) hasIng = true;
});
var hasStep = false;
stepList.querySelectorAll(".step-row textarea").forEach(function (s) {
if (s.value.trim()) hasStep = true;
});
if (!hasIng) errors.push("ingredients");
if (!hasStep) errors.push("steps");
if (errors.length) {
var msg = errors.indexOf("title") > -1
? "Add a title before saving."
: "Add at least one ingredient and one step.";
toast(msg, "err");
if (errors.indexOf("title") > -1) document.getElementById("f-title").focus();
return;
}
persist();
toast("Recipe saved 🍅", "ok");
});
/* ---------- reset ---------- */
document.getElementById("resetBtn").addEventListener("click", function () {
if (!confirm("Clear the editor and load the sample recipe?")) return;
try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
hydrate(defaults);
document.getElementById("f-title").closest(".field").classList.remove("invalid");
document.getElementById("titleErr").textContent = "";
persist();
toast("Reset to sample recipe");
});
loadDraft();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Recipe Editor — 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="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">🍅</span>
<div class="brand-text">
<span class="kicker">Cookbook Studio</span>
<h1>Recipe Editor</h1>
</div>
</div>
<div class="topbar-actions">
<span id="saveState" class="save-state" aria-live="polite">Draft saved locally</span>
<button type="button" class="btn btn-ghost" id="resetBtn">Clear</button>
<button type="button" class="btn btn-primary" id="saveBtn">Save recipe</button>
</div>
</header>
<main class="layout">
<!-- EDITOR -->
<section class="editor" aria-label="Recipe editor">
<form id="recipeForm" novalidate>
<fieldset class="card">
<legend class="card-legend">The basics</legend>
<div class="field">
<label for="f-title">Recipe title</label>
<input id="f-title" name="title" type="text" maxlength="80"
placeholder="e.g. Sun-Roasted Tomato & Basil Galette" autocomplete="off" />
<p class="hint" id="titleErr" aria-live="polite"></p>
</div>
<div class="field">
<label for="f-desc">Short description</label>
<textarea id="f-desc" name="desc" rows="2" maxlength="220"
placeholder="A blistered, jammy tomato tart with a buttery rye crust and torn basil."></textarea>
</div>
</fieldset>
<fieldset class="card">
<legend class="card-legend">Meta</legend>
<div class="meta-grid">
<div class="field">
<label for="f-prep">Prep (min)</label>
<input id="f-prep" name="prep" type="number" min="0" max="999" inputmode="numeric" placeholder="20" />
</div>
<div class="field">
<label for="f-cook">Cook (min)</label>
<input id="f-cook" name="cook" type="number" min="0" max="999" inputmode="numeric" placeholder="35" />
</div>
<div class="field">
<label for="f-servings">Servings</label>
<input id="f-servings" name="servings" type="number" min="1" max="99" inputmode="numeric" placeholder="4" />
</div>
<div class="field">
<label for="f-diff">Difficulty</label>
<select id="f-diff" name="difficulty">
<option value="Easy">Easy</option>
<option value="Medium" selected>Medium</option>
<option value="Hard">Hard</option>
</select>
</div>
<div class="field field-wide">
<label for="f-diet">Diet / tags</label>
<input id="f-diet" name="diet" type="text" maxlength="60"
placeholder="Vegetarian · Nut-free" autocomplete="off" />
</div>
</div>
</fieldset>
<fieldset class="card">
<legend class="card-legend">Ingredients</legend>
<p class="card-sub">Drag the handle to reorder, or use the arrows. Quantities scale in the preview.</p>
<ol class="builder" id="ingList" aria-label="Ingredient rows"></ol>
<button type="button" class="btn btn-add" id="addIng">+ Add ingredient</button>
</fieldset>
<fieldset class="card">
<legend class="card-legend">Method</legend>
<p class="card-sub">Numbered steps. Reorder to refine the flow.</p>
<ol class="builder" id="stepList" aria-label="Method steps"></ol>
<button type="button" class="btn btn-add" id="addStep">+ Add step</button>
</fieldset>
</form>
</section>
<!-- PREVIEW -->
<section class="preview-wrap" aria-label="Live preview">
<div class="preview-bar">
<span class="kicker">Live preview</span>
<div class="scaler" role="group" aria-label="Scale servings">
<button type="button" class="step-btn" id="scaleDown" aria-label="Fewer servings">−</button>
<span id="scaleVal" aria-live="polite">4 servings</span>
<button type="button" class="step-btn" id="scaleUp" aria-label="More servings">+</button>
</div>
</div>
<article class="recipe-card" id="card">
<div class="hero" aria-hidden="true">
<span class="hero-emoji">🍅</span>
<span class="hero-badge" id="cardDiff">Medium</span>
</div>
<div class="card-body">
<span class="kicker" id="cardDiet">Vegetarian · Nut-free</span>
<h2 id="cardTitle">Sun-Roasted Tomato & Basil Galette</h2>
<p class="card-desc" id="cardDesc">A blistered, jammy tomato tart with a buttery rye crust and torn basil.</p>
<dl class="card-meta">
<div><dt>Prep</dt><dd id="cardPrep">20 min</dd></div>
<div><dt>Cook</dt><dd id="cardCook">35 min</dd></div>
<div><dt>Total</dt><dd id="cardTotal">55 min</dd></div>
<div><dt>Serves</dt><dd id="cardServes">4</dd></div>
</dl>
<h3 class="card-h">Ingredients</h3>
<ul class="card-ings" id="cardIngs"></ul>
<h3 class="card-h">Method</h3>
<ol class="card-steps" id="cardSteps"></ol>
<footer class="card-foot">
<span aria-hidden="true">🌿</span> Recipe by you · Cookbook Studio
</footer>
</div>
</article>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Recipe Editor (ingredients + steps builder)
A two-pane cookbook authoring tool. On the left, an editor form collects the basics (title, description), recipe meta (prep, cook, servings, difficulty, diet/tags), and two dynamic builders: an ingredients table with quantity, unit and name fields, and a numbered method list of step textareas. Every row can be reordered by dragging its handle or nudged with up/down arrows, and removed with a single control.
On the right, a sticky live preview renders a polished, print-friendly recipe card with a gradient “food photo” hero, a meta strip, and the ingredient and method lists. It updates on every keystroke. A servings scaler recalculates ingredient quantities proportionally, so doubling the batch rewrites the amounts in place without touching your source numbers.
Drafts persist automatically to localStorage, so a refresh restores your work-in-progress. The Save button runs lightweight validation (a title plus at least one ingredient and one step) and confirms with a toast, while Clear restores the sample recipe. Everything is vanilla HTML, CSS and JS — no frameworks, no build step.
Illustrative UI only — recipes & nutrition data are fictional, not dietary advice.