Skip to content

Recipe display: flat directions, tickable ingredient & direction checkboxes#308

Merged
spe1020 merged 13 commits intozapcooking:mainfrom
dmnyc:recipe-remove-autogroup
Apr 19, 2026
Merged

Recipe display: flat directions, tickable ingredient & direction checkboxes#308
spe1020 merged 13 commits intozapcooking:mainfrom
dmnyc:recipe-remove-autogroup

Conversation

@dmnyc
Copy link
Copy Markdown
Collaborator

@dmnyc dmnyc commented Apr 19, 2026

Summary

Three related recipe-display improvements on top of #306#305#304.

1. Remove auto-grouping of directions into phases

The keyword heuristic was mis-classifying steps — e.g. a banana-bread recipe landed Heat oven to 350° under "Baking & Cooking" and every remaining step (including Bake for 65 minutes) under "Finishing & Serving", which read as nonsense.

Drop PHASE_KEYWORDS and the phase-assignment loop. extractAndGroupDirections now always returns a single { id: 'directions', title: 'Directions', steps } phase; the collapsibles, expand/collapse controls, and hash-scroll per phase are gone with the phase concept. Existing callers (Recipe, PrintRecipeModal) work unchanged because both already handle the single-phase case.

2. Tickable ingredient checkboxes (closes #223)

Replace the bullet-list ingredients with a checkbox list so cooks can tick off items as they shop or cook. Checks persist per-recipe in localStorage keyed by the Nostr event id (recipe_ingredients_checked:<id>) so state survives reloads and navigation. A "Clear" affordance appears once anything is checked.

Implementation: extract the ## Ingredients section out of the prose "before directions" chunk the same way ## Details already is, parse its items, and render them via a new Ingredients.svelte component between the prose and DirectionsPhases. Updated scrollToIngredients to target the new section's DOM id. PrintRecipeModal pulls ingredients from event.content independently, so printing is unaffected.

3. Tickable direction checkboxes

Mirror the ingredients pattern for directions. Checks persist at recipe_directions_checked:<id>; checked steps get strikethrough + muted color but retain their step number for easy resumption. Step text is left verbatim — no scaling, no transformation.

Notes

Test plan

  • Open a recipe — directions render as a flat numbered list (no phase headings or collapsibles).
  • Ingredients render as checkable items; tapping toggles the check + strikethrough; "Clear" resets.
  • Reload the page — ingredient and direction checks persist.
  • Open a different recipe — checks are recipe-specific, not shared.
  • Overview card's "jump to ingredients" button still scrolls correctly.
  • Print Recipe Modal still shows ingredients and directions.
  • No regressions for recipes that have no ## Ingredients section (they simply don't render the checkbox block).

dmnyc added 13 commits April 19, 2026 13:53
Tailwind's dark:hidden / dark:block on the logo <img> tags only take
effect once the main CSS bundle loads. In dev (and occasional edge
cases in prod) there's a brief window where both logos are visible and
the light one paints first. Add a tiny inline <style> in app.html
keyed on html.dark so the correct logo is selected before any external
CSS arrives; tag the <img> pairs with logo-light / logo-dark.
A subtle "Add images by URL" toggle under the media grid lets users
paste a direct image/video URL instead of uploading. First URL added
becomes the cover photo (satisfying the publish requirement). Works
across all three recipe editors (create, fork, gated) via the shared
MediaUploader component.
DOMPurify can strip target/rel on <a> in some configurations, which
caused markdown-preview links in the recipe editor to navigate
in-place and destroy unsaved edits. Add an afterSanitizeAttributes
hook that reapplies target=_blank + rel=noopener noreferrer on every
anchor post-sanitization.
Watch the 11 draft fields via a JSON signature; 2s after the last
edit, save to localStorage (and queue the debounced relay sync) so
users don't lose work. Skips while empty and while a manual save is
in flight; updates the URL with ?draft=<id> so reloads resume the
same draft.
Auto-save was toggling isSavingDraft and writing a transient status
message, which swapped the Save Draft button to "Saving…" and shifted
the button row every 2s while typing — the editor appeared to jump
around. The URL was also rewritten on every save.

Make auto-save fully silent: no button state toggle, no message flash,
and only write ?draft=<id> the first time a new draft gets an id.
Manual save keeps its full feedback, and the Save Draft button's cloud
icon still reflects relay sync state once the background publisher
settles.
Two issues made reordering the last items nearly impossible:

- handleDrop always inserted before the target, so dropping on the
  last item never moved the dragged item past it. Detect whether the
  cursor is in the target's top or bottom half and insert before or
  after accordingly. A top/bottom box-shadow marker shows which side
  the drop will land on (no layout shift).

- The 8px flex gap between items was dead space — the drop zone only
  covered each pill. Drop gap-2 and give each <li> py-1 padding
  (first:pt-0 last:pb-0) so the hit area extends into the gap; the
  visible pill moves to an inner <div>. Total vertical spacing
  between items is unchanged.
- parser.ts: use a dedicated DOMPurify instance for markdown so the
  afterSanitizeAttributes hook doesn't leak target=_blank behavior into
  every other sanitize() call in the app (e.g. the longform editor).
- MediaUploader.svelte: normalize the pasted URL via `parsed.toString()`
  before the dedupe check and before storing, so equivalent URLs
  (`example.com` vs `example.com/`) collapse to one entry.
- MediaUploader.svelte: add `aria-expanded` + `aria-controls` to the
  "Add images by URL" disclosure toggle for screen-reader support.
- create/+page.svelte: allow auto-save to persist when all fields are
  cleared on an existing draft (previously the delete-everything state
  was never saved, so reloading brought the old content back).
- create/+page.svelte: declare `draftSignature` explicitly and debounce
  its computation to 250ms so we don't re-JSON.stringify the entire
  draft on every keystroke.
<svelte:window on:error> was catching native ErrorEvents but treating
them as CustomEvents, so the details were lost and every spurious
window error — including the ones iOS Safari fires during share /
backgrounding / service-worker transitions — nuked the entire app
with an "Oops! Something went wrong" screen.

Remove the svelte:window binding and align the window 'error'
listener with the existing 'unhandledrejection' listener: log-only,
no fatal UI. The programmatic CustomEvent path is preserved for
child components that want to surface a fallback deliberately.
After the earlier patch removed `<svelte:window on:error={handleError} />`
to stop benign window errors from nuking the app, `handleError` had no
caller and the entire fallback UI / retry / error-state code was
effectively dead — RecipeErrorBoundary and FeedErrorBoundary would
never render their fallback.

Re-wire `handleError` to a dedicated `window` CustomEvent
(`zc:fatal-error`). Child components that hit a genuinely fatal error
can dispatch:

    window.dispatchEvent(new CustomEvent('zc:fatal-error', {
      detail: { error, errorInfo }
    }));

to surface the boundary's fallback UI. Native ErrorEvents are still
logged-only, same as before.
Typing `@<name>` in the new kind:1 post composer was not showing any
suggestions. The dropdown component WAS rendering with the right state
(`show=true`, populated `suggestions`), but it was positioned
off-screen because of a CSS containing-block issue introduced by zapcooking#280.

Root cause: zapcooking#280 switched MentionDropdown to `position: fixed` to
escape the composer's overflow container. Per the CSS spec, `fixed`
positions against the viewport unless an ancestor has `transform`,
`filter`, or `perspective` — in which case that ancestor becomes the
containing block. Modal's <dialog> uses Tailwind's
`-translate-x-1/2 -translate-y-1/2` (a permanent `transform`), so the
dropdown's `top`/`left` values — computed from the caret's
`getBoundingClientRect()` in viewport coordinates — were being
interpreted relative to the dialog box instead, pushing the dropdown
off-screen.

Fix: portal the dropdown directly to `document.body` when `show` is
true. It now lives outside the transformed dialog subtree, so
`position: fixed` is correctly viewport-relative and the caret-based
coordinates land where expected.

Also drop a redundant double-assignment in PostComposer's on:input
handler (also from zapcooking#280) so the composer matches the working
ReplyComposer pattern — the controller's `onTextChange` callback is
already the single source of truth for composer text state.
The keyword heuristic was mis-classifying steps — e.g. a banana-bread
recipe would land "Heat oven to 350°" under Baking & Cooking and every
remaining step (including "Bake for 65 minutes") under Finishing &
Serving, which read as nonsense.

Drop PHASE_KEYWORDS and the phase-assignment loop. extractAndGroupDirections
now always returns a single { id: 'directions', title: 'Directions',
steps } phase — existing callers (Recipe, PrintRecipeModal) work
unchanged because both already handle the single-phase case. The
DirectionsPhases component now renders a plain numbered list; the
collapsibles, expand/collapse controls, and hash-scroll per phase are
gone with the phase concept.
Replace the bullet-list ingredients with a checkbox list so cooks can
tick off items as they shop or cook. Checks persist per-recipe in
localStorage keyed by the Nostr event id (`recipe_ingredients_checked:<id>`)
so state survives reloads and navigation. Adds a "Clear" affordance once
anything is checked.

Implementation: extract the `## Ingredients` section out of the prose
"before directions" chunk the same way `## Details` already is, parse
its items into an array, and render them via a new Ingredients.svelte
component between the prose and DirectionsPhases. Update
scrollToIngredients to target the new section's DOM id. PrintRecipeModal
pulls ingredients from event.content independently, so printing is
unaffected.

Closes zapcooking#223
Mirror the ingredients-checkbox pattern for directions so cooks can
tick off steps as they work through the recipe. Checks persist
per-recipe in localStorage keyed by step number
(`recipe_directions_checked:<id>`). Shows a "Clear" affordance once
anything is checked; checked steps get strikethrough + muted color but
retain their step number for easy resumption.

Note that this intentionally does not scale or transform step text —
timing and instruction text is left as-is.
@dmnyc dmnyc force-pushed the recipe-remove-autogroup branch from 0ec9ad7 to 1d3c750 Compare April 19, 2026 17:56
@spe1020 spe1020 merged commit c8e7d82 into zapcooking:main Apr 19, 2026
1 check passed
@dmnyc dmnyc deleted the recipe-remove-autogroup branch April 19, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: change ingredient bullet list to tickboxes that can be checked off during cooking

2 participants