Frontend: B3 — Add/edit recipe form #23

Closed
opened 2026-04-02 11:27:27 +02:00 by marcel · 6 comments
Owner

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)

  • Single scrollable column
  • Hero image upload + recipe name + serves/cook time + ingredients list + steps list + tags

Desktop (> 1024px)

  • Sidebar (224px) + topbar (breadcrumb + Save/Cancel buttons)
  • Left panel (flex:1, --color-page bg, 24px padding): hero upload (80px) + name (16px input) + serves/cook/prep (3-column row) + ingredients + steps
  • Right panel (280px, --color-surface bg): effort chips + category chips + live preview card

Form Sections

Basic Info

  • Hero image upload (optional)
  • Recipe name (text, required)
  • Serves (number), Cook time (number), Prep time (number) — 3-column on desktop

Ingredients

  • Editable list: each row has quantity input + name input + remove button
  • "Add ingredient" link at bottom
  • Autocomplete ingredient names from ingredient table

Steps

  • Numbered list with numbered circles
  • Each step is a free-text textarea
  • Steps are optional at save time — recipes without steps can still be planned
  • "Add step" link at bottom

Tags (required)

  • Effort level (required, single-select): Easy / Medium / Hard
  • Category (required, at least 1): Chicken, Fish, Beef, Vegetarian, Pasta, etc.

Live Preview (desktop only)

  • Shows a preview card that updates as the user types

Behavior

  • Save → INSERT (new) or UPDATE (edit) recipe → redirect to B1
  • Minimum tags to save: effort level + at least 1 category tag
  • If entered from a day slot in the planner, offer to assign the recipe to that day after saving

Acceptance Criteria

  • Single component handles both add (empty) and edit (prefilled) states
  • Ingredient list with add/remove rows and autocomplete
  • Steps list with add/remove (optional at save)
  • Effort + category tags required for save
  • Desktop: live preview panel (280px right)
  • Save persists to backend, redirects to B1
## 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) - Single scrollable column - Hero image upload + recipe name + serves/cook time + ingredients list + steps list + tags ### Desktop (> 1024px) - Sidebar (224px) + topbar (breadcrumb + Save/Cancel buttons) - Left panel (flex:1, `--color-page` bg, 24px padding): hero upload (80px) + name (16px input) + serves/cook/prep (3-column row) + ingredients + steps - Right panel (280px, `--color-surface` bg): effort chips + category chips + live preview card ## Form Sections ### Basic Info - Hero image upload (optional) - Recipe name (text, required) - Serves (number), Cook time (number), Prep time (number) — 3-column on desktop ### Ingredients - Editable list: each row has quantity input + name input + remove button - "Add ingredient" link at bottom - Autocomplete ingredient names from ingredient table ### Steps - Numbered list with numbered circles - Each step is a free-text textarea - Steps are **optional at save time** — recipes without steps can still be planned - "Add step" link at bottom ### Tags (required) - **Effort level** (required, single-select): Easy / Medium / Hard - **Category** (required, at least 1): Chicken, Fish, Beef, Vegetarian, Pasta, etc. ### Live Preview (desktop only) - Shows a preview card that updates as the user types ## Behavior - Save → INSERT (new) or UPDATE (edit) recipe → redirect to B1 - **Minimum tags to save:** effort level + at least 1 category tag - If entered from a day slot in the planner, offer to assign the recipe to that day after saving ## Acceptance Criteria - [ ] Single component handles both add (empty) and edit (prefilled) states - [ ] Ingredient list with add/remove rows and autocomplete - [ ] Steps list with add/remove (optional at save) - [ ] Effort + category tags required for save - [ ] Desktop: live preview panel (280px right) - [ ] Save persists to backend, redirects to B1
marcel added the kind/featurepriority/medium labels 2026-04-02 11:30:05 +02:00
Author
Owner

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.

**Spec file:** [`specs/frontend/j1-add-recipe.html`](../specs/frontend/j1-add-recipe.html) — screen B3 with mobile (single scroll) + desktop (split form + tags panel) previews, agent table, and LLM implementation guide.
Author
Owner

👨‍💻 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.ts loads null for add and the full recipe object for edit. The component receives an optional recipe prop:

let { recipe = null } = $props();

All form fields initialize from recipe if present, otherwise empty. One component, two states. No conditional logic scattered around.

