Frontend: B3 — Add/edit recipe form #23
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Single form component for creating and editing recipes. Two states: empty (new) and prefilled (edit). Tags are required and power the variety algorithm and intelligent suggestions.
Journey: J1 — Add a recipe
Role: Planner only
Screen: B3
Design rule: B3 = B4 (add and edit) — build once with two initial states.
Layout
Mobile (< 768px)
Desktop (> 1024px)
--color-pagebg, 24px padding): hero upload (80px) + name (16px input) + serves/cook/prep (3-column row) + ingredients + steps--color-surfacebg): effort chips + category chips + live preview cardForm Sections
Basic Info
Ingredients
Steps
Tags (required)
Live Preview (desktop only)
Behavior
Acceptance Criteria
Spec file:
specs/frontend/j1-add-recipe.html— screen B3 with mobile (single scroll) + desktop (split form + tags panel) previews, agent table, and LLM implementation guide.👨💻 Kai — Frontend Engineer
B3 is one of the most interesting forms to build because it's a single component with two initial states (add vs edit), a dynamic ingredient list, a dynamic steps list, autocomplete, and a live preview panel on desktop. Let me map out the implementation.
"Build once with two initial states" — how I'll handle it:
The page route will be
/recipes/new(add) and/recipes/[recipeId]/edit(edit). The+page.server.tsloadsnullfor add and the full recipe object for edit. The component receives an optionalrecipeprop:All form fields initialize from
recipeif present, otherwise empty. One component, two states. No conditional logic scattered around.Dynamic ingredient rows:
"Add ingredient" appends a new
{ quantity: '', name: '' }entry. Remove button splices the array. The reactivity here is straightforward with$stateon an array. Svelte 5's fine-grained reactivity will update only the affected rows.Autocomplete for ingredient names: this needs a debounced fetch to
GET /api/ingredients?q=...as the user types. I'll use$effect()watching the current field value, with a 300ms debounce. The autocomplete dropdown must close onEscapeand select onEnter/click. Keyboard-navigable.Dynamic step rows:
Same pattern as ingredients. Free-text
<textarea>per step. Auto-resize the textarea height to content (CSSfield-sizing: contentor a JS$effectapproach). "Add step" appends, numbered circles update automatically via{#each}index.Tags — effort + category:
<select>. Clearer interaction, better touch target.$state(Set<string>)for selected categories.$derived(isValid)combining effort + categories length check.Live preview (desktop only):
$derivedfrom the form state — no extra fetch needed. The preview card is essentially a mini-RecipeCard showing name, effort, first category tag, and serve count.hidden lg:blockapproach to show the preview panel only on desktop. On mobile, no preview.Form submission:
?/createfor new recipes?/updatefor edits (with the recipe ID in the URL)Questions I need answered:
<input type="file">with multipart form submission, or a separate upload endpoint that returns a URL? The latter is more flexible but requires a custom server endpoint.🔧 Backend Engineer
B3 creates or updates the core
recipeentity. This is the write-heavy form in the recipe domain and the data model needs to be solid.The endpoints:
POST /api/recipes— create new recipe → 201 with recipe IDPUT /api/recipes/{recipeId}— full update → 200 or 204Request body schema questions:
{ quantity: string, ingredientName: string }or as{ quantity: number, unit: string, ingredientId: UUID }? The difference matters for the autocomplete and the ingredient table.ingredientmaster table and create new entries for unknown ingredients, the ingredient entity needs to be household-scoped or global (shared ingredient names across households would enable a useful autocomplete corpus).{ order: number, text: string }? I'd go with positional ordering (the array index is the step order), but the database should storestep_orderexplicitly to allow future reordering.EASY | MEDIUM | HARD) and categories as a string array? Or tag IDs from a tag table? Enum for effort is cleaner. For categories, if it's a fixed list (Chicken, Fish, Beef, etc.), an enum or a CHECK constraint on a string column works. If categories can be user-defined, you need a tag table.Data integrity:
NOT NULL,varchar(200)with a CHECK constraintLENGTH(name) > 0. Don't allow blank names to save.NOT NULL,INTEGER,CHECK (serves > 0).Image upload:
multipart/form-data. I'd recommend a two-step approach: separatePOST /api/uploadsendpoint that returns a URL, then the recipe form submits the URL. Keeps the recipe endpoint clean and avoids large payloads.spring.servlet.multipart.max-file-size). What's the limit? 5MB? 10MB?The "assign to day slot after saving" flow:
?slotDate=2026-04-07param. After creating the recipe, the backend should optionally assign it to that slot in the same transaction, or return the recipe ID and let the frontend make a second call to assign it. The two-call approach is simpler and more composable.Update semantics:
PUTimplies full replacement. If the user removes all ingredients and saves, the old ingredients must be deleted. This means on update: delete existing ingredients and steps, then insert the new ones. APATCHendpoint that supports partial updates would be more complex and isn't needed for v1.🧪 QA Engineer
B3 is the most test-surface-rich form in the app. Dynamic lists, autocomplete, two save modes, tag validation, image upload, and an optional post-save assignment flow. Here's my full coverage matrix.
Happy paths:
Validation — bad paths:
Dynamic list behavior:
Autocomplete:
Two-state equivalence:
Post-save assignment (edge case):
?slotDate=param) → save → offer to assign → accept → verify the recipe is assigned to that date on C1Component tests:
$statearray updates correctlyisValidderived state: false when effort missing, false when no category, true when both present + name filledIntegration tests:
POST /api/recipeswith valid payload → 201POST /api/recipesmissing effort tag → 422 with clear errorPUT /api/recipes/{id}by a member → 403POST /api/recipesunauthenticated → 401PUT /api/recipes/{id}with a recipe from a different household → 403/404🔒 Sable — Security Engineer
B3 is the highest-risk screen in the recipe domain from a security standpoint. It creates and modifies persistent data, accepts file uploads, and uses autocomplete that touches the database. Let me walk through the attack surface.
Authorization — who can write:
POST /api/recipesandPUT /api/recipes/{recipeId}must require planner role. 403 for members. This must be enforced at the service layer, not just checked in the frontend. A member who callsPOST /api/recipesdirectly should get 403, regardless of what the UI shows them.PUT /api/recipes/{recipeId}: the service must verify the recipe'shousehold_idmatches the authenticated user's household before updating. Without this, a planner from household A can overwrite household B's recipes by knowing their UUID.File upload — highest-risk surface:
image/jpeg,image/png,image/webp.spring.servlet.multipart.max-file-size). A missing limit allows a DoS via large upload.Autocomplete endpoint (
GET /api/ingredients?q=...):qparameter is used in a SQL query. If it uses aLIKEclause with user input, it must be parameterized — no string concatenation.?q=with wildcard abuse (%%%%%) could cause a slow query if not handled carefully; consider adding a minimum length or max-length check.CSRF on form submission:
Input validation:
maxlengthattribute — those are client-bypassed). A 10,000-character recipe name that gets stored and then rendered on B2 could cause layout issues or be used for stored XSS if ever rendered with{@html}.{@html}allowed.🎨 Atlas — UI/UX Designer
B3 is specified as "design once with two states" — I own making sure the form is visually consistent across add and edit, and that the desktop split layout doesn't sacrifice clarity for cleverness.
Desktop layout — left panel + right panel:
--color-pagebg, 24px padding. Right panel (280px):--color-surfacebg. The visual separation is correct — the tags/preview panel is "secondary" to the form, so--color-surfaceon a slightly elevated background is appropriate.Tags panel — right side, desktop:
--green-tintbg +--green-darktext +--green-lightborder (matching the active nav state pattern). Unselected:--color-surfacebg +--color-text+--color-border.Live preview card (desktop only):
Mobile form UX:
min-height: 44pxtouch targets for the add actions.One system question: The ingredient autocomplete dropdown — does it match our existing dropdown/popover pattern from the design system, or is this the first use of an autocomplete in the app? If it's the first, I need to design and spec it properly before Kai implements it. Autocomplete dropdowns have many states (loading, results, no-results, selected) that all need defined visual treatment.