Fix recipe page: step text contrast and interactive ingredient checkboxes
Build and Deploy / build-and-deploy (push) Successful in 1m55s
Build and Deploy / build-and-deploy (push) Successful in 1m55s
- Darken step text: use --roux-text instead of #4A3525, explicit p color to override Pico CSS defaults - Make ingredient list interactive: each ingredient is now a checkbox row that shows ✓ checkmark on click with line-through + muted opacity - Collect ingredients from ALL sections (not just sections named 'Ingredients'), deduplicated per section, grouped by section name - Switch from <ul>/<li> to <div>/<label> with checkbox inputs
This commit is contained in:
+44
-25
@@ -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-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-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 { list-style: none; padding: 0; margin: 0 0 28px; font-size: 15px; }"
|
||||||
, ".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-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-ingredients li:last-child { border-bottom: none; }"
|
, ".rp-ingredient-row:last-child { border-bottom: none; }"
|
||||||
, ".rp-ingredients .rp-qty { color: #8A7560; min-width: 48px; font-variant-numeric: tabular-nums; }"
|
, ".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-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 { font-size: 15px; line-height: 1.7; color: var(--roux-text); }"
|
||||||
, ".rp-method p { margin: 0 0 14px; }"
|
, ".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-step { font-weight: 500; color: var(--roux-text); margin-right: 4px; }"
|
||||||
, ".rp-method .rp-comment { color: #8A7560; font-size: 14px; }"
|
, ".rp-method .rp-comment { color: #8A7560; font-size: 14px; }"
|
||||||
, ".rp-method .rp-note { color: #8A7560; font-size: 13px; }"
|
, ".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).
|
-- | Render ingredients as a flat list grouped by section (mockup style).
|
||||||
rpIngredientList :: [Section] -> Html
|
rpIngredientList :: [Section] -> Html
|
||||||
rpIngredientList sections = do
|
rpIngredientList sections = do
|
||||||
let ingSects = filter isIngSection sections
|
-- Collect ingredients from all sections, deduplicated per section
|
||||||
unnamed = [s | s <- ingSects, isUnnamedIngSection s]
|
let withIngredients = filter (not . Prelude.null . collectIngredients) sections
|
||||||
named = [s | s <- ingSects, not (isUnnamedIngSection s)]
|
unnamed = [s | s <- withIngredients, isUnnamedIngredientSection s]
|
||||||
|
named = [s | s <- withIngredients, not (isUnnamedIngredientSection s)]
|
||||||
-- Unnamed sections first, then named
|
-- Unnamed sections first, then named
|
||||||
unless (null unnamed) $
|
unless (null unnamed) $
|
||||||
H.ul ! A.class_ "rp-ingredients" $
|
H.div ! A.class_ "rp-ingredients" $
|
||||||
mapM_ rpIngredientItems (concatMap collectIngredients unnamed)
|
mapM_ rpIngredientItems (dedupIngredients (concatMap collectIngredients unnamed))
|
||||||
mapM_ rpNamedIngredientSection named
|
mapM_ rpNamedIngredientSection named
|
||||||
where
|
where
|
||||||
isIngSection s =
|
isUnnamedIngredientSection s = Prelude.null (sectionName s)
|
||||||
case sectionName s of
|
|
||||||
Just n -> let lower = T.toLower n in lower `elem` ["ingredients", "ingredient"]
|
-- | Deduplicate ingredients by name, keeping the first occurrence.
|
||||||
Nothing -> not . Prelude.null $ collectIngredients s
|
dedupIngredients :: [(Text, Maybe Quantity)] -> [(Text, Maybe Quantity)]
|
||||||
isUnnamedIngSection s = Prelude.null (sectionName s)
|
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.
|
-- | Render a named ingredient section with a sub-heading.
|
||||||
rpNamedIngredientSection :: Section -> Html
|
rpNamedIngredientSection :: Section -> Html
|
||||||
rpNamedIngredientSection section = do
|
rpNamedIngredientSection section = do
|
||||||
let ings = collectIngredients section
|
let ings = dedupIngredients (collectIngredients section)
|
||||||
unless (null ings) $ do
|
unless (null ings) $ do
|
||||||
case sectionName section of
|
case sectionName section of
|
||||||
Just n -> H.p ! A.class_ "rp-ing-section" $ H.toHtml n
|
Just n -> H.p ! A.class_ "rp-ing-section" $ H.toHtml n
|
||||||
Nothing -> pure ()
|
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.
|
-- | Collect ingredient (name, quantity) pairs from a section.
|
||||||
collectIngredients :: Section -> [(Text, Maybe Quantity)]
|
collectIngredients :: Section -> [(Text, Maybe Quantity)]
|
||||||
@@ -1106,24 +1118,31 @@ collectIngredients section =
|
|||||||
-- | Render one ingredient item in the mockup-style list.
|
-- | Render one ingredient item in the mockup-style list.
|
||||||
rpIngredientItems :: (Text, Maybe Quantity) -> Html
|
rpIngredientItems :: (Text, Maybe Quantity) -> Html
|
||||||
rpIngredientItems (name, mQty) =
|
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" $
|
H.span ! A.class_ "rp-qty" $
|
||||||
case mQty of
|
case mQty of
|
||||||
Just q -> H.toHtml (showQuantity q)
|
Just q -> H.toHtml (showQuantity q)
|
||||||
Nothing -> H.toHtml ("\x2014" :: Text)
|
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).
|
-- | Render numbered method steps as plain paragraphs (mockup style).
|
||||||
rpMethodList :: [Section] -> Html
|
rpMethodList :: [Section] -> Html
|
||||||
rpMethodList sections = do
|
rpMethodList sections = do
|
||||||
let methodSects = filter (not . isIngSection) sections
|
let methodSects = filter (not . isIngSection) sections
|
||||||
allSteps = concatMap extractSteps methodSects
|
allItems = concatMap extractSteps methodSects
|
||||||
meaningful = filter (not . isEmptyStep) allSteps
|
(_, html) = Prelude.foldl go (1, mempty) allItems
|
||||||
H.div ! A.class_ "rp-method" $ mapM_ (uncurry rpRenderMethodStep) (zip [1 ..] meaningful)
|
H.div ! A.class_ "rp-method" $ html
|
||||||
where
|
where
|
||||||
extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s))
|
extractSteps s = Prelude.map (sectionName s,) (NE.toList (sectionBody s))
|
||||||
isEmptyStep (_, SecStep step) = null (unStep step) || all isBlankText (unStep step)
|
-- \| Fold through items, incrementing step counter only for non-empty steps.
|
||||||
isEmptyStep _ = False
|
-- 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 (StepText t) = T.all (== ' ') t
|
||||||
isBlankText _ = False
|
isBlankText _ = False
|
||||||
isIngSection s =
|
isIngSection s =
|
||||||
|
|||||||
Reference in New Issue
Block a user