Dynamic ingredient rows:

let ingredients = $state(recipe?.ingredients ?? [{ quantity: '', name: '' }]);

"Add ingredient" appends a new { quantity: '', name: '' } entry. Remove button splices the array. The reactivity here is straightforward with $state on 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 on Escape and select on Enter/click. Keyboard-navigable.

Dynamic step rows:

Same pattern as ingredients. Free-text <textarea> per step. Auto-resize the textarea height to content (CSS field-sizing: content or a JS $effect approach). "Add step" appends, numbered circles update automatically via {#each} index.

Tags — effort + category:

  • Effort: single-select among Easy/Medium/Hard. I'll use radio buttons styled as chips, not a <select>. Clearer interaction, better touch target.
  • Category: multi-select chips. Each chip toggles independently. $state(Set<string>) for selected categories.
  • Validation: both must be selected before save is enabled. I'll use $derived(isValid) combining effort + categories length check.

Live preview (desktop only):

  • Renders a preview card that updates as the user types. This is $derived from the form state — no extra fetch needed. The preview card is essentially a mini-RecipeCard showing name, effort, first category tag, and serve count.
  • I'll use Tailwind's hidden lg:block approach to show the preview panel only on desktop. On mobile, no preview.

Form submission:

  • SvelteKit form actions for both save paths. The action name distinguishes add vs edit:
    • ?/create for new recipes
    • ?/update for edits (with the recipe ID in the URL)
  • On success → redirect to B1. On error → return validation errors, display inline.

Questions I need answered:

  • Hero image upload: is this a standard <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.
  • Autocomplete: does it fuzzy-match across the household's ingredient history, or a global ingredient list? What's the API endpoint?
  • "If entered from a day slot in the planner, offer to assign the recipe to that day after saving" — how is the day slot context passed? URL param? Session state? This is an edge case but needs a defined mechanism.
## 👨‍💻 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.ts` loads `null` for add and the full recipe object for edit. The component receives an optional `recipe` prop: ```ts let { recipe = null } = $props(); ``` All form fields initialize from `recipe` if present, otherwise empty. One component, two states. No conditional logic scattered around. **Dynamic ingredient rows:** ```ts let ingredients = $state(recipe?.ingredients ?? [{ quantity: '', name: '' }]); ``` "Add ingredient" appends a new `{ quantity: '', name: '' }` entry. Remove button splices the array. The reactivity here is straightforward with `$state` on 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 on `Escape` and select on `Enter`/click. Keyboard-navigable. **Dynamic step rows:** Same pattern as ingredients. Free-text `<textarea>` per step. Auto-resize the textarea height to content (CSS `field-sizing: content` or a JS `$effect` approach). "Add step" appends, numbered circles update automatically via `{#each}` index. **Tags — effort + category:** - Effort: single-select among Easy/Medium/Hard. I'll use radio buttons styled as chips, not a `<select>`. Clearer interaction, better touch target. - Category: multi-select chips. Each chip toggles independently. `$state(Set<string>)` for selected categories. - Validation: both must be selected before save is enabled. I'll use `$derived(isValid)` combining effort + categories length check. **Live preview (desktop only):** - Renders a preview card that updates as the user types. This is `$derived` from the form state — no extra fetch needed. The preview card is essentially a mini-RecipeCard showing name, effort, first category tag, and serve count. - I'll use Tailwind's `hidden lg:block` approach to show the preview panel only on desktop. On mobile, no preview. **Form submission:** - SvelteKit form actions for both save paths. The action name distinguishes add vs edit: - `?/create` for new recipes - `?/update` for edits (with the recipe ID in the URL) - On success → redirect to B1. On error → return validation errors, display inline. **Questions I need answered:** - Hero image upload: is this a standard `<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. - Autocomplete: does it fuzzy-match across the household's ingredient history, or a global ingredient list? What's the API endpoint? - "If entered from a day slot in the planner, offer to assign the recipe to that day after saving" — how is the day slot context passed? URL param? Session state? This is an edge case but needs a defined mechanism.
Author
Owner

🔧 Backend Engineer

B3 creates or updates the core recipe entity. 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 ID
  • PUT /api/recipes/{recipeId} — full update → 200 or 204
  • Both require planner role. Members cannot create or edit recipes.

Request body schema questions:

  • Ingredients: is each ingredient sent as { quantity: string, ingredientName: string } or as { quantity: number, unit: string, ingredientId: UUID }? The difference matters for the autocomplete and the ingredient table.
    • If we autocomplete against an ingredient master 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).
    • If we store ingredients as free text per recipe, there's no join — simpler, but less queryable for variety analysis.
  • Steps: sent as an ordered array of strings, or { order: number, text: string }? I'd go with positional ordering (the array index is the step order), but the database should store step_order explicitly to allow future reordering.
  • Tags: effort as an enum (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:

  • Recipe name: NOT NULL, varchar(200) with a CHECK constraint LENGTH(name) > 0. Don't allow blank names to save.
  • Serves: NOT NULL, INTEGER, CHECK (serves > 0).
  • Cook/prep time: nullable integers (steps are optional, so timing might not be known).
  • At least 1 category tag + 1 effort tag required at save time — enforce in the service layer with a domain validation, not just in the frontend. The DB constraint for "at least 1 tag" is harder to express in SQL, so service-layer enforcement is appropriate here (with a 422 response on violation).

Image upload:

  • Where is the image stored? If it's a file upload in the form, the controller needs to accept multipart/form-data. I'd recommend a two-step approach: separate POST /api/uploads endpoint that returns a URL, then the recipe form submits the URL. Keeps the recipe endpoint clean and avoids large payloads.
  • Max file size limit needs to be configured in Spring Boot (spring.servlet.multipart.max-file-size). What's the limit? 5MB? 10MB?

The "assign to day slot after saving" flow:

  • If the recipe is created from a day slot in the planner, the UI passes a ?slotDate=2026-04-07 param. 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:

  • PUT implies 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. A PATCH endpoint that supports partial updates would be more complex and isn't needed for v1.
## 🔧 Backend Engineer B3 creates or updates the core `recipe` entity. 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 ID - `PUT /api/recipes/{recipeId}` — full update → 200 or 204 - Both require planner role. Members cannot create or edit recipes. **Request body schema questions:** - Ingredients: is each ingredient sent as `{ quantity: string, ingredientName: string }` or as `{ quantity: number, unit: string, ingredientId: UUID }`? The difference matters for the autocomplete and the ingredient table. - If we autocomplete against an `ingredient` master 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). - If we store ingredients as free text per recipe, there's no join — simpler, but less queryable for variety analysis. - Steps: sent as an ordered array of strings, or `{ order: number, text: string }`? I'd go with positional ordering (the array index is the step order), but the database should store `step_order` explicitly to allow future reordering. - Tags: effort as an enum (`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:** - Recipe name: `NOT NULL`, `varchar(200)` with a CHECK constraint `LENGTH(name) > 0`. Don't allow blank names to save. - Serves: `NOT NULL`, `INTEGER`, `CHECK (serves > 0)`. - Cook/prep time: nullable integers (steps are optional, so timing might not be known). - At least 1 category tag + 1 effort tag required at save time — enforce in the service layer with a domain validation, not just in the frontend. The DB constraint for "at least 1 tag" is harder to express in SQL, so service-layer enforcement is appropriate here (with a 422 response on violation). **Image upload:** - Where is the image stored? If it's a file upload in the form, the controller needs to accept `multipart/form-data`. I'd recommend a two-step approach: separate `POST /api/uploads` endpoint that returns a URL, then the recipe form submits the URL. Keeps the recipe endpoint clean and avoids large payloads. - Max file size limit needs to be configured in Spring Boot (`spring.servlet.multipart.max-file-size`). What's the limit? 5MB? 10MB? **The "assign to day slot after saving" flow:** - If the recipe is created from a day slot in the planner, the UI passes a `?slotDate=2026-04-07` param. 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:** - `PUT` implies 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. A `PATCH` endpoint that supports partial updates would be more complex and isn't needed for v1.
Author
Owner

🧪 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:

  • Add new recipe: fill all required fields, add 2 ingredients, add 3 steps, select effort + category, save → 201, redirected to B1
  • Edit recipe: load edit page with prefilled data, change recipe name, save → 200, redirected to B1 with updated name visible
  • Save without steps: valid recipe with no steps saves successfully → 201 (steps are optional)
  • Save with image: upload an image, save → image URL in the API response

Validation — bad paths:

  • Save with no recipe name → validation error shown inline, form not submitted
  • Save with no effort tag selected → error, form blocked
  • Save with no category tag selected → error, form blocked
  • Save with effort but no category → error (both required)
  • Save with empty ingredient list → does this block save? The spec doesn't say — need a decision (I'd assume at least 1 ingredient required for a useful recipe, but the spec doesn't mandate it)
  • Ingredient row with quantity but no name → error? Or saved as-is?

Dynamic list behavior:

  • "Add ingredient" appends a new empty row — verify row count increases
  • Remove button on a row → verify that row is removed and remaining rows renumber (if numbered)
  • Remove the only ingredient row → what happens? Does the row disappear, leaving an empty list? Or is there a minimum of 1 row?
  • Same tests for steps: add step, remove step, remove only step

Autocomplete:

  • Type a partial ingredient name → dropdown appears with suggestions
  • Select suggestion via click → field populated
  • Select suggestion via keyboard (arrow + Enter) → field populated
  • Type a name with no matching suggestions → no dropdown (or "no results" state)
  • Type special characters → no crash, query is properly encoded

Two-state equivalence:

  • Edit mode: all fields pre-populated from the existing recipe — verify each field matches the loaded data
  • Edit and cancel → navigate away without saving → no data changed in backend (confirm with a GET after)

Post-save assignment (edge case):

  • Navigate to B3 from a day slot (with ?slotDate= param) → save → offer to assign → accept → verify the recipe is assigned to that date on C1

Component tests:

  • Ingredient list: add/remove rows, $state array updates correctly
  • Step list: add/remove, textarea auto-resize if implemented
  • Tag chips: effort single-select (selecting one deselects others), category multi-select
  • isValid derived state: false when effort missing, false when no category, true when both present + name filled
  • Live preview: updates in real-time as name and tags change

Integration tests:

  • POST /api/recipes with valid payload → 201
  • POST /api/recipes missing effort tag → 422 with clear error
  • PUT /api/recipes/{id} by a member → 403
  • POST /api/recipes unauthenticated → 401
  • PUT /api/recipes/{id} with a recipe from a different household → 403/404
## 🧪 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:** - Add new recipe: fill all required fields, add 2 ingredients, add 3 steps, select effort + category, save → 201, redirected to B1 - Edit recipe: load edit page with prefilled data, change recipe name, save → 200, redirected to B1 with updated name visible - Save without steps: valid recipe with no steps saves successfully → 201 (steps are optional) - Save with image: upload an image, save → image URL in the API response **Validation — bad paths:** - Save with no recipe name → validation error shown inline, form not submitted - Save with no effort tag selected → error, form blocked - Save with no category tag selected → error, form blocked - Save with effort but no category → error (both required) - Save with empty ingredient list → does this block save? The spec doesn't say — need a decision (I'd assume at least 1 ingredient required for a useful recipe, but the spec doesn't mandate it) - Ingredient row with quantity but no name → error? Or saved as-is? **Dynamic list behavior:** - "Add ingredient" appends a new empty row — verify row count increases - Remove button on a row → verify that row is removed and remaining rows renumber (if numbered) - Remove the only ingredient row → what happens? Does the row disappear, leaving an empty list? Or is there a minimum of 1 row? - Same tests for steps: add step, remove step, remove only step **Autocomplete:** - Type a partial ingredient name → dropdown appears with suggestions - Select suggestion via click → field populated - Select suggestion via keyboard (arrow + Enter) → field populated - Type a name with no matching suggestions → no dropdown (or "no results" state) - Type special characters → no crash, query is properly encoded **Two-state equivalence:** - Edit mode: all fields pre-populated from the existing recipe — verify each field matches the loaded data - Edit and cancel → navigate away without saving → no data changed in backend (confirm with a GET after) **Post-save assignment (edge case):** - Navigate to B3 from a day slot (with `?slotDate=` param) → save → offer to assign → accept → verify the recipe is assigned to that date on C1 **Component tests:** - Ingredient list: add/remove rows, `$state` array updates correctly - Step list: add/remove, textarea auto-resize if implemented - Tag chips: effort single-select (selecting one deselects others), category multi-select - `isValid` derived state: false when effort missing, false when no category, true when both present + name filled - Live preview: updates in real-time as name and tags change **Integration tests:** - `POST /api/recipes` with valid payload → 201 - `POST /api/recipes` missing effort tag → 422 with clear error - `PUT /api/recipes/{id}` by a member → 403 - `POST /api/recipes` unauthenticated → 401 - `PUT /api/recipes/{id}` with a recipe from a different household → 403/404
Author
Owner

🔒 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/recipes and PUT /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 calls POST /api/recipes directly should get 403, regardless of what the UI shows them.
  • For PUT /api/recipes/{recipeId}: the service must verify the recipe's household_id matches 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:

  • If B3 accepts image uploads, this is a significant attack surface. Concerns:
    • File type validation: validate MIME type server-side (not just the file extension — those are trivially spoofed). Only allow image/jpeg, image/png, image/webp.
    • File size limit: enforce on the server (spring.servlet.multipart.max-file-size). A missing limit allows a DoS via large upload.
    • Filename sanitization: never use the client-provided filename for storage. Generate a server-side UUID filename.
    • Storage path: if storing on disk, validate the storage path is within the designated upload directory (path traversal prevention). Prefer object storage (MinIO, S3) over local disk.
    • Content scanning: for a household app this is probably out of scope for v1, but worth noting.

Autocomplete endpoint (GET /api/ingredients?q=...):

  • This endpoint must be authenticated. An unauthenticated caller should get 401.
  • The search must be scoped to the user's household (or a global ingredient list if that's the design). It must not expose ingredient names from other households.
  • The q parameter is used in a SQL query. If it uses a LIKE clause 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:

  • B3 uses SvelteKit form actions. Confirm that CSRF protection is enabled for these actions (SvelteKit's built-in CSRF token, or Spring Security's CSRF filter on the backend API if submitting JSON directly). Planner-only actions that modify data are high-value CSRF targets.

