Frontend: A3/D3 — Pantry staples component (onboarding + settings) #20

Closed
opened 2026-04-02 11:26:44 +02:00 by marcel · 7 comments
Owner

Summary

Toggle staple ingredients by category. Ingredients marked as staples are excluded from generated shopping lists. A3 and D3 are the same component — shown during onboarding (step 2 of 3) and accessible from settings at any time.

Journey: J6 (onboarding) + J5 (shopping settings)
Role: Planner
Design rule: A3 = D3 — design and build once.

Categories

Oils & fats, Spices, Grains & pasta, Sauces, Baking, Dairy & basics.
A default list of common staples is pre-selected.

Chip Design

  • Font: 12px, padding: 6px 12px, border-radius: 20px
  • Selected: --green-tint bg, --green-light border, --green-dark text
  • Unselected: --color-surface bg, --color-border border

Layout

Mobile (< 768px)

  • Single-column categorized chip list, 20px padding

Desktop — Onboarding (A3)

  • Left: progress sidebar 300px (step 2 active)
  • Right: content area, 2-column category grid (gap: 24px row, 32px col)

Desktop — Settings (D3)

  • App sidebar (224px) + topbar + 3-column category grid (gap: 24px row, 32px col)

Behavior

  • Tap chip to toggle staple status
  • Debounced save (300ms) — UPDATE ingredient SET is_staple
  • Changes persist immediately, no explicit save button needed

Acceptance Criteria

  • Single component renders in both onboarding and settings contexts
  • Chips toggle between selected/unselected states
  • Default staples pre-selected on first load
  • Debounced persistence (300ms)
  • Mobile: single column | Desktop onboarding: 2-col | Desktop settings: 3-col
## Summary Toggle staple ingredients by category. Ingredients marked as staples are excluded from generated shopping lists. **A3 and D3 are the same component** — shown during onboarding (step 2 of 3) and accessible from settings at any time. **Journey:** J6 (onboarding) + J5 (shopping settings) **Role:** Planner **Design rule:** A3 = D3 — design and build once. ## Categories Oils & fats, Spices, Grains & pasta, Sauces, Baking, Dairy & basics. A default list of common staples is pre-selected. ## Chip Design - Font: 12px, padding: 6px 12px, border-radius: 20px - **Selected**: `--green-tint` bg, `--green-light` border, `--green-dark` text - **Unselected**: `--color-surface` bg, `--color-border` border ## Layout ### Mobile (< 768px) - Single-column categorized chip list, 20px padding ### Desktop — Onboarding (A3) - Left: progress sidebar 300px (step 2 active) - Right: content area, 2-column category grid (gap: 24px row, 32px col) ### Desktop — Settings (D3) - App sidebar (224px) + topbar + 3-column category grid (gap: 24px row, 32px col) ## Behavior - Tap chip to toggle staple status - Debounced save (300ms) — UPDATE ingredient SET `is_staple` - Changes persist immediately, no explicit save button needed ## Acceptance Criteria - [ ] Single component renders in both onboarding and settings contexts - [ ] Chips toggle between selected/unselected states - [ ] Default staples pre-selected on first load - [ ] Debounced persistence (300ms) - [ ] Mobile: single column | Desktop onboarding: 2-col | Desktop settings: 3-col
marcel added the kind/featurepriority/high labels 2026-04-02 11:30:00 +02:00
Author
Owner

Spec file: specs/frontend/j6-household-setup.html — screen A3/D3 with mobile + desktop previews (both onboarding and settings contexts), agent table, and LLM implementation guide.

**Spec file:** [`specs/frontend/j6-household-setup.html`](../specs/frontend/j6-household-setup.html) — screen A3/D3 with mobile + desktop previews (both onboarding and settings contexts), agent table, and LLM implementation guide.
Author
Owner

👨‍💻 Kai — Frontend Engineer

