From 5bf5a0ab920655155803d910be4a9855eb2729b2 Mon Sep 17 00:00:00 2001 From: Charlie Date: Tue, 16 Jun 2026 22:53:10 +0200 Subject: [PATCH] schema-apps: stunning Recipe card layout Replace basic Recipe rendering with a full bespoke recipe-card design: - Hero food photo with warm gradient scrim, title overlay, description - Stats strip with iconified chips (prep, cook, yield) - Two-column checkable ingredients list with animated checkboxes - Numbered method steps with large gradient step-number badges - Warm appetising accent, responsive mobile layout - Hides generic header, removes default hero, marks all handled props First task on GLM-5.2. By Charlie. --- app.css | 270 +++++++++++++++++++++++++++++++++++++++++++++++--------- app.js | 118 ++++++++++++++++++++----- 2 files changed, 320 insertions(+), 68 deletions(-) diff --git a/app.css b/app.css index f312edf..2425b2c 100644 --- a/app.css +++ b/app.css @@ -396,7 +396,7 @@ textarea.inline-edit { letter-spacing: 0; } -/* ── Per-type bespoke: Recipe ── */ +/* ── Shared stat chips (Recipe, Organization, etc.) ── */ .stats-strip { display: flex; align-items: center; @@ -430,89 +430,271 @@ textarea.inline-edit { height: 24px; background: var(--line); } +/* Shared step-list (Recipe legacy, HowTo) */ +.step-list { + counter-reset: step; + list-style: none; + padding: 0; + margin: 0; +} +.step-list li { + position: relative; + padding: .5rem 0 .5rem 2.2rem; + line-height: 1.6; + color: var(--ink-2); + border-left: 2px solid color-mix(in oklab, var(--accent) 20%, var(--line)); + margin-left: .6rem; +} +.step-list li::before { + counter-increment: step; + content: counter(step); + position: absolute; + left: -13px; + top: .45rem; + width: 24px; height: 24px; + border-radius: 50%; + background: var(--accent); + color: #fff; + font-size: .7rem; + font-weight: 700; + display: grid; + place-items: center; + box-shadow: 0 1px 4px rgba(0,0,0,.12); +} + +/* ── Per-type bespoke: Recipe (stunning recipe-card) ── */ +.card.recipe-card { overflow: hidden; } +.card.recipe-card .hd.hidden { display: none; } -.section-block { - margin: .75rem 0; +/* Hero */ +.recipe-hero { + position: relative; + width: 100%; + height: 340px; + overflow: hidden; + cursor: pointer; } -.section-label { - font-size: .68rem; +.recipe-hero-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform .6s cubic-bezier(.16,1,.3,1); +} +.recipe-hero:hover .recipe-hero-img { + transform: scale(1.05); +} +.recipe-scrim { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, + rgba(0,0,0,.05) 0%, + rgba(0,0,0,.15) 35%, + rgba(0,0,0,.55) 70%, + rgba(20,10,5,.85) 100%); +} +.recipe-overlay { + position: absolute; + bottom: 0; left: 0; right: 0; + padding: 1.5rem 2rem 1.75rem; + color: #fff; +} +.recipe-badge { + display: inline-block; + padding: .3rem .7rem; + border-radius: 100px; + background: rgba(255,255,255,.18); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,.2); + font-size: .62rem; font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; + margin-bottom: .6rem; +} +.recipe-title { + margin: 0; + font-size: 2rem; + font-weight: 800; + letter-spacing: -.025em; + line-height: 1.1; + text-shadow: 0 2px 12px rgba(0,0,0,.4); +} +.recipe-desc { + margin: .5rem 0 0; + font-size: .88rem; + line-height: 1.5; + opacity: .88; + max-width: 540px; +} + +/* Body */ +.card.recipe-card .bd { + padding: 1.5rem 2rem 2rem; +} +.card.recipe-card .bd::before { display: none; } + +/* Stats strip */ +.recipe-stats { + display: flex; + flex-wrap: wrap; + gap: .65rem; + margin-bottom: 1.5rem; +} +.recipe-stat { + display: flex; + align-items: center; + gap: .5rem; + padding: .55rem .9rem; + border-radius: 12px; + background: color-mix(in oklab, var(--accent) 6%, var(--surface)); + border: 1px solid color-mix(in oklab, var(--accent) 14%, var(--line)); + transition: transform .2s, box-shadow .2s; +} +.recipe-stat:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px color-mix(in oklab, var(--accent) 15%, transparent); +} +.recipe-stat-icon { + font-size: 1.15rem; + line-height: 1; +} +.recipe-stat-text { + display: flex; + flex-direction: column; + line-height: 1.25; + font-weight: 600; + font-size: .82rem; + color: var(--ink-2); +} +.recipe-stat-text small { + font-size: .58rem; + font-weight: 700; + text-transform: uppercase; letter-spacing: .08em; + color: var(--accent); + opacity: .85; +} + +/* Section blocks */ +.recipe-section { + margin: 0 0 1.5rem; +} +.recipe-section:last-child { + margin-bottom: 0; +} +.recipe-section-title { + font-size: .72rem; + font-weight: 800; + letter-spacing: .1em; text-transform: uppercase; - color: var(--muted); - margin-bottom: .4rem; + color: var(--accent); + margin-bottom: .75rem; + padding-bottom: .4rem; + border-bottom: 2px solid color-mix(in oklab, var(--accent) 15%, var(--line)); } -.checklist { + +/* Ingredients checklist */ +.recipe-checklist { list-style: none; padding: 0; margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: .15rem .8rem; } -.checklist li { +.recipe-check-item { display: flex; align-items: flex-start; gap: .55rem; - padding: .35rem 0; + padding: .35rem .35rem; + border-radius: 8px; cursor: pointer; - transition: opacity .15s; + transition: background .15s; +} +.recipe-check-item:hover { + background: color-mix(in oklab, var(--accent) 4%, transparent); } -.check-box { +.recipe-check-box { flex-shrink: 0; - width: 18px; height: 18px; - border: 2px solid color-mix(in oklab, var(--accent) 40%, var(--line)); - border-radius: 4px; + width: 20px; height: 20px; + border: 2px solid color-mix(in oklab, var(--accent) 35%, var(--line)); + border-radius: 6px; margin-top: 1px; display: grid; place-items: center; - transition: background .15s, border-color .15s; + transition: background .15s, border-color .15s, transform .15s; } -.checklist li.checked .check-box { +.recipe-check-item:hover .recipe-check-box { + border-color: var(--accent); +} +.recipe-check-item.checked .recipe-check-box { background: var(--accent); border-color: var(--accent); + transform: scale(1.08); } -.checklist li.checked .check-box::after { - content: "\2713"; +.recipe-check-item.checked .recipe-check-box::after { + content: "\2713 "; color: #fff; - font-size: .65rem; + font-size: .7rem; font-weight: 700; } -.check-text { - line-height: 1.55; +.recipe-check-text { + line-height: 1.5; color: var(--ink-2); - transition: opacity .15s; + transition: opacity .15s, text-decoration .15s; } -.checklist li.checked .check-text { - opacity: .45; +.recipe-check-item.checked .recipe-check-text { + opacity: .4; text-decoration: line-through; } -.step-list { - counter-reset: step; + +/* Method steps */ +.recipe-steps { list-style: none; padding: 0; margin: 0; + counter-reset: recipe-step; } -.step-list li { +.recipe-step { position: relative; - padding: .5rem 0 .5rem 2.2rem; - line-height: 1.6; - color: var(--ink-2); - border-left: 2px solid color-mix(in oklab, var(--accent) 20%, var(--line)); - margin-left: .6rem; + display: flex; + align-items: flex-start; + gap: .85rem; + padding: .6rem 0; } -.step-list li::before { - counter-increment: step; - content: counter(step); - position: absolute; - left: -13px; - top: .45rem; - width: 24px; height: 24px; +.recipe-step-num { + flex-shrink: 0; + counter-increment: recipe-step; + width: 32px; height: 32px; border-radius: 50%; - background: var(--accent); + background: linear-gradient(135deg, var(--accent), color-mix(in oklab, var(--accent) 75%, #d97706)); color: #fff; - font-size: .7rem; - font-weight: 700; + font-size: .85rem; + font-weight: 800; display: grid; place-items: center; - box-shadow: 0 1px 4px rgba(0,0,0,.12); + box-shadow: 0 2px 8px color-mix(in oklab, var(--accent) 30%, transparent); +} +.recipe-step-num::before { + content: counter(recipe-step); +} +.recipe-step-text { + line-height: 1.65; + color: var(--ink-2); + padding-top: .2rem; +} + +/* Dark mode */ +@media (max-width: 600px) { + .recipe-hero { height: 260px; } + .recipe-overlay { padding: 1rem 1.25rem 1.25rem; } + .recipe-title { font-size: 1.5rem; } + .recipe-desc { font-size: .82rem; } + .recipe-checklist { grid-template-columns: 1fr; } + .card.recipe-card .bd { padding: 1.25rem; } } /* ── Per-type bespoke: Review ── */ diff --git a/app.js b/app.js index cb62982..654ff0f 100644 --- a/app.js +++ b/app.js @@ -298,23 +298,83 @@ function renderTypeSpecific(view, heroRendered) { const handled = new Set(); - // ── RECIPE ── + // ── RECIPE (recipe-card layout) ── if (cfg.type === "Recipe") { - // Stats strip: prep, cook, yield + const card = document.querySelector(".card"); + if (card) card.classList.add("recipe-card"); + + const heroMount = document.getElementById("hero-mount"); + const typeLabel = document.getElementById("type-label"); + + // Remove generic hero so we can build our own + heroMount.querySelector(".hero")?.remove(); + + // ── Hero: food photo with overlay ── + const imageData = data["image"]; + if (imageData) { + const hero = document.createElement("div"); hero.className = "recipe-hero"; + hero.setAttribute("data-prop", "image"); + hero.setAttribute("title", "Click to change image URL"); + hero.setAttribute("tabindex", "0"); + + const img = document.createElement("img"); + img.className = "recipe-hero-img"; + img.src = imageData; + img.alt = data["name"] || "Recipe photo"; + img.loading = "lazy"; + hero.appendChild(img); + + // Warm gradient scrim + const scrim = document.createElement("div"); scrim.className = "recipe-scrim"; + hero.appendChild(scrim); + + // Overlay content + const overlay = document.createElement("div"); overlay.className = "recipe-overlay"; + + const badge = document.createElement("span"); badge.className = "recipe-badge"; + badge.textContent = "\u{1F374}\u{00A0}" + (typeLabel ? typeLabel.textContent : "Recipe"); + overlay.appendChild(badge); + + const h1 = document.createElement("h1"); h1.className = "recipe-title"; + h1.textContent = data["name"] || ""; + overlay.appendChild(h1); + + const desc = data["description"]; + if (desc) { + const descEl = document.createElement("p"); descEl.className = "recipe-desc"; + descEl.textContent = desc; + overlay.appendChild(descEl); + handled.add("description"); + } + + hero.appendChild(overlay); + heroMount.appendChild(hero); + } + + // Hide normal header since title is in the hero + const hd = document.querySelector(".hd"); + if (hd) hd.classList.add("hidden"); + + handled.add("name"); + handled.add("image"); + + // ── Stats strip: prep, cook, yield ── const stats = []; const prep = data["prepTime"]; - if (prep) { const d = formatDuration(prep); if (d) stats.push({ label: "Prep", val: d }); } + if (prep) { const d = formatDuration(prep); if (d) stats.push({ icon: "\u{23F1}\u{FE0F}", label: "Prep", val: d }); } const cook = data["cookTime"]; - if (cook) { const d = formatDuration(cook); if (d) stats.push({ label: "Cook", val: d }); } + if (cook) { const d = formatDuration(cook); if (d) stats.push({ icon: "\u{1F525}", label: "Cook", val: d }); } const yield_ = data["recipeYield"]; - if (yield_) stats.push({ label: "Yield", val: yield_ }); + if (yield_) stats.push({ icon: "\u{1F37D}\u{FE0F}", label: "Yields", val: yield_ }); if (stats.length) { - const strip = document.createElement("div"); strip.className = "stats-strip"; - stats.forEach((s, i) => { - if (i > 0) { const sep = document.createElement("span"); sep.className = "stats-sep"; strip.appendChild(sep); } - const chip = document.createElement("span"); chip.className = "stat-chip"; - chip.innerHTML = "" + s.label + "" + s.val; + const strip = document.createElement("div"); strip.className = "recipe-stats"; + stats.forEach((s) => { + const chip = document.createElement("div"); chip.className = "recipe-stat"; + const iconSpan = document.createElement("span"); iconSpan.className = "recipe-stat-icon"; iconSpan.textContent = s.icon; + const textSpan = document.createElement("span"); textSpan.className = "recipe-stat-text"; + textSpan.innerHTML = "" + s.label + "" + s.val; + chip.append(iconSpan, textSpan); strip.appendChild(chip); }); view.appendChild(strip); @@ -323,18 +383,21 @@ if (cook) handled.add("cookTime"); if (yield_) handled.add("recipeYield"); - // Ingredients checklist + // ── Ingredients checklist ── const ingredients = data["recipeIngredient"]; if (ingredients) { - const items = typeof ingredients === "string" ? ingredients.split(/,(?=[\s])/) : (Array.isArray(ingredients) ? ingredients : [ingredients]); - const section = document.createElement("div"); section.className = "section-block"; - const h = document.createElement("div"); h.className = "section-label"; h.textContent = "Ingredients"; + const items = typeof ingredients === "string" + ? ingredients.split(/,(?=\s)/) + : (Array.isArray(ingredients) ? ingredients : [ingredients]); + const section = document.createElement("div"); section.className = "recipe-section"; + const h = document.createElement("div"); h.className = "recipe-section-title"; + h.textContent = "\u{1F4E6} Ingredients"; section.appendChild(h); - const ul = document.createElement("ul"); ul.className = "checklist"; + const ul = document.createElement("ul"); ul.className = "recipe-checklist"; items.forEach((item) => { - const li = document.createElement("li"); - const cb = document.createElement("span"); cb.className = "check-box"; - const txt = document.createElement("span"); txt.className = "check-text"; txt.textContent = item.trim(); + const li = document.createElement("li"); li.className = "recipe-check-item"; + const cb = document.createElement("span"); cb.className = "recipe-check-box"; + const txt = document.createElement("span"); txt.className = "recipe-check-text"; txt.textContent = item.trim(); li.append(cb, txt); li.addEventListener("click", () => li.classList.toggle("checked")); ul.appendChild(li); @@ -344,16 +407,23 @@ handled.add("recipeIngredient"); } - // Instructions numbered list + // ── Method: numbered steps ── const instr = data["recipeInstructions"]; if (instr) { - const steps = typeof instr === "string" ? instr.split(/\n|\.\s+(?=[A-Z])/).filter(s => s.trim()) : (Array.isArray(instr) ? instr : [instr]); - const section = document.createElement("div"); section.className = "section-block"; - const h = document.createElement("div"); h.className = "section-label"; h.textContent = "Instructions"; + const steps = typeof instr === "string" + ? instr.split(/\n/).filter((s) => s.trim()) + : (Array.isArray(instr) ? instr : [instr]); + const section = document.createElement("div"); section.className = "recipe-section"; + const h = document.createElement("div"); h.className = "recipe-section-title"; + h.textContent = "\u{1F4DD} Method"; section.appendChild(h); - const ol = document.createElement("ol"); ol.className = "step-list"; + const ol = document.createElement("ol"); ol.className = "recipe-steps"; steps.forEach((step) => { - const li = document.createElement("li"); li.textContent = step.trim().replace(/^\d+\.\s*/, ""); + const li = document.createElement("li"); li.className = "recipe-step"; + const num = document.createElement("span"); num.className = "recipe-step-num"; + const txt = document.createElement("span"); txt.className = "recipe-step-text"; + txt.textContent = step.trim().replace(/^\d+\.\s*/, ""); + li.append(num, txt); ol.appendChild(li); }); section.appendChild(ol);