Input validation:

  • Recipe name: max length enforced server-side (not just in the HTML maxlength attribute — 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}.
  • Ingredient quantities: if stored as strings, no injection risk. If parsed as numbers, validate they are non-negative and within a reasonable range.
  • Step text: same as recipe name — max length enforced server-side. Steps are rendered on B2 and B4; no {@html} allowed.
## 🔒 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/recipes` and `PUT /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 calls `POST /api/recipes` directly should get 403, regardless of what the UI shows them. - For `PUT /api/recipes/{recipeId}`: the service must verify the recipe's `household_id` matches 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:** - If B3 accepts image uploads, this is a significant attack surface. Concerns: - **File type validation**: validate MIME type server-side (not just the file extension — those are trivially spoofed). Only allow `image/jpeg`, `image/png`, `image/webp`. - **File size limit**: enforce on the server (`spring.servlet.multipart.max-file-size`). A missing limit allows a DoS via large upload. - **Filename sanitization**: never use the client-provided filename for storage. Generate a server-side UUID filename. - **Storage path**: if storing on disk, validate the storage path is within the designated upload directory (path traversal prevention). Prefer object storage (MinIO, S3) over local disk. - **Content scanning**: for a household app this is probably out of scope for v1, but worth noting. **Autocomplete endpoint (`GET /api/ingredients?q=...`):** - This endpoint must be authenticated. An unauthenticated caller should get 401. - The search must be scoped to the user's household (or a global ingredient list if that's the design). It must not expose ingredient names from other households. - The `q` parameter is used in a SQL query. If it uses a `LIKE` clause 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:** - B3 uses SvelteKit form actions. Confirm that CSRF protection is enabled for these actions (SvelteKit's built-in CSRF token, or Spring Security's CSRF filter on the backend API if submitting JSON directly). Planner-only actions that modify data are high-value CSRF targets. **Input validation:** - Recipe name: max length enforced server-side (not just in the HTML `maxlength` attribute — 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}`. - Ingredient quantities: if stored as strings, no injection risk. If parsed as numbers, validate they are non-negative and within a reasonable range. - Step text: same as recipe name — max length enforced server-side. Steps are rendered on B2 and B4; no `{@html}` allowed.
Author
Owner