Questions & Observations

  • Context switching via prop: The component must know whether it's in onboarding (2-col) or settings (3-col) context. I'd expect a context: 'onboarding' | 'settings' prop that drives the grid class. Is that the intended API, or should the grid be purely CSS-driven via a container query?

  • Data flow: Where does the initial staple state come from? It must be loaded server-side in +page.server.ts (not client-side $effect) so the first render is correct with SSR. The component should receive categories (with pre-resolved is_staple flags) as a prop — no fetching inside the component itself.

  • Debounce implementation: A 300ms debounce with $effect is the natural fit, but $effect re-runs on every state change including the debounce timer itself. I'd use a plain debounce utility called from a toggle handler, not an $effect, to avoid unexpected re-trigger loops.

  • Optimistic vs. confirmed UI: The issue doesn't mention error handling. If the debounced PATCH fails, does the chip revert? Silently ignoring a failed save is a data loss risk. Worth deciding now — even a simple toast + revert is better than nothing.

  • Default staples on first load: "Default list of common staples is pre-selected" — is this a server-side concern (seeded into the DB on household creation) or does the frontend hardcode a list and mark them selected if the backend returns no data? These are very different implementations. The server-side approach is cleaner and more testable.

Suggestions

  • Keep the component thin: StaplesManager.svelte (layout + context prop) wrapping CategorySection.svelte wrapping StapleChip.svelte. Each stays short and focused.
  • The toggle handler should call updateStaple(ingredientId, newValue) which updates $state immediately (optimistic) and fires the debounced PATCH.
  • Write component tests before building: one test for "chip toggles on click", one for "correct chips marked selected on initial render", one for "debounce fires once on rapid toggling".
## 👨‍💻 Kai — Frontend Engineer ### Questions & Observations - **Context switching via prop**: The component must know whether it's in onboarding (2-col) or settings (3-col) context. I'd expect a `context: 'onboarding' | 'settings'` prop that drives the grid class. Is that the intended API, or should the grid be purely CSS-driven via a container query? - **Data flow**: Where does the initial staple state come from? It must be loaded server-side in `+page.server.ts` (not client-side `$effect`) so the first render is correct with SSR. The component should receive `categories` (with pre-resolved `is_staple` flags) as a prop — no fetching inside the component itself. - **Debounce implementation**: A 300ms debounce with `$effect` is the natural fit, but `$effect` re-runs on every state change including the debounce timer itself. I'd use a plain debounce utility called from a toggle handler, not an `$effect`, to avoid unexpected re-trigger loops. - **Optimistic vs. confirmed UI**: The issue doesn't mention error handling. If the debounced PATCH fails, does the chip revert? Silently ignoring a failed save is a data loss risk. Worth deciding now — even a simple toast + revert is better than nothing. - **Default staples on first load**: "Default list of common staples is pre-selected" — is this a server-side concern (seeded into the DB on household creation) or does the frontend hardcode a list and mark them selected if the backend returns no data? These are very different implementations. The server-side approach is cleaner and more testable. ### Suggestions - Keep the component thin: `StaplesManager.svelte` (layout + context prop) wrapping `CategorySection.svelte` wrapping `StapleChip.svelte`. Each stays short and focused. - The toggle handler should call `updateStaple(ingredientId, newValue)` which updates `$state` immediately (optimistic) and fires the debounced PATCH. - Write component tests before building: one test for "chip toggles on click", one for "correct chips marked selected on initial render", one for "debounce fires once on rapid toggling".
Author
Owner

🏗️ Backend Engineer — Spring Boot / PostgreSQL

