Update recipe view page layout to match roux-recipe-mockup.html
Build and Deploy / build-and-deploy (push) Failing after 1m40s

Layout changes:
- Nav matching landing page style (ROUX logo + Recipes + Cook History)
- Large serif title with inline editing preserved
- Subtitle from recipe description
- Meta strip: rounded background with last cook info + source link + Log meal button
- Full-width hero image with upload/replace overlay
- 2-column body grid (1.5fr ingredients+method | 1fr cook history rail)
- Flat ingredient list with quantities (no checkboxes)
- Numbered method paragraphs (no checkboxes, timers preserved)
- Cook history rail on right with + Add button
- Tags displayed below the grid
- Responsive: single column on mobile

Architecture:
- Removed: renderMetaBar, renderCookLog, renderRecentCookEntries,
  renderRecentEntry, renderEntry, renderIngredientGroup, renderIngredientRow,
  collectSectionIngredients, dedupFirst, renderMethodSections, renderMethodStep,
  isEmptyStep, isBlankText, isMethodSection, sectionMethodSteps,
  renderMarginalNote, collectNotes, bodyItemSteps, pairWith
- Added: recipePageStyles, rpHeroImage, rpLastCookInfo, formatFullDate,
  rpIngredientList, rpNamedIngredientSection, collectIngredients,
  rpIngredientItems, rpMethodList, rpRenderMethodStep, rpHistoryList,
  rpRenderHistoryEntry