🎨 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:

  • Left panel: --color-page bg, 24px padding. Right panel (280px): --color-surface bg. The visual separation is correct — the tags/preview panel is "secondary" to the form, so --color-surface on a slightly elevated background is appropriate.
  • The hero image upload zone (80px height) is in the left panel. 80px is very small for a drop zone — is this a compact preview slot, or a full upload affordance? On mobile a larger touch area is needed. I'd like to see the spec clarify whether 80px is the drop zone height or the thumbnail height after upload.
  • The 3-column row for serves/cook/prep time on desktop: these are number inputs with labels. Each column needs equal width. What are the labels exactly — "Serves", "Cook time (min)", "Prep time (min)"? And what's the input width? Number inputs should have a fixed width (around 80px) to prevent the 3-column layout from collapsing on narrow desktop viewports.

Tags panel — right side, desktop:

  • "Effort chips" and "Category chips" in the 280px right panel. Single-select for effort, multi-select for categories. Chips use the standard button token: 13px, weight 500, tracking 0.04em.
  • Selected state for chips: the spec doesn't define this explicitly. My expectation: selected effort/category chip = --green-tint bg + --green-dark text + --green-light border (matching the active nav state pattern). Unselected: --color-surface bg + --color-text + --color-border.
  • The category list (Chicken, Fish, Beef, Vegetarian, Pasta, etc.): how many categories are there in total? If it's more than 8–10, the right panel will be very long and the tag section will push below the fold on a standard laptop screen. Does the category list scroll, or does it wrap?