Questions & Observations

  • Household vs. global is_staple: The spec says staples are excluded from generated shopping lists, which implies per-household staple preferences. But is is_staple a column on the ingredient table (global) or on a join table like household_ingredient (per-household)? This is the most important data model question to answer before writing any code.

  • API design for toggle: A debounced save on the frontend sending PATCH /ingredients/{id} with {"is_staple": true} is fine, but consider:

    • Does the endpoint need to verify the ingredient belongs to the requesting user's household?
    • Is there a batch endpoint (PATCH /ingredients/staples) to handle rapid toggling without hammering the DB? 300ms debounce helps, but a batch payload is safer under load.
  • Default staples seeding: "Default list of common staples is pre-selected on first load" — this should be seeded when the household is created, not derived on the frontend. A Flyway migration can insert default ingredient rows with is_staple = true. The frontend just reads what the server returns.

  • Schema consideration: If is_staple lives on a per-household table, the DDL needs a UNIQUE (household_id, ingredient_id) constraint and a proper FK. If it's on the ingredient table directly (global), a NOT NULL DEFAULT false constraint is sufficient — but then all households share the same staple list, which may not be the intent.

Suggestions

  • Define the data model boundary first: per-household or global. Document it in the endpoint spec before implementation starts.
  • The PATCH endpoint should return 204 No Content on success — no need to return the full ingredient object for a boolean toggle.
  • Add a service-layer check: ingredient.householdId == currentUser.householdId before persisting — don't rely only on the URL parameter.
## 🏗️ Backend Engineer — Spring Boot / PostgreSQL ### Questions & Observations - **Household vs. global `is_staple`**: The spec says staples are excluded from *generated shopping lists*, which implies per-household staple preferences. But is `is_staple` a column on the `ingredient` table (global) or on a join table like `household_ingredient` (per-household)? This is the most important data model question to answer before writing any code. - **API design for toggle**: A debounced save on the frontend sending `PATCH /ingredients/{id}` with `{"is_staple": true}` is fine, but consider: - Does the endpoint need to verify the ingredient belongs to the requesting user's household? - Is there a batch endpoint (`PATCH /ingredients/staples`) to handle rapid toggling without hammering the DB? 300ms debounce helps, but a batch payload is safer under load. - **Default staples seeding**: "Default list of common staples is pre-selected on first load" — this should be seeded when the household is created, not derived on the frontend. A Flyway migration can insert default ingredient rows with `is_staple = true`. The frontend just reads what the server returns. - **Schema consideration**: If `is_staple` lives on a per-household table, the DDL needs a `UNIQUE (household_id, ingredient_id)` constraint and a proper FK. If it's on the `ingredient` table directly (global), a `NOT NULL DEFAULT false` constraint is sufficient — but then all households share the same staple list, which may not be the intent. ### Suggestions - Define the data model boundary first: per-household or global. Document it in the endpoint spec before implementation starts. - The PATCH endpoint should return `204 No Content` on success — no need to return the full ingredient object for a boolean toggle. - Add a service-layer check: `ingredient.householdId == currentUser.householdId` before persisting — don't rely only on the URL parameter.
Author
Owner

🧪 QA Engineer — Test Coverage Review

Questions & Observations

  • Missing error state in acceptance criteria: The ACs cover happy paths well (toggle, default selection, debounce, layouts) but there's no AC for what happens when the PATCH fails. If the save fails silently, the user thinks their staple is saved but it isn't — that's a data loss bug with no test to catch it.

  • "Default staples pre-selected on first load" — first load of what? First time the household is created? Every page load? What happens if new ingredients are added to a category after initial setup — are they staples by default? This boundary needs defining before I can write a test for it.

  • Debounce edge cases to cover:

    • Rapid toggling same chip 5x within 300ms — final state should match last toggle, exactly one API call
    • Toggle chip, navigate away before debounce fires — does the save still happen?
    • Two chips toggled in quick succession — are both saves sent?
  • Layout testing: The 3-column (settings) vs 2-column (onboarding) layout difference is a behaviour difference driven by a prop. This needs a component test, not just a visual check.

Test Plan I'd Write

