diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs
index 62d2229..743dc27 100644
--- a/src/Roux/Html.hs
+++ b/src/Roux/Html.hs
@@ -932,14 +932,20 @@ recipePageStyles =
, ".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-ingredients { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; }"
+ , ".rp-ingredient-row { display: flex; align-items: center; gap: 14px; padding: 6px 0; border-bottom: 0.5px dashed rgba(61,40,23,0.15); cursor: pointer; }"
+ , ".rp-ingredient-row:last-child { border-bottom: none; }"
+ , ".rp-ingredient-row input[type=checkbox] { appearance: none; -webkit-appearance: none; width: 15px; height: 15px; border: 1.5px solid rgba(61,44,30,0.25); border-radius: 2px; background: transparent; cursor: pointer; flex-shrink: 0; position: relative; transition: border-color 0.15s; margin: 0; }"
+ , ".rp-ingredient-row input[type=checkbox]:checked { border-color: rgba(61,44,30,0.5); }"
+ , ".rp-ingredient-row input[type=checkbox]:checked::after { content: '\x2713'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 11px; font-weight: 400; color: var(--roux-text); opacity: 0.5; line-height: 1; }"
+ , ".rp-ingredient-row .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; transition: opacity 0.15s; }"
+ , ".rp-ingredient-row .name { font-size: 0.9rem; line-height: 1.5; color: var(--roux-text); transition: opacity 0.15s; }"
+ , ".rp-ingredient-row:has(input:checked) .rp-qty { opacity: 0.4; }"
+ , ".rp-ingredient-row:has(input:checked) .name { opacity: 0.4; text-decoration: line-through; }"
, ".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 { font-size: 15px; line-height: 1.7; color: var(--roux-text); }"
+ , ".rp-method p { margin: 0 0 14px; color: var(--roux-text); }"
, ".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; }"
@@ -1068,30 +1074,36 @@ 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)]
+ -- Collect ingredients from all sections, deduplicated per section
+ let withIngredients = filter (not . Prelude.null . collectIngredients) sections
+ unnamed = [s | s <- withIngredients, isUnnamedIngredientSection s]
+ named = [s | s <- withIngredients, not (isUnnamedIngredientSection s)]
-- Unnamed sections first, then named
unless (null unnamed) $
- H.ul ! A.class_ "rp-ingredients" $
- mapM_ rpIngredientItems (concatMap collectIngredients unnamed)
+ H.div ! A.class_ "rp-ingredients" $
+ mapM_ rpIngredientItems (dedupIngredients (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)
+ isUnnamedIngredientSection s = Prelude.null (sectionName s)
+
+-- | Deduplicate ingredients by name, keeping the first occurrence.
+dedupIngredients :: [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
+dedupIngredients = go []
+ where
+ go seen [] = reverse seen
+ go seen ((name, qty) : rest)
+ | name `elem` Prelude.map fst seen = go seen rest
+ | otherwise = go ((name, qty) : seen) rest
-- | Render a named ingredient section with a sub-heading.
rpNamedIngredientSection :: Section -> Html
rpNamedIngredientSection section = do
- let ings = collectIngredients section
+ let ings = dedupIngredients (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
+ H.div ! A.class_ "rp-ingredients" $ mapM_ rpIngredientItems ings
-- | Collect ingredient (name, quantity) pairs from a section.
collectIngredients :: Section -> [(Text, Maybe Quantity)]
@@ -1106,24 +1118,31 @@ collectIngredients section =
-- | Render one ingredient item in the mockup-style list.
rpIngredientItems :: (Text, Maybe Quantity) -> Html
rpIngredientItems (name, mQty) =
- H.li $ do
+ H.label ! A.class_ "rp-ingredient-row" $ do
+ H.input ! A.type_ "checkbox"
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
+ H.span ! A.class_ "name" $ 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)
+ allItems = concatMap extractSteps methodSects
+ (_, html) = Prelude.foldl go (1, mempty) allItems
+ H.div ! A.class_ "rp-method" $ html
where
extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s))
- isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
- isEmptyStep _ = False
+ -- \| Fold through items, incrementing step counter only for non-empty steps.
+ -- Comments and notes render without consuming a step number, so any
+ -- horizontal-rule separators or front-matter comments don't throw off
+ -- the numbering.
+ go (n, acc) (sname, SecStep step)
+ | null (unStep step) || all isBlankText (unStep step) = (n, acc)
+ | otherwise = (n + 1, acc <> rpRenderMethodStep n (sname, SecStep step))
+ go (n, acc) item = (n, acc <> rpRenderMethodStep 0 item)
isBlankText (StepText t) = T.all (== ' ') t
isBlankText _ = False
isIngSection s =