docs: add design and plan for inline title editing
This commit is contained in:
@@ -0,0 +1,367 @@
|
|||||||
|
# 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:
|
||||||
|
```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.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Inline Recipe Title Editing
|
||||||
|
|
||||||
|
Date: 2026-05-21
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add in-page editing for recipe titles. Clicking the recipe title on the detail page makes it editable inline (using `contenteditable`). Pressing Enter or clicking away saves the change via a PATCH request. Escape cancels. The DOM updates in-place on success — no page reload.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
This is the first step toward making all recipe fields editable in-page, keeping the layout stable during editing.
|
||||||
|
|
||||||
|
## Client-side interaction
|
||||||
|
|
||||||
|
1. The `<h1>` recipe title on the recipe detail page gets a click handler.
|
||||||
|
2. On click:
|
||||||
|
- `contenteditable` is set to `true` on the `<h1>`.
|
||||||
|
- The element receives focus (via `.focus()` at the end of the text).
|
||||||
|
- A CSS class `.roux-editing` is added for a subtle visual cue (faint dashed border / changed cursor).
|
||||||
|
- A hidden `<span id="roux-title-original">` stores the original text for revert.
|
||||||
|
3. On **Enter** (without Shift): prevent default newline, trigger save.
|
||||||
|
4. On **Escape**: revert to the original text, remove `contenteditable`, remove `.roux-editing`.
|
||||||
|
5. On **blur** (clicking away): trigger save.
|
||||||
|
6. **Save flow**:
|
||||||
|
- Read the current text content from the `<h1>`.
|
||||||
|
- If unchanged from original, do nothing (just exit edit mode).
|
||||||
|
- Send `PATCH /recipes/{filename}/title` with JSON body `{ "title": "New Title" }`.
|
||||||
|
- On 200 OK: update the `<h1>` text to the server's response (the server normalizes/trims it), remove `contenteditable`, remove `.roux-editing`.
|
||||||
|
- On error: revert to original text, remove edit mode, show a brief error indication.
|
||||||
|
7. **Hover hint**: a subtle pencil icon or "Edit" text appears next to the title on hover (CSS only — no extra JS needed).
|
||||||
|
|
||||||
|
## Server-side
|
||||||
|
|
||||||
|
### New route: `PATCH /recipes/{filename}/title`
|
||||||
|
|
||||||
|
**Handler**: `titleUpdateHandler` in `Roux.Server` module.
|
||||||
|
|
||||||
|
1. Validate HTTP method is PATCH (return 405 otherwise).
|
||||||
|
2. Parse JSON body: expects `{ "title": "..." }`. Return 400 if missing or invalid.
|
||||||
|
3. Look up the recipe file:
|
||||||
|
- Strip `.cook` extension, lowercase, same pattern as existing `lookupRecipe`.
|
||||||
|
- Read the `.cook` file from disk.
|
||||||
|
4. Update the title in the YAML front matter:
|
||||||
|
- Find the `title:` line in the front matter block (text between the opening `---\n` and `\n---`).
|
||||||
|
- Replace its value with the new title.
|
||||||
|
- If no `title:` field exists, insert one after the opening `---`.
|
||||||
|
- Write the file back to disk.
|
||||||
|
5. The existing fsnotify watcher picks up the change and updates the shared IORef state. SSE events notify other connected clients.
|
||||||
|
6. Return `200 OK` with JSON `{ "success": true, "title": "<normalized title>" }`.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- Recipe file not found → 404.
|
||||||
|
- File read/write error → 500 with JSON `{ "error": "Could not update recipe file" }`.
|
||||||
|
- Empty title (after trimming) → 400 with JSON `{ "error": "Title cannot be empty" }`.
|
||||||
|
- Client-side: on any error, revert to original text and show a brief visual error state.
|
||||||
|
|
||||||
|
### YAML update logic
|
||||||
|
|
||||||
|
Reuses the same pattern as the existing `updateImageMetadata` / `addOrUpdateYamlKey` functions in `Server.hs`. A dedicated `updateTitleInCookFile` function reads the file, performs the replacement, and writes it back.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- **`src/Roux/Server.hs`**: Add `titleUpdateHandler` and route registration.
|
||||||
|
- **`src/Roux/Html.hs`**: Add inline JS for title editing (`titleEditJs`), add CSS for `.roux-editing` state and hover hint.
|
||||||
|
|
||||||
|
## What's not included (future steps)
|
||||||
|
|
||||||
|
- Editing other recipe fields (source, description, servings, etc.)
|
||||||
|
- Debouncing or optimistic updates
|
||||||
|
- Keyboard shortcut indicators
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- No placeholders or TODOs.
|
||||||
|
- Internally consistent: client save matches server endpoint, error handling is symmetric.
|
||||||
|
- Scoped appropriately for a single implementation plan (one server handler + one client JS block).
|
||||||
|
- No ambiguity: `contenteditable` behavior, PATCH route, YAML update pattern are all explicit.
|
||||||
Reference in New Issue
Block a user