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 Control.Monad (unless)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy as LB
import Data.Function ((&))
import qualified Data.List.NonEmpty as NE import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes, fromMaybe) import Data.Maybe (fromMaybe)
import Data.Ratio (denominator, numerator) import Data.Ratio (denominator, numerator)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
@@ -61,10 +60,7 @@ newtype ImportError = ImportError Text
-- Helpers used throughout -- 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 display.
| Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@). | Format a quantity for natural display (e.g. @4 cups@, @1/2 tsp@).
@@ -652,7 +648,7 @@ cookLogJs =
T.unlines T.unlines
[ "(function(){" [ "(function(){"
, "'use strict';" , "'use strict';"
, "var btn = document.getElementById('roux-record-btn');" , "var btns = document.querySelectorAll('.roux-record-trigger');"
, "var modal = document.getElementById('roux-record-modal');" , "var modal = document.getElementById('roux-record-modal');"
, "var backdrop = document.getElementById('roux-modal-backdrop');" , "var backdrop = document.getElementById('roux-modal-backdrop');"
, "var cancel = document.getElementById('roux-modal-cancel');" , "var cancel = document.getElementById('roux-modal-cancel');"
@@ -660,7 +656,7 @@ cookLogJs =
, "var dateInput = document.getElementById('roux-record-date');" , "var dateInput = document.getElementById('roux-record-date');"
, "var commentInput = document.getElementById('roux-record-comment');" , "var commentInput = document.getElementById('roux-record-comment');"
, "var errorEl = document.getElementById('roux-record-error');" , "var errorEl = document.getElementById('roux-record-error');"
, "if (!btn || !modal || !form) return;" , "if (!btns.length || !modal || !form) return;"
, "" , ""
, "function showModal() {" , "function showModal() {"
, " dateInput.value = new Date().toISOString().slice(0,10);" , " dateInput.value = new Date().toISOString().slice(0,10);"
@@ -674,7 +670,7 @@ cookLogJs =
, " modal.style.display = 'none';" , " modal.style.display = 'none';"
, "}" , "}"
, "" , ""
, "btn.addEventListener('click', showModal);" , "btns.forEach(function(b) { b.addEventListener('click', showModal); });"
, "if (cancel) cancel.addEventListener('click', hideModal);" , "if (cancel) cancel.addEventListener('click', hideModal);"
, "if (backdrop) backdrop.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 titleEditJs
H.script ! A.type_ "text/javascript" $ H.preEscapedText cookLogJs 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". -- | Format a Day as "Mon DD" e.g. "Mar 15".
formatDay :: Day -> Text formatDay :: Day -> Text
formatDay = T.pack . Time.formatTime Time.defaultTimeLocale "%b %e" 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 :: FilePath -> Recipe -> [CookLog.CookEntry] -> Html
renderRecipe filename recipe entries = do renderRecipe filename recipe entries = do
H.nav ! A.class_ "roux-navbar" $ do H.style $ H.toHtml recipePageStyles
H.ul $ H.li $ H.a ! A.class_ "roux-logo" ! A.href "/" $ "Roux" H.div ! A.class_ "rp-page" $ do
H.ul $ H.li $ H.a ! A.href "/recipes" $ "Recipes" -- Nav (matching landing page style)
let meta = recipeMetadata recipe H.nav ! A.class_ "rp-nav" $ do
course = metaCourse meta H.a ! A.class_ "rp-logo" ! A.href "/" $ "ROUX"
desc = metaDescription meta H.div ! A.class_ "rp-nav-links" $ do
case course of H.a ! A.href "/recipes" $ "Recipes"
Just c -> H.p ! A.class_ "roux-course" $ H.toHtml c H.a ! A.href "/cook-history" $ "Cook History"
Nothing -> pure () let meta = recipeMetadata recipe
H.div ! A.style "display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;" $ do course = metaCourse meta
H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe) desc = metaDescription meta
H.div ! A.style "text-align: right;" $ do -- Course label
H.div ! A.style "display: flex; gap: 0.75rem; align-items: center; justify-content: flex-end;" $ do case course of
case metaSource meta of Just c -> H.p ! A.class_ "rp-course" $ H.toHtml c
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
Nothing -> pure () Nothing -> pure ()
case metaImage meta of -- Title (inline-editable via existing JS)
Just img -> do H.h1 ! A.class_ "roux-recipe-title rp-title" $ H.toHtml (titleText recipe)
H.div ! A.class_ "roux-image-wrap" $ do -- Subtitle (from description)
H.img ! A.src (H.toValue img) ! A.alt (H.toValue (titleText recipe)) case desc of
H.label ! A.class_ "roux-image-overlay" ! A.for "roux-image-input" $ "Replace" Just d -> H.p ! A.class_ "rp-sub" $ H.toHtml d
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 -> pure ()
Nothing -> do -- Meta strip: last cook + source + log meal
H.div ! A.class_ "roux-image-wrap roux-image-empty" $ do H.div ! A.class_ "rp-meta-strip" $ do
H.label ! A.class_ "roux-image-placeholder" ! A.for "roux-image-input" $ "Upload photo" H.div ! A.class_ "rp-meta-strip-last" $ rpLastCookInfo entries
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;" H.div ! A.class_ "rp-meta-strip-actions" $ do
renderMetaBar meta case metaSource meta of
let sections = NE.toList (recipeSections recipe) Just url -> H.a ! A.class_ "rp-btn-ghost" ! A.href (H.toValue url) ! A.target "_blank" $ "\x2197 Original"
notes = collectNotes recipe Nothing -> pure ()
H.div ! A.class_ "roux-grid" $ do H.button ! A.class_ "rp-btn roux-record-trigger" ! A.id "roux-record-btn" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Log meal"
-- Ingredients column -- Hero image
H.div $ do rpHeroImage meta filename
H.h2 "Ingredients" -- Body grid
mapM_ renderIngredientGroup sections H.div ! A.class_ "rp-body-grid" $ do
-- Method column -- Left column: servings, ingredients, method
H.div $ do H.div $ do
H.h2 "Method" case metaServings meta of
renderMethodSections sections Just s -> do
-- Marginalia column H.div ! A.class_ "rp-section-label" $ "Serves"
H.div ! A.class_ "roux-marginalia" $ do H.div ! A.class_ "rp-serves" $ H.toHtml (showServings s)
case metaSource meta of Nothing -> pure ()
Just url -> H.div ! A.class_ "note" $ do let sections = NE.toList (recipeSections recipe)
H.h2 "Source" H.div ! A.class_ "rp-section-label" $ "Ingredients"
H.p $ H.toHtml url rpIngredientList sections
Nothing -> pure () H.div ! A.class_ "rp-section-label" $ "Method"
let recipeTags = metaTags meta rpMethodList sections
unless (null recipeTags) $ do -- Right column: cook history rail
H.div ! A.class_ "note" $ do H.aside $ do
H.h2 "Tags" H.div ! A.class_ "rp-section-label" $ do
H.p $ H.toHtml (T.intercalate " · " recipeTags) H.span "Cook history"
renderCookLog filename entries H.button ! A.class_ "rp-btn-ghost roux-record-trigger" ! H.dataAttribute "filename" (H.toValue (T.pack filename)) $ "+ Add"
unless (null notes) $ do rpHistoryList entries
H.h2 "Notes" -- Tags
mapM_ renderMarginalNote notes let tags = metaTags meta
-- Record meal modal 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.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-backdrop" ! A.id "roux-modal-backdrop" $ ""
H.div ! A.class_ "roux-modal-dialog" $ do 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_ "button" ! A.class_ "roux-modal-cancel" ! A.id "roux-modal-cancel" $ "Cancel"
H.button ! A.type_ "submit" ! A.class_ "roux-modal-confirm" $ "Log it" 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. -- | Extract display title from recipe metadata or fallback.
titleText :: Recipe -> Text titleText :: Recipe -> Text
titleText recipe = fromMaybe "Untitled Recipe" (metaTitle (recipeMetadata recipe)) 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. -- | Format servings info.
showServings :: (Int, Maybe Text) -> Text showServings :: (Int, Maybe Text) -> Text
showServings (n, Nothing) = T.pack (show n) showServings (n, Nothing) = T.pack (show n)
@@ -1084,104 +1202,6 @@ durationToSeconds d =
toDouble :: Rational -> Double toDouble :: Rational -> Double
toDouble r = fromIntegral (numerator r) / fromIntegral (denominator r) 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) -- Step items (inline rendering within a step)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -1218,36 +1238,6 @@ renderStepItem (StepComment t) =
renderStepItem StepBreak = renderStepItem StepBreak =
H.br 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 -- URL encoding