Live preview card (desktop only):

  • "Shows a preview card that updates as the user types." What does the preview card look like? Is it the same RecipeCard component used on B1 (the recipe list), or a simplified version? This is a shared component question — if B1 has a defined RecipeCard design, the live preview should reuse it exactly.
  • The preview should show: recipe name, effort chip, first category chip (or all chips?), serve count. Does it show a placeholder when the name is empty?

Mobile form UX:

  • On mobile, the form is "single scrollable column" with no live preview. The tags section (effort + category) will need to be clearly separated from the steps section — a section heading with an eyebrow label.
  • The "Add ingredient" and "Add step" links: are these full-width tappable areas (better for mobile) or small text links? I'd recommend min-height: 44px touch 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.

## 🎨 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:** - Left panel: `--color-page` bg, 24px padding. Right panel (280px): `--color-surface` bg. The visual separation is correct — the tags/preview panel is "secondary" to the form, so `--color-surface` on a slightly elevated background is appropriate. - The hero image upload zone (80px height) is in the left panel. 80px is very small for a drop zone — is this a compact preview slot, or a full upload affordance? On mobile a larger touch area is needed. I'd like to see the spec clarify whether 80px is the drop zone height or the thumbnail height after upload. - The 3-column row for serves/cook/prep time on desktop: these are number inputs with labels. Each column needs equal width. What are the labels exactly — "Serves", "Cook time (min)", "Prep time (min)"? And what's the input width? Number inputs should have a fixed width (around 80px) to prevent the 3-column layout from collapsing on narrow desktop viewports. **Tags panel — right side, desktop:** - "Effort chips" and "Category chips" in the 280px right panel. Single-select for effort, multi-select for categories. Chips use the standard button token: 13px, weight 500, tracking 0.04em. - Selected state for chips: the spec doesn't define this explicitly. My expectation: selected effort/category chip = `--green-tint` bg + `--green-dark` text + `--green-light` border (matching the active nav state pattern). Unselected: `--color-surface` bg + `--color-text` + `--color-border`. - The category list (Chicken, Fish, Beef, Vegetarian, Pasta, etc.): how many categories are there in total? If it's more than 8–10, the right panel will be very long and the tag section will push below the fold on a standard laptop screen. Does the category list scroll, or does it wrap? **Live preview card (desktop only):** - "Shows a preview card that updates as the user types." What does the preview card look like? Is it the same RecipeCard component used on B1 (the recipe list), or a simplified version? This is a shared component question — if B1 has a defined RecipeCard design, the live preview should reuse it exactly. - The preview should show: recipe name, effort chip, first category chip (or all chips?), serve count. Does it show a placeholder when the name is empty? **Mobile form UX:** - On mobile, the form is "single scrollable column" with no live preview. The tags section (effort + category) will need to be clearly separated from the steps section — a section heading with an eyebrow label. - The "Add ingredient" and "Add step" links: are these full-width tappable areas (better for mobile) or small text links? I'd recommend `min-height: 44px` touch 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.
Sign in to join this conversation.