feat(staples): A3/D3 — Pantry staples toggle UI #35
Reference in New Issue
Block a user
Delete Branch "feat/issue-20-pantry-staples"
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
aria-pressed, selected/unselected visual states, focus ring for keyboard accessibilityonToggle(id, value)up$statefor all chip selections; optimistic updates with debounced PATCH (300 ms) to/household/staples; reverts chip and shows inline error on failure; 2-col grid in onboarding context, 3-col in settings+server.ts: PATCH proxy — validatesid, callsPATCH /v1/ingredients/{id}via typed API client, returns 204 or 500+page.server.ts: load function fetches categories and ingredients in parallel, groups ingredients by category id+page.svelte: context-aware layout —?ctx=onboardingrenders ProgressSidebar (step 2 active) + mobile "Schritt 2 von 3" indicator + Continue/Skip footer; noctxrenders plain settings headinghousehold/setupredirect: updated to/household/staples?ctx=onboardinghousehold/invitestub: landing page for the Continue buttonTest plan
npm run test)npm run check)/household/stapleswithout?ctxshows plain heading, 3-col gridCloses #20
🤖 Generated with Claude Code
🧑💻 Kai — Frontend Engineer
Verdict: 🚫 Changes requested
Good TDD discipline throughout, clean component decomposition, debounce pattern is solid. But there's one production bug that will silently break the entire onboarding flow.
Blockers
1.
ctxis never passed as a prop by SvelteKit — onboarding layout will never render in production+page.sveltedeclaresctxas a$props()field:SvelteKit only passes
data(from load),form(from actions), andchildrento page components. URL query parameters are not passed as props. In productionctxwill always beundefined— the{#if isOnboarding}branch will never be entered, the ProgressSidebar will never show, and the onboarding flow is silently broken.The test works only because
@testing-library/sveltelets you inject arbitrary props directly. The mock for$app/stateis set up but the component never actually reads from it.Fix option A — handle in load function (cleaner, SSR-safe):
Then in the page:
let { data } = $props(); const isOnboarding = $derived(data.ctx === 'onboarding');Fix option B — read from
$app/statein the component:(Also update the test to not pass
ctxas a prop at all — mockpage.url.searchParams.getto return'onboarding'.)Either works; A is more testable.
2.
<svelte:head>before<script>in+page.svelteThe original stub had
<svelte:head>at the top, and the new<script>block was appended after it, leaving the non-conventional order. Svelte compiles it, but script should come first — it's the established pattern in every other page in the codebase (seehousehold/setup/+page.svelte).Suggestions
StaplesManager.svelte: Thedebouncehelper defined inline is fine for now. If it's needed elsewhere (shopping list real-time interactions come to mind), extract it to$lib/utils/debounce.ts.debouncedPatchersrecord is initialised lazily viagetPatcher(). Clean pattern, no issues.$effectre-initialisingstapleStateon everycategorieschange is intentional but means any in-flight optimistic updates get reset if the parent re-renders with new props. Low risk for v1, worth a comment.🖥️ Backend Engineer — API Proxy & Server Logic
Verdict: ⚠️ Approved with concerns
This is a frontend PR — I'm focusing exclusively on
+server.tsand+page.server.ts, since that's where backend concerns touch.Concerns
1.
isStapleis forwarded to the backend without type validation+server.ts:idis validated (returns 400 if missing), butisStaplehas no check. A caller could send{ id: 'ing-1' }with noisStaple— the schema says it's optional so the backend would accept a no-op PATCH. Worse,{ id: 'ing-1', isStaple: 'yes' }or{ id: 'ing-1', isStaple: null }would reach the backend and the TypeScript types would be coerced at runtime. Add:2. Backend error status code is swallowed — always returns 500
If the backend returns a 403 (user not authorized to patch this ingredient) or 404 (ingredient doesn't exist), the proxy collapses it to a generic 500. The client gets no actionable information and the error handling in
StaplesManagershows the same generic message regardless.At a minimum, forward the backend's status code:
LGTM
+page.server.tsload function:Promise.allfor parallel fetches is the right call. Grouping bycategory.idis clean. Non-null assertions (cat.id!) are acceptable since the schema types these as optional but the API will always return them.apiClient(fetch)pattern correctly threads SvelteKit'sfetchthrough so session cookies are forwarded to the backend. Good.🔬 QA Engineer — Test Coverage Review
Verdict: ⚠️ Approved with concerns
Strong unit test foundation — 221 tests green, good use of fake timers for debounce, optimistic revert is covered. A few coverage gaps worth addressing.
Missing Test Cases
1.
StaplesManager— no test for emptycategoriesarrayThe component renders
{#each categories as category}— if an empty array is passed, nothing renders. This is probably fine (it won't crash) but the edge case is untested. Given that the load function returns[]when the API fails (via?? []), this is reachable in prod.2.
page.server.ts— no test for API failure during loadThe load function uses
?? []as fallback when either API call fails (categoriesResult.data ?? []), but this path is completely untested. If/v1/ingredient-categoriesreturns an error, categories silently becomes empty — the user sees a blank page with no feedback.3.
+server.ts— no test for missingisStaplein bodyThe handler validates
idbut notisStaple. There's no test asserting what happens whenisStapleis absent. Given the backend engineer flagged this as a gap, there's a corresponding missing test:4.
page.test.ts— thectxbug means these tests don't verify real SvelteKit behaviourThe onboarding tests pass
ctxas a direct prop, which works in jsdom but is not how SvelteKit delivers this value in production. All 6 onboarding tests are testing a prop-passing mechanism that doesn't exist at runtime. Once thectxprop is fixed (either via load function or$app/state), the tests need updating to test the actual mechanism.Minor Issues
5. Dynamic import in
CategorySection.test.ts:37All other test files use the static top-level import (
import { userEvent } from '@testing-library/user-event'). This inconsistency is a style issue but can confuse readers about whether there's a deliberate reason for the dynamic import.LGTM
vi.useFakeTimers()+vi.advanceTimersByTime(300)+vi.runAllTimersAsync()is exactly right. Covers both "no fetch before debounce window" and "exactly one fetch after" in one clean test.aria-pressedstate used as the assertion target rather than CSS class — correct approach.🔒 Sable — Security Engineer
Verdict: ⚠️ Approved with concerns
No XSS, no CSRF gaps I can see, no secrets in code. One authorization concern that needs clarification before merge.
Concerns
1. Authorization on
PATCH /household/staples— who can flipisStapleon any ingredient?+server.tsproxies toPATCH /v1/ingredients/{id}. Ingredients appear to be global in this schema (not household-scoped —IngredientCategoryResponsehas no household ID, andsearchIngredientshas no household filter). That means:isStaplefor every household in the system, not just their own.member) can call this endpoint and mutate global data.This needs a backend confirmation: does
PATCH /v1/ingredients/{id}enforce that only users with admin or planner roles can call it? If not, this is a broken access control issue (OWASP A01).The SvelteKit
hooks.server.tsvalidates that a session exists, but that's authentication only — role-based authorization for this specific mutation needs to be enforced at the backend.Action required: Confirm backend enforces role check on
PATCH /v1/ingredients/{id}. If it doesn't, add a role guard in+server.ts:2.
isStaplenot type-checked before forwardingbody.isStapleis passed directly to the backend without validation. A crafted request withisStaple: "__proto__"orisStaple: nullwon't cause injection in this context (it's a typed API client with a JSON body), but it will produce unexpected backend behaviour. Addtypeof isStaple !== 'boolean'check (see backend review).LGTM
{@html}usage anywhere — XSS surface is zero in this PR.apiClient(fetch)threads the SvelteKitfetchso the session cookie is forwarded — the backend session validation will fire on every proxied request. ✓request.json()notrequest.text()/eval()— no injection vector there.+server.tsroutes for same-origin requests. TheContent-Type: application/jsonheader sent byStaplesManageralso bypasses the old CSRF-via-form-encoding vector. ✓🎨 Atlas — UI/UX Designer
Verdict: 🚫 Changes requested
The chip selected state colours are correct and the category eyebrow pattern matches the design system. But there are three violations of the design system spec that need fixing before this ships.
Blockers
1.
StapleChip— chip text size is 12px, spec requires 13pxDesign system constraint: "Buttons: 13px, weight 500, tracking 0.04em, font-sans — always." A chip that toggles state is functionally a toggle button — this rule applies. Fix:
text-[12px]→text-[13px]tracking-[0.04em]font-sans(currently relying on inheritance, which will break if a parent sets a display font)Corrected class string:
2.
+page.svelte— "Weiter" and "Überspringen" navigation links are completely unstyledThese are raw
<a>tags with zero Tailwind classes. They will render with browser-default link styling (blue underline, system font). This is not acceptable for any element the user interacts with in the onboarding flow.At minimum these need:
font-sans text-[13px] font-medium tracking-[0.04em](button text spec)text-[var(--green-dark)] bg-[var(--green-tint)]with padding and border-radiustext-[var(--color-text-muted)]The exact visual treatment depends on the A3 spec — if one doesn't exist yet, match the pattern used for the "Weiter" button on the household setup page (A2).
3.
+page.sveltesettings branch —<h1>has no typography stylingThis will render with browser-default h1 sizing (2em, Times New Roman on some browsers). Add the display heading pattern from the rest of the app. Based on the household setup form heading:
font-[var(--font-display)] text-[18px] md:text-[28px] font-medium text-[var(--color-text)].Suggestions
--green-tintbg,--green-lightborder,--green-darktext) matches the established active state pattern. ✓text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)]is consistent with the pattern used in other eyebrow labels across the app. ✓rounded-fullon chips is the right choice for pill-shaped ingredients. ✓focus-visible:outline-2 focus-visible:outline-offset-2focus ring is correct WCAG 2.2 pattern. ✓Review feedback addressed
All reviewer concerns resolved in 9 commits on this branch. 230 tests green, 0 type errors.
StaplesManager.test.ts7b497be?? []fallback path65f18cfisStaplenot validatedtypeof isStaple !== 'boolean'check → 400; added two new tests21b873b(error as { status? }).status ?? 500; added 404-forwarding test3581af2locals.benutzer?.rolle !== 'planer'→ 403; added test45b7e7bctxprop never set by SvelteKit;<svelte:head>before<script>url.searchParams.get('ctx')and returns it in data; page readsdata.ctx; script moved first; page + load tests updated8daaa0etext-[13px],tracking-[0.04em],font-sansadded; test added73b33ee<h1>2d6ddf0df95462🤖 Generated with Claude Code