# 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 `

` 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 `

`** In `renderRecipe`, find the title heading and add a CSS class for JS targeting. Change: ```haskell H.h1 $ H.toHtml (titleText recipe) ``` To: ```haskell 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: ```css ".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** ```bash cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 ``` Expected: Build succeeds with no errors or warnings. - [ ] **Step 4: Commit** ```bash 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: ```haskell -- | 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** ```bash cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 ``` Expected: Build succeeds with no errors or warnings. - [ ] **Step 4: Commit** ```bash 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): ```haskell -- | 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`: ```haskell -- | 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 `_`: ```haskell ["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** ```bash cd /work/personal/roux/roux-main && ./hs stack build 2>&1 | tail -20 ``` Expected: Build succeeds with no errors or warnings. - [ ] **Step 5: Commit** ```bash 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** ```bash 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** ```bash 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** ```bash head -5 recipes/example-recipe.cook ``` Expected: The `title:` field shows the updated value. - [ ] **Step 4: Verify the GET response still works** ```bash 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** ```bash # 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"}` ```bash # Missing title curl -s -X PATCH -H "Content-Type: application/json" \ -d '{}' \ http://localhost:3000/recipes/example-recipe/title ``` Expected: `{"error":"Missing 'title' field"}` ```bash # 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** ```bash 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)** ```bash 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.