Files
roux/docs/plans/2026-05-21-inline-title-editing.md

13 KiB

Inline Title Editing — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Allow users to click the recipe title on the detail page to edit it inline, saving via PATCH to the server.

Architecture: Client-side contenteditable on the <h1> sends PATCH /recipes/{filename}/title with JSON body. Server updates the title: field in the .cook file's YAML front matter. DOM updates in-place on success — no page reload. Existing fsnotify watcher + SSE handle out-of-band updates for other clients.

Tech Stack: Haskell (WAI), blaze-html, vanilla JS, Cooklang .cook file format


Task 1: Add title CSS class to recipe page

Files:

  • Modify: src/Roux/Html.hs:1-1032

  • Step 1: Add CSS class to the title <h1>

In renderRecipe, find the title heading and add a CSS class for JS targeting.

Change:

        H.h1 $ H.toHtml (titleText recipe)

To:

        H.h1 ! A.class_ "roux-recipe-title" $ H.toHtml (titleText recipe)

Make sure A is imported from Text.Blaze.Html5.Attributes (it already is).

  • Step 2: Add CSS for editable state

In the T.unlines block inside H.style in the page function, append these rules after the existing timer animations:

".roux-recipe-title { cursor: pointer; position: relative; }"
".roux-recipe-title:hover::after { content: '\\270E'; font-size: 0.7rem; opacity: 0.3; margin-left: 0.5rem; vertical-align: super; }"
".roux-recipe-title.roux-editing { cursor: text; border-bottom: 1px dashed rgba(184, 92, 56, 0.4); outline: none; }"
".roux-recipe-title.roux-title-error { animation: roux-shake 0.3s ease-in-out; }"
"@keyframes roux-shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } }"
  • Step 3: Run ./hs stack build to confirm compilation
cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20

Expected: Build succeeds with no errors or warnings.

  • Step 4: Commit
git add src/Roux/Html.hs
git commit -m "feat: add CSS class and styles for editable recipe title"

Task 2: Add client-side title editing JavaScript

Files:

  • Modify: src/Roux/Html.hs

  • Step 1: Add titleEditJs inline script

After the existing recipeImageUploadJs definition (around the end of the JS definitions), add a new titleEditJs value:

-- | Inline JavaScript for in-place recipe title editing.
titleEditJs :: Text
titleEditJs =
    T.unlines
        [ "(function(){"
        , "'use strict';"
        , "var h1 = document.querySelector('.roux-recipe-title');"
        , "if (!h1) return;"
        , "var originalText = '';"
        , "var filenameEl = document.getElementById('roux-current-recipe');"
        , "if (!filenameEl) return;"
        , "var recipeFilename = filenameEl.textContent.trim();"
        , ""
        , "h1.addEventListener('click', function(e) {"
        , "  if (h1.getAttribute('contenteditable') === 'true') return;"
        , "  originalText = h1.textContent.trim();"
        , "  h1.setAttribute('contenteditable', 'true');"
        , "  h1.classList.add('roux-editing');"
        , "  var range = document.createRange();"
        , "  var sel = window.getSelection();"
        , "  range.selectNodeContents(h1);"
        , "  range.collapse(false);"
        , "  sel.removeAllRanges();"
        , "  sel.addRange(range);"
        , "});"
        , ""
        , "h1.addEventListener('keydown', function(e) {"
        , "  if (e.key === 'Enter' && !e.shiftKey) {"
        , "    e.preventDefault();"
        , "    h1.blur();"
        , "  } else if (e.key === 'Escape') {"
        , "    e.preventDefault();"
        , "    h1.textContent = originalText;"
        , "    h1.removeAttribute('contenteditable');"
        , "    h1.classList.remove('roux-editing');"
        , "  }"
        , "});"
        , ""
        , "h1.addEventListener('blur', function() {"
        , "  if (h1.getAttribute('contenteditable') !== 'true') return;"
        , "  var newText = h1.textContent.trim();"
        , "  if (newText === originalText || !newText) {"
        , "    h1.textContent = originalText;"
        , "    h1.removeAttribute('contenteditable');"
        , "    h1.classList.remove('roux-editing');"
        , "    return;"
        , "  }"
        , "  h1.removeAttribute('contenteditable');"
        , "  h1.classList.remove('roux-editing');"
        , "  var xhr = new XMLHttpRequest();"
        , "  xhr.open('PATCH', '/recipes/' + encodeURIComponent(recipeFilename) + '/title', true);"
        , "  xhr.setRequestHeader('Content-Type', 'application/json');"
        , "  xhr.onload = function() {"
        , "    if (xhr.status === 200) {"
        , "      try {"
        , "        var resp = JSON.parse(xhr.responseText);"
        , "        h1.textContent = resp.title || newText;"
        , "      } catch(e) {"
        , "        h1.textContent = newText;"
        , "      }"
        , "    } else {"
        , "      h1.textContent = originalText;"
        , "      h1.classList.add('roux-title-error');"
        , "      setTimeout(function() { h1.classList.remove('roux-title-error'); }, 2000);"
        , "    }"
        , "  };"
        , "  xhr.onerror = function() {"
        , "    h1.textContent = originalText;"
        , "    h1.classList.add('roux-title-error');"
        , "    setTimeout(function() { h1.classList.remove('roux-title-error'); }, 2000);"
        , "  };"
        , "  xhr.send(JSON.stringify({ title: newText }));"
        , "});"
        , "})();"
        ]