Layer Test What it catches
Component Chip toggles on click, aria-pressed updates Regression on toggle interaction
Component Initial render shows correct pre-selected chips Default staples regression
Component Rapid clicks → single debounced call Debounce correctness
Component Failed PATCH → chip reverts (once error handling is defined) Silent data loss
Integration PATCH /ingredients/{id} persists is_staple DB write correctness
Integration Unauthenticated PATCH → 401 Auth enforcement
E2E Onboarding step 2: toggle staple, advance, re-enter settings, verify state Full J6 flow

Suggestion

  • Add one AC: "If the save fails, the chip reverts to its previous state and an error is shown." Without this, there's no definition of correct failure behaviour to test against.
## 🧪 QA Engineer — Test Coverage Review ### Questions & Observations - **Missing error state in acceptance criteria**: The ACs cover happy paths well (toggle, default selection, debounce, layouts) but there's no AC for what happens when the PATCH fails. If the save fails silently, the user thinks their staple is saved but it isn't — that's a data loss bug with no test to catch it. - **"Default staples pre-selected on first load"** — first load of what? First time the household is created? Every page load? What happens if new ingredients are added to a category after initial setup — are they staples by default? This boundary needs defining before I can write a test for it. - **Debounce edge cases to cover**: - Rapid toggling same chip 5x within 300ms — final state should match last toggle, exactly one API call - Toggle chip, navigate away before debounce fires — does the save still happen? - Two chips toggled in quick succession — are both saves sent? - **Layout testing**: The 3-column (settings) vs 2-column (onboarding) layout difference is a behaviour difference driven by a prop. This needs a component test, not just a visual check. ### Test Plan I'd Write | Layer | Test | What it catches | |---|---|---| | Component | Chip toggles on click, aria-pressed updates | Regression on toggle interaction | | Component | Initial render shows correct pre-selected chips | Default staples regression | | Component | Rapid clicks → single debounced call | Debounce correctness | | Component | Failed PATCH → chip reverts (once error handling is defined) | Silent data loss | | Integration | PATCH `/ingredients/{id}` persists `is_staple` | DB write correctness | | Integration | Unauthenticated PATCH → 401 | Auth enforcement | | E2E | Onboarding step 2: toggle staple, advance, re-enter settings, verify state | Full J6 flow | ### Suggestion - Add one AC: "If the save fails, the chip reverts to its previous state and an error is shown." Without this, there's no definition of correct failure behaviour to test against.
Author
Owner

🔒 Sable — Security Engineer

Questions & Observations

  • IDOR risk on the toggle endpoint: The debounced PATCH /ingredients/{id} must verify that ingredient.householdId == session.householdId. If the backend only checks "is the user authenticated?" but not "does this ingredient belong to this user's household?", a planner can toggle staples for another household by enumerating ingredient IDs. This is the most critical control to verify.

  • Role enforcement: The issue says Role: Planner. The backend must reject PATCH /ingredients/{id}/staple with 403 if the session user is a member. Members should not be able to modify staple settings. Confirm this is enforced at the service layer, not just the frontend.

  • Ingredient names and XSS: Category names and ingredient names rendered as chip labels — are these static/seeded data, or can a planner create custom ingredients with arbitrary names? If user-created ingredient names are rendered inside chips, they need HTML escaping. Svelte auto-escapes text nodes, so {ingredient.name} is safe, but {@html ingredient.name} would not be. Flag any {@html} usage in the component during review.

  • Debounce and rate limiting: 300ms debounce on the frontend reduces API calls, but doesn't prevent a malicious client from skipping the debounce entirely and flooding PATCH calls. Consider rate limiting on the toggle endpoint (e.g., Spring's HandlerInterceptor or a bucket4j limiter) — especially since the endpoint writes to the DB on every call.

  • Information leakage in error responses: If the ingredient ID doesn't exist or belongs to another household, the backend should return 404 in both cases (not 403). Returning 403 confirms the resource exists, enabling enumeration.

No Concerns

  • No {@html} risk from the chip design itself (static categories, toggle state is a boolean).
  • Cookie/session handling is out of scope for this component — covered by existing auth infrastructure.
