- Use newtype instead of data for single-field types (RouxConfig, ImportError)
- Replace maybe "" id with fromMaybe "" throughout
- Replace not (x `elem` ys) with x `notElem` ys
- Remove redundant brackets, redundant $, and eta-reduce lookupRecipe
- Use numeric underscore for 1_000_000
Previously images were saved directly in the recipe directory
alongside .cook files. Now they go into <recipes-dir>/recipe-images/,
keeping the recipe directory clean and making the purpose of the
file clear from its path.
Users can now upload an photo for any recipe directly from the
view page. The upload button appears as either a 'Replace' overlay
on existing images or an empty dashed placeholder for recipes
without one.
Upload flow:
1. Click upload button -> file picker opens (accepts image/*)
2. JavaScript reads the file as base64, POSTs to /upload-image
with form fields: filename, file-b64, file-name
3. Server decodes the base64, saves to <recipe-dir>/<basename>.<ext>
4. Server updates the .cook file's YAML front matter, adding or
updating the 'image:' key with a /recipe-images/ URL
5. Server returns JSON success, page reloads to show the image
Server routes added:
- POST /upload-image — accepts base64-encoded image upload
- GET /recipe-images/<filename> — serves saved recipe images
(extension-whitelisted to .jpg/.jpeg/.png/.gif/.webp)
Image files are stored alongside .cook files in the recipe directory
and served via the /recipe-images/ path. The fsnotify watcher detects
the .cook metadata change and triggers an SSE reload.
Cooklang timer references (~{15%minutes}) now render as clickable
spans. Clicking one starts a live countdown displayed in-place
with MM:SS format. A reset button (↺) restarts the timer from
the original duration. A close button (✕) returns to the static
label. When the timer reaches zero, the display pulses and turns
rust-colored for 3 cycles.
Implementation:
- durationToSeconds helper converts Duration to total seconds
- Timer span uses data-seconds attribute for the total
- Label + controls (display, reset, close) structure pre-rendered
in HTML, toggled via a .running CSS class
- Timer JS initializes all .roux-timer-tag elements on page load
- Each timer manages its own setInterval, multiple timers can
run simultaneously across different steps
Adds a .roux-desc-row flex container that places the recipe
description text on the left and the image (from metaImage)
on the right, using what was previously empty horizontal space.
Image is capped at 280px, with a subtle shadow and rounded
corners. Responsively stacks on mobile.
- Drop italics on cookware/timer tags — they were over-emphasizing
roughly a third of step text. Now plain with slight opacity.
- Fix parser bug: @salt{large pinch} braces no longer leak through
as raw text. Non-numeric quantities are consumed silently,
ingredient appears without displayed quantity.
- Remove duplicate '\342\206\227 Original' link from marginalia SOURCE block.
Title-adjacent link is sufficient.
- Add tags to marginalia column for visual balance.
- Constrain content to 1120px max-width for a printed-cookbook feel.
- Add .container h2 CSS specificity override to beat Pico CSS defaults.
Section labels (INGREDIENTS, METHOD) now properly render in Fraunces.
- Switch navbars from <div> to <nav> so Pico's own nav ul{list-style:none}
suppresses gray square bullets. Add explicit li{list-style:none} override.
- Constrain content to 1120px max-width so parchment frames the page.
Changes based on design feedback:
Typography: Swap Lexend Peta → Fraunces (literary serif) for all
headings, preserving Quicksand for body text.
Color palette: Add --roux-ochre and --roux-sage vars. Restrict rust
accent to step numbers, rules, and hover. Titles and labels → ink.
Navbar: 'Roux' logo now uses Fraunces in ink color.
Parser bug: Add isMethodSection filter to exclude 'Ingredients'
sections from the method column, preventing raw text ingredient
listings from leaking into steps.
Index page: Titles in ink with rust-on-hover. Add letter dividers
(A, B, C...) for alpha mode. Show tags/course metadata under titles.
Recipe page: 3-column grid (240px 1fr 180px) with marginalia column
for source link and notes. Tag pills → sage outline. Ingredient
highlights → ochre underline. Checkboxes → hand-sketched rotation.
Source link → muted ink with rust hover.
The Cooklang parser (splitByBlankLines) identifies step boundaries by
blank lines (\n\n). renderSection was joining body items with a single
newline (\n), causing multiple steps to be parsed as one. Fixed by using
\n\n as the separator between body items, matching the spec.
When importing a recipe, the raw JSON output from scrape-recipe is now
saved as <recipe-name>.json in the recipe directory alongside the .cook
file. This lets users inspect the intermediate schema.org representation
to debug step breakdown or ingredient parsing issues.
The in-memory lastReload variable was lost on location.reload() because
the JavaScript context is destroyed. This caused a reload loop when the
watcher fired multiple SSE events for the same file change — each reload
reset the debounce to 0, so the next event would trigger another reload.
Switch to sessionStorage, which persists across page loads within the
same browser tab session. Also increases the debounce window to 3000ms
to account for page load time.
The lazy I/O in LB.hGetContents returns a thunk immediately without
reading any data. If waitForProcess is called before the ByteString
is forced, the parent blocks on the process while the child blocks
on a full pipe buffer (no one is reading). Fix by computing LB.length
(which forces full traversal) before calling waitForProcess.
createProcess returns (stdin, stdout, stderr, process), not
(stdout, stderr, _, process). Since stdin is Inherited (not CreatePipe),
it returns Nothing, not Just. The pattern (Just outH, Just errH, _, _)
was matching on the stdin and stdout fields, so it always fell through
to the error branch.
Also adds type-safety conventions to AGENTS.md: no partial patterns on
IO results, no partial functions (head/tail/fromJust), always use case
with explicit branches.
readProcessWithExitCode returns stdout/stderr as String, which forces GHC
to decode raw bytes using the current locale encoding. In a Docker
container with C locale (default), non-ASCII UTF-8 bytes (like 0xE2)
cause 'invalid argument' errors.
Replace with readProcessBytes which uses createProcess + hSetBinaryMode
to capture stdout/stderr as raw lazy ByteStrings, bypassing locale
decoding entirely. JSON is decoded directly from raw bytes via A.decode.
The urldecode helper in parseFormBody was using Html.urlDecode which only
handled %%20, %%23, and %%25. Full URLs encode colons, slashes, and dots
as %%3A, %%2F, and %%2E, so they were never decoded. The scraper received
the still-encoded URL and failed to fetch it.
Replace Html.urlDecode with a proper percent-decode that handles all %%XX
hex sequences using digitToInt.
Logs each stage of the import with [roux] prefix: request URL, script
path, exit codes, stderr output, JSON parse status, conversion status,
and file write path. Error messages now include the attempted URL for
easier debugging.
- Strip .cook from map keys to match lookupRecipe behavior
- Use makeRelative for event paths to match relative map keys
- Filter watcher to only .cook files
- Log watcher thread exceptions
- Use imported isSuffixOf from Data.List