- Updated cookLogJs to use .roux-record-trigger class for dual trigger buttons
- Removed unused imports (Data.Function, catMaybes)
- All hlint warnings fixed, 51 tests passing
This commit is contained in:
2026-05-26 07:38:15 -04:00
parent 6a7374c912
commit 0643dda10d
3 changed files with 608 additions and 264 deletions
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Roux — Landing page mockup</title>
<style>
:root {
--cream: #F5EBD3;
--cream-light: #FDF6E7;
--brown-dark: #3D2817;
--brown-mid: #4A3525;
--brown-soft: #6B5847;
--brown-muted: #8A7560;
--terracotta: #C5532E;
--terracotta-soft: rgba(197, 83, 46, 0.08);
--rule: rgba(61, 40, 23, 0.18);
--rule-soft: rgba(61, 40, 23, 0.12);
--font-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--cream); font-family: var(--font-sans); color: var(--brown-dark); }
.page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }
.nav { display: flex; justify-content: space-between; align-items: center;
border-bottom: 0.5px solid var(--rule); padding-bottom: 12px; margin-bottom: 32px;
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-soft); text-transform: uppercase; }
.logo { font-family: var(--font-serif); font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--brown-dark); }
.nav-links { display: flex; gap: 28px; }
.nav-links a { color: var(--brown-soft); text-decoration: none; cursor: pointer; }
.nav-links a.active { color: var(--terracotta); }
.nav-links a:hover { color: var(--terracotta); }
h1, h2, h3 { font-family: var(--font-serif); font-weight: 500; color: var(--brown-dark); margin: 0; }
h1.recipe-title { font-size: 32px; line-height: 1.1; }
h3.shelf-title { font-size: 20px; }
.shuffle-card {
background: var(--terracotta-soft);
border-radius: 12px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
margin-bottom: 36px;
}
.shuffle-meta { font-size: 12px; letter-spacing: 0.12em; color: var(--brown-muted); text-transform: uppercase; margin-bottom: 4px; }
.shuffle-card h3 { font-size: 20px; }
.shuffle-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
.btn {
background: var(--terracotta); color: var(--cream-light);
border: none; border-radius: 8px; padding: 10px 18px;
font-size: 13px; font-weight: 500; cursor: pointer;
font-family: inherit;
}
.btn:hover { filter: brightness(0.95); }
.btn-ghost {
background: none; border: none; color: var(--terracotta);
font-size: 13px; cursor: pointer; padding: 0; font-family: inherit;
}
.btn-ghost:hover { text-decoration: underline; }
.shelf-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
.shelf { margin-bottom: 36px; }
.shelf-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.card { cursor: pointer; }
.card-image {
width: 100%; aspect-ratio: 1.3; border-radius: 8px;
background-size: cover; background-position: center;
transition: opacity 0.15s ease;
}
.card:hover .card-image { opacity: 0.92; }
.card-title { font-family: var(--font-serif); font-size: 17px; font-weight: 500; color: var(--brown-dark); margin: 10px 0 4px; }
.card-meta { font-size: 12px; color: var(--brown-muted); }
/* placeholder gradients — swap with real images */
.img-1 { background: linear-gradient(135deg, #E8B86A, #C9893F 60%, #8A5A2A); }
.img-2 { background: linear-gradient(135deg, #A8C77A, #6B8E3D 60%, #3D5A1F); }
.img-3 { background: linear-gradient(135deg, #D4A574, #9C6B3F 60%, #5C3A1F); }
.img-4 { background: linear-gradient(135deg, #E8C46A, #B8902F 60%, #7A5E1A); }
.img-5 { background: linear-gradient(135deg, #C7A878, #8E6B3D 60%, #5A4520); }
.img-6 { background: linear-gradient(135deg, #B5D4A1, #7AA862 60%, #4D7038); }
@media (max-width: 720px) {
.page { padding: 24px 24px 60px; }
.shelf-row { grid-template-columns: 1fr 1fr; }
.shuffle-card { flex-direction: column; align-items: flex-start; }
.shuffle-actions { width: 100%; justify-content: flex-end; }
}
</style>
</head>
<body>
<div class="page">
<nav class="nav">
<span class="logo">ROUX</span>
<div class="nav-links">
<a class="active">Home</a>
<a>Recipes</a>
<a>Cook History</a>
</div>
</nav>
<div class="shuffle-card">
<div>
<div class="shuffle-meta">Don&rsquo;t know what to cook?</div>
<h3>Try Sheet-Pan Salmon &amp; Asparagus</h3>
</div>
<div class="shuffle-actions">
<button class="btn-ghost">&#x21BB; Shuffle</button>
<button class="btn">Open recipe</button>
</div>
</div>
<section class="shelf">
<div class="shelf-header">
<h3 class="shelf-title">Recently cooked</h3>
<button class="btn-ghost">All history &rarr;</button>
</div>
<div class="shelf-row">
<div class="card">
<div class="card-image img-1"></div>
<div class="card-title">Chicken Piccata</div>
<div class="card-meta">May 26 &middot; 2nd cook</div>
</div>
<div class="card">
<div class="card-image img-2"></div>
<div class="card-title">Arugula Pesto Wheat Berries</div>
<div class="card-meta">May 21</div>
</div>
<div class="card">
<div class="card-image img-3"></div>
<div class="card-title">Alain Ducasse&rsquo;s Gougères</div>
<div class="card-meta">May 18</div>
</div>
</div>
</section>
<section class="shelf">
<div class="shelf-header">
<h3 class="shelf-title">Recently added</h3>
<button class="btn-ghost">All recipes &rarr;</button>
</div>
<div class="shelf-row">
<div class="card">
<div class="card-image img-4"></div>
<div class="card-title">Apple Cider Vinegar Dressing</div>
<div class="card-meta">Added May 24</div>
</div>
<div class="card">
<div class="card-image img-5"></div>
<div class="card-title">Chicken Piccata</div>
<div class="card-meta">Added May 20</div>
</div>
<div class="card">
<div class="card-image img-6"></div>
<div class="card-title">Arugula Pesto Chicken</div>
<div class="card-meta">Added May 14</div>
</div>
</div>
</section>
</div>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Roux — Recipe page mockup</title>
<style>
:root {
--cream: #F5EBD3;
--cream-light: #FDF6E7;
--brown-dark: #3D2817;
--brown-mid: #4A3525;
--brown-soft: #6B5847;
--brown-muted: #8A7560;
--terracotta: #C5532E;
--meta-strip-bg: rgba(61, 40, 23, 0.05);
--rule: rgba(61, 40, 23, 0.18);
--rule-soft: rgba(61, 40, 23, 0.15);
--font-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--cream); font-family: var(--font-sans); color: var(--brown-dark); }
.page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }
.nav { display: flex; justify-content: space-between; align-items: center;
border-bottom: 0.5px solid var(--rule); padding-bottom: 12px; margin-bottom: 28px;
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-soft); text-transform: uppercase; }
.logo { font-family: var(--font-serif); font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--brown-dark); }
.nav-links { display: flex; gap: 28px; }
.nav-links a { color: var(--brown-soft); text-decoration: none; cursor: pointer; }
.nav-links a:hover { color: var(--terracotta); }
h1, h2, h3 { font-family: var(--font-serif); font-weight: 500; color: var(--brown-dark); margin: 0; }
h1.recipe-title { font-size: 38px; line-height: 1.05; }
.recipe-sub { font-size: 16px; color: var(--brown-soft); margin: 8px 0 16px; }
/* meta strip — the key move: last cook + quick log button right under the title */
.meta-strip {
display: flex; align-items: center; gap: 14px;
background: var(--meta-strip-bg); border-radius: 10px;
padding: 12px 18px; margin-bottom: 24px;
font-size: 14px;
}
.meta-strip .last-cook { color: var(--brown-soft); flex: 1; min-width: 0; }
.meta-strip .last-cook strong { font-weight: 500; color: var(--brown-dark); }
.meta-strip .last-cook .note { font-style: italic; }
.meta-strip-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
.btn {
background: var(--terracotta); color: var(--cream-light);
border: none; border-radius: 8px; padding: 9px 16px;
font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
}
.btn:hover { filter: brightness(0.95); }
.btn-ghost {
background: none; border: none; color: var(--terracotta);
font-size: 13px; cursor: pointer; padding: 0; font-family: inherit;
}
.btn-ghost:hover { text-decoration: underline; }
.hero-image {
width: 100%; aspect-ratio: 2.2; border-radius: 10px;
background: linear-gradient(135deg, #E8B86A, #C9893F 50%, #8A5A2A);
margin-bottom: 32px;
}
/* body grid: ingredients/method on left, cook-history rail on right */
.body-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 40px; }
.section-label {
font-size: 12px; letter-spacing: 0.12em; color: var(--brown-muted);
text-transform: uppercase; margin-bottom: 10px;
}
.serves { font-family: var(--font-serif); font-size: 28px; margin-bottom: 22px; }
.ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; line-height: 1.9; }
.ingredients li { display: flex; gap: 14px; align-items: baseline; padding: 4px 0; border-bottom: 0.5px dashed var(--rule-soft); }
.ingredients li:last-child { border-bottom: none; }
.ingredients .qty { color: var(--brown-muted); min-width: 48px; font-variant-numeric: tabular-nums; }
.method { font-size: 15px; line-height: 1.7; color: var(--brown-mid); }
.method p { margin: 0 0 12px; }
.method .step { font-weight: 500; color: var(--brown-dark); margin-right: 4px; }
/* cook history rail */
.history-rail { }
.history-rail .section-label { display: flex; justify-content: space-between; align-items: baseline; }
.history-list { display: flex; flex-direction: column; gap: 0; }
.history-entry { padding: 12px 0; border-bottom: 0.5px solid var(--rule-soft); }
.history-entry:last-child { border-bottom: none; }
.history-date { font-family: var(--font-serif); font-size: 15px; font-weight: 500; color: var(--brown-dark); margin-bottom: 2px; }
.history-note { font-size: 13px; color: var(--brown-soft); line-height: 1.5; }
.history-note.empty { color: var(--brown-muted); font-style: italic; }
@media (max-width: 720px) {
.page { padding: 24px 24px 60px; }
.meta-strip { flex-direction: column; align-items: flex-start; }
.meta-strip-actions { width: 100%; justify-content: flex-end; }
.body-grid { grid-template-columns: 1fr; gap: 32px; }
h1.recipe-title { font-size: 30px; }
}
</style>
</head>
<body>
<div class="page">
<nav class="nav">
<span class="logo">ROUX</span>
<div class="nav-links">
<a>Recipes</a>
<a>Cook History</a>
</div>
</nav>
<h1 class="recipe-title">Chicken Piccata</h1>
<p class="recipe-sub">With buttery angel hair pasta &amp; dressed greens with parmesan</p>
<div class="meta-strip">
<div class="last-cook">
<strong>Last cooked May 26</strong> &mdash; <span class="note">&ldquo;Pretty good &mdash; lots of extra pasta.&rdquo;</span>
</div>
<div class="meta-strip-actions">
<button class="btn-ghost">&#x2197; Original</button>
<button class="btn">+ Log meal</button>
</div>
</div>
<div class="hero-image" role="img" aria-label="Chicken Piccata photo"></div>
<div class="body-grid">
<div>
<div class="section-label">Serves</div>
<div class="serves">2</div>
<div class="section-label">Ingredients</div>
<ul class="ingredients">
<li><span class="qty">&mdash;</span><span>water</span></li>
<li><span class="qty">2</span><span>chicken breasts</span></li>
<li><span class="qty">1</span><span>lemon</span></li>
<li><span class="qty">&frac14; c</span><span>parsley</span></li>
<li><span class="qty">1 T</span><span>capers</span></li>
<li><span class="qty">2 T</span><span>olive oil</span></li>
<li><span class="qty">2 T</span><span>butter</span></li>
</ul>
<div class="section-label">Method</div>
<div class="method">
<p><span class="step">1.</span> Slice chicken breasts horizontally, season, and dredge in flour.</p>
<p><span class="step">2.</span> Sear in olive oil over medium-high until golden, about 3 min per side. Set aside.</p>
<p><span class="step">3.</span> Deglaze the pan with lemon juice and a splash of water; add butter and capers and simmer until glossy.</p>
<p><span class="step">4.</span> Return chicken to the pan, spoon sauce over, and rest 2 min before plating over angel hair.</p>
</div>
</div>
<aside class="history-rail">
<div class="section-label">
<span>Cook history</span>
<button class="btn-ghost">+ Add</button>
</div>
<div class="history-list">
<div class="history-entry">
<div class="history-date">May 26, 2026</div>
<div class="history-note">Pretty good &mdash; lots of extra pasta.</div>
</div>
<div class="history-entry">
<div class="history-date">May 26, 2026</div>
<div class="history-note empty">No notes</div>
</div>
<div class="history-entry">
<div class="history-date">Apr 12, 2026</div>
<div class="history-note">Doubled the capers &mdash; much better.</div>
</div>
<div class="history-entry">
<div class="history-date">Feb 03, 2026</div>
<div class="history-note">First try. Sauce broke a little.</div>
</div>
</div>
</aside>
</div>
</div>
</body>
</html>
+254 -264
View File
@@ -23,9 +23,8 @@ module Roux.Html (
import Control.Monad (unless)
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes, fromMaybe)
import Data.Maybe (fromMaybe)
import Data.Ratio (denominator, numerator)
import Data.Text (Text)
import qualified Data.Text as T
@@ -61,10 +60,7 @@ newtype ImportError = ImportError Text
-- Helpers used throughout
-- ---------------------------------------------------------------------------
-- | Pair a label with a value if present.
pairWith :: a -> Maybe b -> Maybe (a, b)
pairWith _ Nothing = Nothing
pairWith a (Just b) = Just (a, b)
{- | Format a quantity for display.
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
@@ -652,7 +648,7 @@ cookLogJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var btn = document.getElementById('roux-record-btn');"
, "var btns = document.querySelectorAll('.roux-record-trigger');"
, "var modal = document.getElementById('roux-record-modal');"
, "var backdrop = document.getElementById('roux-modal-backdrop');"
, "var cancel = document.getElementById('roux-modal-cancel');"
@@ -660,7 +656,7 @@ cookLogJs =
, "var dateInput = document.getElementById('roux-record-date');"
, "var commentInput = document.getElementById('roux-record-comment');"
, "var errorEl = document.getElementById('roux-record-error');"
, "if (!btn || !modal || !form) return;"
, "if (!btns.length || !modal || !form) return;"
, ""
, "function showModal() {"
, " dateInput.value = new Date().toISOString().slice(0,10);"
@@ -674,7 +670,7 @@ cookLogJs =
, " modal.style.display = 'none';"
, "}"
, ""
, "btn.addEventListener('click', showModal);"
, "btns.forEach(function(b) { b.addEventListener('click', showModal); });"
, "if (cancel) cancel.addEventListener('click', hideModal);"
, "if (backdrop) backdrop.addEventListener('click', hideModal);"
, ""
@@ -891,113 +887,138 @@ recipePage info entries =
H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs
-- | Render the cook log section in the marginalia column.
renderCookLog :: FilePath -> [CookLog.CookEntry] -> Html
renderCookLog _ [] = pure ()
renderCookLog _filename entries = do
H.h2 "Cook history"
H.div ! A.class_ "roux-cook-entries" $ do
mapM_ renderEntry entries
-- | Render the most recent cook entries under the "Record meal" button.
renderRecentCookEntries :: [CookLog.CookEntry] -> Html
renderRecentCookEntries [] = pure ()
renderRecentCookEntries entries = do
H.div ! A.style "margin-top: 0.4rem; font-size: 0.75rem; color: var(--roux-muted); line-height: 1.5;" $ do
H.span "Recent: "
mapM_ renderRecentEntry (take 5 entries)
renderRecentEntry :: CookLog.CookEntry -> Html
renderRecentEntry entry = do
H.span ! A.style "color: var(--roux-muted);" $ H.toHtml (formatDay (CookLog.ceCookedDate entry))
unless (T.null (CookLog.ceComment entry)) $
H.span ! A.style "color: var(--roux-text);" $
": " >> H.toHtml (CookLog.ceComment entry)
H.span ", "
-- | Render a single cook log entry.
renderEntry :: CookLog.CookEntry -> Html
renderEntry entry = do
H.p ! A.style "margin: 0.3rem 0; font-size: 0.9rem;" $ do
H.strong ! A.style "display: block; text-align: center; color: var(--roux-muted);" $
H.toHtml ("\x2014\x2014 " <> formatDay (CookLog.ceCookedDate entry) <> " \x2014\x2014")
unless (T.null (CookLog.ceComment entry)) $
H.span ! A.style "display: block; margin-top: 0.2rem;" $
H.toHtml (CookLog.ceComment entry)
-- | Format a Day as "Mon DD" e.g. "Mar 15".
formatDay :: Day -> Text
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e"
-- | Render the body of a recipe page (no page shell).
-- | Recipe page CSS matching the roux-recipe-mockup.html layout.
recipePageStyles :: Text
recipePageStyles =
T.unlines
[ ".rp-page { max-width: 980px; margin: 0 auto; padding: 36px 48px 80px; }"
, ".rp-nav { display: flex; justify-content: space-between; align-items: center; border-bottom: 0.5px solid rgba(61,44,30,0.18); padding-bottom: 12px; margin-bottom: 20px; font-size: 12px; letter-spacing: 0.12em; color: #6B5847; text-transform: uppercase; }"
, ".rp-logo { font-family: \"Fraunces\", serif; font-size: 22px; font-weight: 500; letter-spacing: 0.08em; color: var(--roux-text); text-decoration: none; }"
, ".rp-nav-links { display: flex; gap: 28px; }"
, ".rp-nav-links a { color: #6B5847; text-decoration: none; }"
, ".rp-nav-links a:hover { color: var(--roux-accent); }"
, ""
, ".rp-course { font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: var(--roux-accent); opacity: 0.65; margin: 0 0 4px; }"
, ".rp-title { font-size: 38px; line-height: 1.05; font-family: \"Fraunces\", serif; font-weight: 500; margin: 0; }"
, ".rp-title.roux-recipe-title:hover::after { content: '\\270E'; font-size: 0.7rem; opacity: 0.3; margin-left: 0.5rem; vertical-align: super; }"
, ".rp-sub { font-size: 16px; color: #6B5847; margin: 8px 0 16px; }"
, ""
, ".rp-meta-strip { display: flex; align-items: center; gap: 14px; background: rgba(61,40,23,0.05); border-radius: 10px; padding: 12px 18px; margin-bottom: 24px; font-size: 14px; }"
, ".rp-meta-strip-last { color: #6B5847; flex: 1; min-width: 0; }"
, ".rp-meta-strip-last strong { font-weight: 500; color: var(--roux-text); }"
, ".rp-meta-strip-last .rp-last-note { font-style: italic; }"
, ".rp-meta-strip-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }"
, ""
, ".rp-btn { background: var(--roux-accent); color: #FDF6E7; border: none; border-radius: 8px; padding: 9px 16px; font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit; }"
, ".rp-btn:hover { filter: brightness(0.95); }"
, ".rp-btn-ghost { background: none; border: none; color: var(--roux-accent); font-size: 13px; cursor: pointer; padding: 0; font-family: inherit; text-decoration: none; }"
, ".rp-btn-ghost:hover { text-decoration: underline; }"
, ""
, ".rp-hero { width: 100%; aspect-ratio: 2.2; border-radius: 10px; background-size: cover; background-position: center; margin-bottom: 32px; position: relative; }"
, ".rp-hero-placeholder { background: linear-gradient(135deg, #E8B86A, #C9893F 50%, #8A5A2A); cursor: pointer; display: flex; align-items: center; justify-content: center; }"
, ".rp-hero-upload { font-size: 14px; color: rgba(61,44,30,0.4); }"
, ".rp-hero-overlay { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(61,44,30,0.6); color: #F5EFE0; font-size: 0.7rem; text-align: center; padding: 4px 0; cursor: pointer; opacity: 0; transition: opacity 0.15s; border-radius: 0 0 10px 10px; }"
, ".rp-hero:hover .rp-hero-overlay { opacity: 1; }"
, ".rp-hero-wrap { position: relative; margin-bottom: 32px; }"
, ".rp-hero-wrap .rp-hero { margin-bottom: 0; }"
, ""
, ".rp-body-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 40px; margin-bottom: 32px; }"
, ""
, ".rp-section-label { font-size: 12px; letter-spacing: 0.12em; color: #8A7560; text-transform: uppercase; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: baseline; }"
, ".rp-serves { font-family: \"Fraunces\", serif; font-size: 28px; margin-bottom: 22px; }"
, ""
, ".rp-ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; line-height: 1.9; }"
, ".rp-ingredients li { display: flex; gap: 14px; align-items: baseline; padding: 4px 0; border-bottom: 0.5px dashed rgba(61,40,23,0.15); }"
, ".rp-ingredients li:last-child { border-bottom: none; }"
, ".rp-ingredients .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; }"
, ".rp-ing-section { font-size: 13px; font-weight: 500; color: #6B5847; margin: 16px 0 6px; letter-spacing: 0.03em; }"
, ""
, ".rp-method { font-size: 15px; line-height: 1.7; color: #4A3525; }"
, ".rp-method p { margin: 0 0 14px; }"
, ".rp-method .rp-step { font-weight: 500; color: var(--roux-text); margin-right: 4px; }"
, ".rp-method .rp-comment { color: #8A7560; font-size: 14px; }"
, ".rp-method .rp-note { color: #8A7560; font-size: 13px; }"
, ""
, ".rp-history-list { display: flex; flex-direction: column; gap: 0; }"
, ".rp-history-entry { padding: 12px 0; border-bottom: 0.5px solid rgba(61,40,23,0.15); }"
, ".rp-history-entry:last-child { border-bottom: none; }"
, ".rp-history-date { font-family: \"Fraunces\", serif; font-size: 15px; font-weight: 500; color: var(--roux-text); margin-bottom: 2px; }"
, ".rp-history-note { font-size: 13px; color: #6B5847; line-height: 1.5; }"
, ".rp-history-note.empty { color: #8A7560; font-style: italic; }"
, ".rp-history-empty { font-size: 13px; color: #8A7560; font-style: italic; }"
, ""
, ".rp-tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }"
, ".rp-tag { font-size: 12px; color: var(--roux-accent); border: 1px solid rgba(184,92,56,0.25); border-radius: 4px; padding: 2px 10px; }"
, ""
, "@media (max-width: 720px) { .rp-page { padding: 24px 24px 60px; } .rp-meta-strip { flex-direction: column; align-items: flex-start; } .rp-meta-strip-actions { width: 100%; justify-content: flex-end; } .rp-body-grid { grid-template-columns: 1fr; gap: 32px; } .rp-title { font-size: 30px; } }"
]
-- | Render the body of a recipe page (no page shell) -- mockup-compliant layout.
renderRecipe :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html
renderRecipe filename recipe entries = do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux"
H.ul $ H.li $ H.a ! A.href "/recipes" $ "Recipes"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
case course of
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c
Nothing -> pure ()
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do
H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe)
H.div ! A.style "text-align: right;" $ do
H.div ! A.style "display: flex; gap: 0.75rem; align-items: center; justify-content: flex-end;" $ do
case metaSource meta of
Just url -> H.a ! A.class_ "roux-source-link" ! A.href (H.toValue url) ! A.target "_blank" ! A.rel "noopener noreferrer" $ do
"↗ Original"
Nothing -> pure ()
H.button
! A.class_ "roux-record-btn"
! A.id "roux-record-btn"
! H.dataAttribute "filename" (H.toValue (T.pack filename))
$ "+ Record meal"
renderRecentCookEntries entries
H.div ! A.class_ "roux-desc-row" $ do
case desc of
Just d -> H.p ! A.class_ "roux-desc" $ H.toHtml d
H.style $ H.toHtml recipePageStyles
H.div ! A.class_ "rp-page" $ do
-- Nav (matching landing page style)
H.nav ! A.class_ "rp-nav" $ do
H.a ! A.class_ "rp-logo" ! A.href "/" $ "ROUX"
H.div ! A.class_ "rp-nav-links" $ do
H.a ! A.href "/recipes" $ "Recipes"
H.a ! A.href "/cook-history" $ "Cook History"
let meta = recipeMetadata recipe
course = metaCourse meta
desc = metaDescription meta
-- Course label
case course of
Just c -> H.p ! A.class_ "rp-course" $ H.toHtml c
Nothing -> pure ()
case metaImage meta of
Just img -> do
H.div ! A.class_ "roux-image-wrap" $ do
H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe))
H.label ! A.class_ "roux-image-overlay" ! A.for "roux-image-input" $ "Replace"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
Nothing -> do
H.div ! A.class_ "roux-image-wrap roux-image-empty" $ do
H.label ! A.class_ "roux-image-placeholder" ! A.for "roux-image-input" $ "Upload photo"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
renderMetaBar meta
let sections = NE.toList (recipeSections recipe)
notes = collectNotes recipe
H.div ! A.class_ "roux-grid" $ do
-- Ingredients column
H.div $ do
H.h2 "Ingredients"
mapM_ renderIngredientGroup sections
-- Method column
H.div $ do
H.h2 "Method"
renderMethodSections sections
-- Marginalia column
H.div ! A.class_ "roux-marginalia" $ do
case metaSource meta of
Just url -> H.div ! A.class_ "note" $ do
H.h2 "Source"
H.p $ H.toHtml url
Nothing -> pure ()
let recipeTags = metaTags meta
unless (null recipeTags) $ do
H.div ! A.class_ "note" $ do
H.h2 "Tags"
H.p $ H.toHtml (T.intercalate " · " recipeTags)
renderCookLog filename entries
unless (null notes) $ do
H.h2 "Notes"
mapM_ renderMarginalNote notes
-- Record meal modal
-- Title (inline-editable via existing JS)
H.h1 ! A.class_ "roux-recipe-title rp-title" $ H.toHtml (titleText recipe)
-- Subtitle (from description)
case desc of
Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
Nothing -> pure ()
-- Meta strip: last cook + source + log meal
H.div ! A.class_ "rp-meta-strip" $ do
H.div ! A.class_ "rp-meta-strip-last" $ rpLastCookInfo entries
H.div ! A.class_ "rp-meta-strip-actions" $ do
case metaSource meta of
Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
Nothing -> pure ()
H.button ! A.class_ "rp-btn roux-record-trigger" ! A.id "roux-record-btn" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Log meal"
-- Hero image
rpHeroImage meta filename
-- Body grid
H.div ! A.class_ "rp-body-grid" $ do
-- Left column: servings, ingredients, method
H.div $ do
case metaServings meta of
Just s -> do
H.div ! A.class_ "rp-section-label" $ "Serves"
H.div ! A.class_ "rp-serves" $ H.toHtml (showServings s)
Nothing -> pure ()
let sections = NE.toList (recipeSections recipe)
H.div ! A.class_ "rp-section-label" $ "Ingredients"
rpIngredientList sections
H.div ! A.class_ "rp-section-label" $ "Method"
rpMethodList sections
-- Right column: cook history rail
H.aside $ do
H.div ! A.class_ "rp-section-label" $ do
H.span "Cook history"
H.button ! A.class_ "rp-btn-ghost roux-record-trigger" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Add"
rpHistoryList entries
-- Tags
let tags = metaTags meta
unless (null tags) $
H.div ! A.class_ "rp-tags" $ mapM_ (\t -> H.span ! A.class_ "rp-tag" $ H.toHtml t) tags
-- Record meal modal (unchanged)
H.div ! A.id "roux-record-modal" ! A.style "display: none;" $ do
H.div ! A.class_ "roux-modal-backdrop" ! A.id "roux-modal-backdrop" $ ""
H.div ! A.class_ "roux-modal-dialog" $ do
@@ -1012,39 +1033,136 @@ renderRecipe filename recipe entries = do
H.button ! A.type_ "button" ! A.class_ "roux-modal-cancel" ! A.id "roux-modal-cancel" $ "Cancel"
H.button ! A.type_ "submit" ! A.class_ "roux-modal-confirm" $ "Log it"
-- ---------------------------------------------------------------------------
-- Recipe page helpers
-- ---------------------------------------------------------------------------
-- | Render the hero image with upload/replace overlay.
rpHeroImage :: Metadata -> FilePath -> Html
rpHeroImage meta filename =
H.div ! A.class_ "rp-hero-wrap" $ do
case metaImage meta of
Just img ->
H.div ! A.class_ "rp-hero" ! A.style (H.toValue ("background-image: url(" <> img <> ")")) $ ""
Nothing ->
H.div ! A.class_ "rp-hero rp-hero-placeholder" $ ""
H.label ! A.class_ "rp-hero-overlay" ! A.for "roux-image-input" $
case metaImage meta of
Just _ -> "Replace"
Nothing -> "Upload photo"
H.input ! A.type_ "file" ! A.accept "image/*" ! A.id "roux-image-input" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) ! A.style "display: none;"
-- | Render the last cook info for the meta strip.
rpLastCookInfo :: [CookLog.CookEntry] -> Html
rpLastCookInfo [] =
H.span "Not yet cooked"
rpLastCookInfo (latest : _) = H.span $ do
H.strong $ H.toHtml ("Last cooked " <> formatFullDate (CookLog.ceCookedDate latest))
unless (T.null (CookLog.ceComment latest)) $ do
" \x2014 "
H.span ! A.class_ "rp-last-note" $ H.toHtml (CookLog.ceComment latest)
-- | Format a Day as "Month DD, YYYY" e.g. "May 26, 2026".
formatFullDate :: Day -> Text
formatFullDate = T.pack . Time.formatTime Time.defaultTimeLocale "%B %e, %Y"
-- | Render ingredients as a flat list grouped by section (mockup style).
rpIngredientList :: [Section] -> Html
rpIngredientList sections = do
let ingSects = filter isIngSection sections
unnamed = [s | s <- ingSects, isUnnamedIngSection s]
named = [s | s <- ingSects, not (isUnnamedIngSection s)]
-- Unnamed sections first, then named
unless (null unnamed) $
H.ul ! A.class_ "rp-ingredients" $
mapM_ rpIngredientItems (concatMap collectIngredients unnamed)
mapM_ rpNamedIngredientSection named
where
isIngSection s =
case sectionName s of
Just n -> let lower = T.toLower n in lower `elem` ["ingredients", "ingredient"]
Nothing -> not . Prelude.null $ collectIngredients s
isUnnamedIngSection s = Prelude.null (sectionName s)
-- | Render a named ingredient section with a sub-heading.
rpNamedIngredientSection :: Section -> Html
rpNamedIngredientSection section = do
let ings = collectIngredients section
unless (null ings) $ do
case sectionName section of
Just n -> H.p ! A.class_ "rp-ing-section" $ H.toHtml n
Nothing -> pure ()
H.ul ! A.class_ "rp-ingredients" $ mapM_ rpIngredientItems ings
-- | Collect ingredient (name, quantity) pairs from a section.
collectIngredients :: Section -> [(Text, Maybe Quantity)]
collectIngredients section =
NE.toList (sectionBody section)
>>= \case
SecStep step ->
let ings = [i | StepIngredient i <- unStep step]
in [(ingName i, ingQuantity i) | i <- ings]
_ -> []
-- | Render one ingredient item in the mockup-style list.
rpIngredientItems :: (Text, Maybe Quantity) -> Html
rpIngredientItems (name, mQty) =
H.li $ do
H.span ! A.class_ "rp-qty" $
case mQty of
Just q -> H.toHtml (showQuantity q)
Nothing -> H.toHtml ("\x2014" :: Text)
H.span $ H.toHtml name
-- | Render numbered method steps as plain paragraphs (mockup style).
rpMethodList :: [Section] -> Html
rpMethodList sections = do
let methodSects = filter (not . isIngSection) sections
allSteps = concatMap extractSteps methodSects
meaningful = filter (not . isEmptyStep) allSteps
H.div ! A.class_ "rp-method" $ mapM_ (uncurry rpRenderMethodStep) (zip [1 ..] meaningful)
where
extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s))
isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
isEmptyStep _ = False
isBlankText (StepText t) = T.all (== ' ') t
isBlankText _ = False
isIngSection s =
case sectionName s of
Just n -> T.toLower n `elem` ["ingredients", "ingredient"]
Nothing -> False
-- | Render one step as a numbered paragraph.
rpRenderMethodStep :: Int -> (Maybe Text, SectionBodyItem) -> Html
rpRenderMethodStep stepNum (_, SecStep step) =
H.p $ do
H.span ! A.class_ "rp-step" $ H.toHtml (T.pack (show stepNum <> ". "))
mapM_ renderStepItem (unStep step)
rpRenderMethodStep _ (_, SecComment t) =
H.p ! A.class_ "rp-comment" $ H.em $ H.toHtml ("-- " <> t)
rpRenderMethodStep _ (_, SecNote t) =
H.p ! A.class_ "rp-note" $ H.small $ H.toHtml ("> " <> t)
-- | Render the cook history rail (right column).
rpHistoryList :: [CookLog.CookEntry] -> Html
rpHistoryList entries
| null entries = H.div ! A.class_ "rp-history-empty" $ "No cook history yet."
| otherwise = H.div ! A.class_ "rp-history-list" $ mapM_ rpRenderHistoryEntry entries
-- | Render a single history entry.
rpRenderHistoryEntry :: CookLog.CookEntry -> Html
rpRenderHistoryEntry entry =
H.div ! A.class_ "rp-history-entry" $ do
H.div ! A.class_ "rp-history-date" $ H.toHtml (formatFullDate (CookLog.ceCookedDate entry))
H.div ! A.class_ ("rp-history-note" <> if T.null (CookLog.ceComment entry) then " empty" else "") $
if T.null (CookLog.ceComment entry)
then "No notes"
else H.toHtml (CookLog.ceComment entry)
-- | Extract display title from recipe metadata or fallback.
titleText :: Recipe -> Text
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe))
-- ---------------------------------------------------------------------------
-- Metadata bar
-- ---------------------------------------------------------------------------
-- | Render the horizontal metadata bar (serves, prep, cook, total).
renderMetaBar :: Metadata -> Html
renderMetaBar meta = do
let items =
catMaybes
[ pairWith (T.pack "Serves") (showServings <$> metaServings meta)
, pairWith (T.pack "Prep") (showDuration <$> metaPrepTime meta)
, pairWith (T.pack "Cook") (showDuration <$> metaCookTime meta)
, pairWith (T.pack "Total") (showDuration <$> metaTotalTime meta)
]
unless (null items) $
H.div ! A.class_ "roux-meta" $
mapM_
( \(label, value) ->
H.div ! A.class_ "roux-meta-item" $ do
H.p ! A.class_ "label" $ H.toHtml label
H.p ! A.class_ "value" $ H.toHtml value
)
items
-- Tags
let tags = metaTags meta
unless (null tags) $
H.p $
mapM_ (\t -> H.span ! A.class_ "tag" $ H.toHtml t) tags
-- | Format servings info.
showServings :: (Int, Maybe Text) -> Text
showServings (n, Nothing) = T.pack (show n)
@@ -1084,104 +1202,6 @@ durationToSeconds d =
toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r)
-- ---------------------------------------------------------------------------
-- Ingredients column
-- ---------------------------------------------------------------------------
-- | Render ingredients grouped by section.
renderIngredientGroup :: Section -> Html
renderIngredientGroup section = do
let ings = collectSectionIngredients section
unless (null ings) $ do
case sectionName section of
Just n -> H.p ! A.class_ "roux-subsection" $ H.toHtml n
Nothing -> pure ()
H.div $ mapM_ renderIngredientRow ings
-- | Render one ingredient row with checkbox.
renderIngredientRow :: (Text, Maybe Quantity) -> Html
renderIngredientRow (name, qty) =
H.label ! A.class_ "roux-ingredient-row" $ do
H.input ! A.type_ "checkbox"
H.span ! A.class_ "name" $ do
case qty of
Just q -> H.span ! A.class_ "qty" $ H.toHtml (showQuantity q <> " ")
Nothing -> pure ()
H.toHtml name
-- | Collect unique ingredients from a single section.
collectSectionIngredients :: Section -> [(Text, Maybe Quantity)]
collectSectionIngredients section =
let ings =
NE.toList (sectionBody section)
>>= bodyItemSteps
>>= unStep
>>= \case
StepIngredient i -> [i]
_ -> []
in dedupFirst ings []
-- | Deduplicate ingredients by name, keeping first occurrence.
dedupFirst :: [Ingredient] -> [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
dedupFirst [] acc = reverse acc
dedupFirst (i : rest) acc =
if any ((== ingName i) . fst) acc
then dedupFirst rest acc
else dedupFirst rest ((ingName i, ingQuantity i) : acc)
-- ---------------------------------------------------------------------------
-- Method column
-- ---------------------------------------------------------------------------
-- | Render the method column: all sections with numbered steps.
renderMethodSections :: [Section] -> Html
renderMethodSections sections = do
let allSteps = concatMap sectionMethodSteps (filter isMethodSection sections)
-- Exclude empty/whitespace-only steps (from leading newlines in
-- Cooklang files) so step numbering always starts at 1.
meaningful = filter (not . isEmptyStep) allSteps
mapM_ (uncurry renderMethodStep) (zip [1 ..] meaningful)
-- | Is this a SectionBodyItem that contains no meaningful step content?
isEmptyStep :: (Maybe Text, SectionBodyItem) -> Bool
isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
isEmptyStep _ = False
-- | Is a StepItem just whitespace?
isBlankText :: StepItem -> Bool
isBlankText (StepText t) = T.all (== ' ') t
isBlankText _ = False
-- | Is this section a method section (not an ingredient listing)?
isMethodSection :: Section -> Bool
isMethodSection section =
case sectionName section of
Just n -> T.toLower n `notElem` ["ingredients", "ingredient"]
Nothing -> True
-- | Extract (section name, body items) from a section for the method column.
sectionMethodSteps :: Section -> [(Maybe Text, SectionBodyItem)]
sectionMethodSteps section =
let name = sectionName section
in Prelude.map (name,) (NE.toList (sectionBody section))
-- | Render one step in the method column (with step number and checkbox).
renderMethodStep :: Int -> (Maybe Text, SectionBodyItem) -> Html
renderMethodStep stepNum (_, SecStep step) = do
H.label ! A.class_ "roux-step" $ do
H.input ! A.type_ "checkbox"
H.div $ do
H.p ! A.class_ "step-label" $ "Step " <> H.toHtml (T.pack (show stepNum))
H.p ! A.class_ "step-text" $ mapM_ renderStepItem (unStep step)
renderMethodStep _ (_, SecComment t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.em $ H.toHtml ("-- " <> t)
renderMethodStep _ (_, SecNote t) =
H.div ! A.class_ "roux-step" $
H.div $ do
H.p ! A.class_ "step-text" $ H.small $ H.toHtml ("> " <> t)
-- ---------------------------------------------------------------------------
-- Step items (inline rendering within a step)
-- ---------------------------------------------------------------------------
@@ -1218,36 +1238,6 @@ renderStepItem (StepComment t) =
renderStepItem StepBreak =
H.br
-- ---------------------------------------------------------------------------
-- Notes section
-- ---------------------------------------------------------------------------
{- | Render a note with a decorative ornament.
| Render a note in the marginalia column (compact, no ornament).
-}
renderMarginalNote :: Text -> Html
renderMarginalNote t =
H.div ! A.class_ "note" $ H.p $ H.toHtml t
-- | Collect all note texts from the recipe.
collectNotes :: Recipe -> [Text]
collectNotes recipe =
recipe
& recipeSections
& NE.toList
>>= NE.toList . sectionBody
>>= \case
SecNote t -> [t]
_ -> []
-- ---------------------------------------------------------------------------
-- Shared helpers for both columns
-- ---------------------------------------------------------------------------
-- | Extract step items from a body item (returns empty for comments/notes).
bodyItemSteps :: SectionBodyItem -> [Step]
bodyItemSteps (SecStep s) = [s]
bodyItemSteps _ = []
-- ---------------------------------------------------------------------------
-- URL encoding