## 🔒 Sable — Security Engineer ### Questions & Observations - **IDOR risk on the toggle endpoint**: The debounced `PATCH /ingredients/{id}` must verify that `ingredient.householdId == session.householdId`. If the backend only checks "is the user authenticated?" but not "does this ingredient belong to this user's household?", a planner can toggle staples for another household by enumerating ingredient IDs. This is the most critical control to verify. - **Role enforcement**: The issue says Role: Planner. The backend must reject `PATCH /ingredients/{id}/staple` with `403` if the session user is a `member`. Members should not be able to modify staple settings. Confirm this is enforced at the service layer, not just the frontend. - **Ingredient names and XSS**: Category names and ingredient names rendered as chip labels — are these static/seeded data, or can a planner create custom ingredients with arbitrary names? If user-created ingredient names are rendered inside chips, they need HTML escaping. Svelte auto-escapes text nodes, so `{ingredient.name}` is safe, but `{@html ingredient.name}` would not be. Flag any `{@html}` usage in the component during review. - **Debounce and rate limiting**: 300ms debounce on the frontend reduces API calls, but doesn't prevent a malicious client from skipping the debounce entirely and flooding `PATCH` calls. Consider rate limiting on the toggle endpoint (e.g., Spring's `HandlerInterceptor` or a bucket4j limiter) — especially since the endpoint writes to the DB on every call. - **Information leakage in error responses**: If the ingredient ID doesn't exist or belongs to another household, the backend should return `404` in both cases (not `403`). Returning `403` confirms the resource exists, enabling enumeration. ### No Concerns - No `{@html}` risk from the chip design itself (static categories, toggle state is a boolean). - Cookie/session handling is out of scope for this component — covered by existing auth infrastructure.
Author
Owner

🎨 Atlas — UI/UX Designer

Questions & Observations

  • Border-radius 20px is off-system: The design system goes --radius-xl (16px) → --radius-full (9999px). 20px sits between them with no token. For a pill-style chip, --radius-full is the semantically correct choice (and avoids a magic number). Was 20px intentional, or should this be --radius-full?

  • Unselected chip text color not specified: The spec defines selected state fully (--green-tint bg, --green-light border, --green-dark text) but unselected only specifies bg (--color-surface) and border (--color-border). What's the text color? --color-text? --color-text-muted? This needs to be explicit to ensure 4.5:1 contrast on --color-surface.

  • Focus and hover states missing: WCAG 2.2 AA requires visible focus indicators. Keyboard users navigating through 30+ chips need a clear :focus-visible ring. Hover state for pointer users should also be specified — a subtle bg shift on --color-surface-hover is consistent with other interactive elements in the system.

  • Category heading typography: The issue lists category names (Oils & fats, Spices, etc.) but doesn't specify their typographic treatment. Are these text-xs uppercase tracking-wide --color-text-muted like section labels elsewhere, or text-sm font-medium? Consistent with the rest of the settings page is important.

  • Empty category state: What renders if a category has no ingredients? A hidden section? A "No items" placeholder? Worth defining so the component handles it gracefully rather than showing an empty heading.

  • Column count as layout context, not content: The 2-col / 3-col difference between onboarding and settings is a layout decision, not a content decision. This is well-handled by a context prop — just confirm the chip grid uses gap: 24px (row) consistently across both layouts as specified.

Suggestions

  • Specify --radius-full explicitly in the spec to close the token gap.
  • Add a focus ring spec: outline: 2px solid --green-light; outline-offset: 2px is consistent with the green interaction palette.
  • Consider a subtle selected count badge per category (e.g. "4 selected") — low implementation cost, high spatial awareness value for users managing 30+ chips.