- [ ] **Step 2: Include `titleEditJs` in the recipe page's script block**

In `recipePage`, find the `sseScript` definition and add `titleEditJs`:

```haskell
    sseScript = do
        H.script ! A.type_ "text/javascript" $ H.preEscapedText sseRecipeJs
        H.script ! A.type_ "text/javascript" $ H.preEscapedText timerJs
        H.script ! A.type_ "text/javascript" $ H.preEscapedText recipeImageUploadJs
        H.script ! A.type_ "text/javascript" $ H.preEscapedText titleEditJs
  • Step 3: Run ./hs stack build to confirm compilation
cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20

Expected: Build succeeds with no errors or warnings.

  • Step 4: Commit
git add src/Roux/Html.hs
git commit -m "feat: add inline title editing JavaScript"

Task 3: Add server-side title update handler

Files:

  • Modify: src/Roux/Server.hs

  • Step 1: Add the updateTitleInCookFile helper function

Add this function somewhere in Server.hs (e.g., near the existing updateImageMetadata function, around line 400):

-- | Update the title in a .cook file's YAML front matter.
updateTitleInCookFile :: FilePath -> Text -> IO ()
updateTitleInCookFile filepath newTitle = do
    content <- BS.readFile filepath
    let text = TE.decodeUtf8 content
        updated = case T.stripPrefix "---\n" text of
            Just afterOpen ->
                let (yamlBlock, rest) = T.breakOn "\n---" afterOpen
                    newYaml = addOrUpdateYamlKey "title" newTitle yamlBlock
                 in "---\n" <> newYaml <> rest
            Nothing ->
                "---\ntitle: " <> newTitle <> "\n---\n\n" <> text
    BS.writeFile filepath (TE.encodeUtf8 updated)

This follows the exact same pattern as the existing updateImageMetadata function (same block structure, same YAML helper).

  • Step 2: Add the titleUpdateHandler function

Add after uploadImageHandler:

-- | Handle PATCH /recipes/{filename}/title — update the recipe title.
titleUpdateHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
titleUpdateHandler recipeDir state request respond = do
    recipes <- readIORef state
    case Wai.pathInfo request of
        ["recipes", rawPath, "title"] -> do
            let key = map toLower (stripCookExt (T.unpack rawPath))
            case Map.lookup key recipes of
                Nothing -> respond (jsonError "Recipe not found")
                Just info -> do
                    body <- readRequestBody request
                    case A.decode body of
                        Nothing -> respond (jsonError "Invalid JSON body")
                        Just (val :: A.Value) ->
                            case titleFromJson val of
                                Nothing -> respond (jsonError "Missing 'title' field")
                                Just title -> do
                                    let trimmed = T.strip title
                                    if T.null trimmed
                                        then respond (jsonError "Title cannot be empty")
                                        else do
                                            let filepath = recipeDir </> Idx.riFilename info
                                            result <- try $ updateTitleInCookFile filepath trimmed
                                            case result of
                                                Left (_ :: IOException) ->
                                                    respond (jsonError "Could not update recipe file")
                                                Right () ->
                                                    respond $
                                                        jsonOk
                                                            [ ("success", "true")
                                                            , ("title", trimmed)
                                                            ]
        _ -> respond notFound
  where
    titleFromJson :: A.Value -> Maybe Text
    titleFromJson (A.Object obj) = case A.lookup "title" obj of
        Just (A.String t) -> Just t
        _ -> Nothing
    titleFromJson _ = Nothing

Note: The try import (Control.Exception.try) is already in scope.

  • Step 3: Register the route in router

In the router function, add the new route before the catch-all _:

        ["recipe-images", filename] -> serveRecipeImage recipeDir (T.unpack filename) request respond
        ["recipes", rawPath, "title"] | Wai.requestMethod request == "PATCH" ->
            titleUpdateHandler recipeDir state request respond
        _ -> handleRequest state request respond

This checks the HTTP method with a guard so that GET /recipes/foo/title still falls through to the normal handling.

You'll also need to make Wai.requestMethod available — it's already imported via Network.Wai as Wai.

  • Step 4: Run ./hs stack build to confirm compilation
cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20

Expected: Build succeeds with no errors or warnings.

  • Step 5: Commit
git add src/Roux/Server.hs
git commit -m "feat: add PATCH endpoint for recipe title updates"

Task 4: End-to-end verification

Files: None (manual verification against the running server)

  • Step 1: Start the server
cd /work/personal/roux/roux-main
# Use stack run to start the server (assuming the usual entrypoint)
./hs stack run 2>&1 &
sleep 3

Or if the project has a specific startup command, adapt accordingly.

  • Step 2: Test the PATCH endpoint with curl
curl -s -X PATCH \
  -H "Content-Type: application/json" \
  -d '{"title":"Updated Title"}' \
  http://localhost:3000/recipes/example-recipe/title

Expected: {"success":"true","title":"Updated Title"}

  • Step 3: Verify the file was updated on disk
head -5 recipes/example-recipe.cook

Expected: The title: field shows the updated value.

  • Step 4: Verify the GET response still works
curl -s http://localhost:3000/recipes/example-recipe | grep -i "Updated Title"

Expected: The page HTML contains the updated title.

  • Step 5: Test error cases
# Empty title
curl -s -X PATCH -H "Content-Type: application/json" \
  -d '{"title":"  "}' \
  http://localhost:3000/recipes/example-recipe/title

Expected: {"error":"Title cannot be empty"}

# Missing title
curl -s -X PATCH -H "Content-Type: application/json" \
  -d '{}' \
  http://localhost:3000/recipes/example-recipe/title

Expected: {"error":"Missing 'title' field"}

# Nonexistent recipe
curl -s -X PATCH -H "Content-Type: application/json" \
  -d '{"title":"Test"}' \
  http://localhost:3000/recipes/nonexistent/title

Expected: {"error":"Recipe not found"}

  • Step 6: Run the existing test suite
cd /work/personal/roux/roux-main && ./hs stack test 2>&1 | tail -20

Expected: All existing tests pass (no regressions).

  • Step 7: Commit (if any fixes needed from verification)
git add -A
git commit -m "fix: address issues found during verification"

Self-review

  • Spec coverage: Every spec requirement has a corresponding task. Task 1 = CSS/hover hint. Task 2 = client-side JS (click-to-edit, Enter save, Escape revert, blur save, error handling). Task 3 = PATCH endpoint, YAML update, error responses. Task 4 = verification.
  • Placeholder scan: No placeholders, TODOs, or vague steps. All code is concrete and complete.
  • Type consistency: addOrUpdateYamlKey (existing) takes (Text, Text, Text) -> Text. updateTitleInCookFile takes (FilePath, Text) -> IO (). titleFromJson returns Maybe Text. All consistent.