## 🎨 Atlas — UI/UX Designer ### Questions & Observations - **Border-radius 20px is off-system**: The design system goes `--radius-xl` (16px) → `--radius-full` (9999px). 20px sits between them with no token. For a pill-style chip, `--radius-full` is the semantically correct choice (and avoids a magic number). Was 20px intentional, or should this be `--radius-full`? - **Unselected chip text color not specified**: The spec defines selected state fully (`--green-tint` bg, `--green-light` border, `--green-dark` text) but unselected only specifies bg (`--color-surface`) and border (`--color-border`). What's the text color? `--color-text`? `--color-text-muted`? This needs to be explicit to ensure 4.5:1 contrast on `--color-surface`. - **Focus and hover states missing**: WCAG 2.2 AA requires visible focus indicators. Keyboard users navigating through 30+ chips need a clear `:focus-visible` ring. Hover state for pointer users should also be specified — a subtle bg shift on `--color-surface-hover` is consistent with other interactive elements in the system. - **Category heading typography**: The issue lists category names (Oils & fats, Spices, etc.) but doesn't specify their typographic treatment. Are these `text-xs uppercase tracking-wide --color-text-muted` like section labels elsewhere, or `text-sm font-medium`? Consistent with the rest of the settings page is important. - **Empty category state**: What renders if a category has no ingredients? A hidden section? A "No items" placeholder? Worth defining so the component handles it gracefully rather than showing an empty heading. - **Column count as layout context, not content**: The 2-col / 3-col difference between onboarding and settings is a layout decision, not a content decision. This is well-handled by a context prop — just confirm the chip grid uses `gap: 24px` (row) consistently across both layouts as specified. ### Suggestions - Specify `--radius-full` explicitly in the spec to close the token gap. - Add a focus ring spec: `outline: 2px solid --green-light; outline-offset: 2px` is consistent with the green interaction palette. - Consider a subtle selected count badge per category (e.g. "4 selected") — low implementation cost, high spatial awareness value for users managing 30+ chips.
Author
Owner

Implementation complete — PR #35

All 19 tasks implemented on branch feat/issue-20-pantry-staples.

What was built

Commit Description
StapleChip.svelte + tests Toggle button with aria-pressed, focus ring, selected/unselected styles
CategorySection.svelte + tests Eyebrow heading + chip row, bubbles onToggle(id, value)
StaplesManager.svelte + tests Optimistic state, debounced PATCH (300 ms), error revert, context-aware grid
+server.ts + tests PATCH proxy to PATCH /v1/ingredients/{id}, validates id, 204/400/500
+page.server.ts + tests Parallel fetch of categories + ingredients, grouped by category id
+page.svelte + tests Onboarding layout (ProgressSidebar step 2, "Schritt 2 von 3", Weiter/Überspringen) and settings layout (plain heading, 3-col grid)
household/setup redirect Updated to /household/staples?ctx=onboarding
household/invite stub Continue button target

Test results

221 unit tests — all green. 0 type errors.

🤖 Generated with Claude Code

## Implementation complete — PR #35 All 19 tasks implemented on branch `feat/issue-20-pantry-staples`. ### What was built | Commit | Description | |--------|-------------| | `StapleChip.svelte` + tests | Toggle button with `aria-pressed`, focus ring, selected/unselected styles | | `CategorySection.svelte` + tests | Eyebrow heading + chip row, bubbles `onToggle(id, value)` | | `StaplesManager.svelte` + tests | Optimistic state, debounced PATCH (300 ms), error revert, context-aware grid | | `+server.ts` + tests | PATCH proxy to `PATCH /v1/ingredients/{id}`, validates id, 204/400/500 | | `+page.server.ts` + tests | Parallel fetch of categories + ingredients, grouped by category id | | `+page.svelte` + tests | Onboarding layout (ProgressSidebar step 2, "Schritt 2 von 3", Weiter/Überspringen) and settings layout (plain heading, 3-col grid) | | `household/setup` redirect | Updated to `/household/staples?ctx=onboarding` | | `household/invite` stub | Continue button target | ### Test results 221 unit tests — all green. 0 type errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.