feat(onboarding): A2 — Household setup page #34
Reference in New Issue
Block a user
Delete Branch "feat/issue-19-household-setup"
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
Implements issue #19 — screen A2 of the J6 household setup onboarding flow.
ProgressSidebarshared component (3 steps, active/completed/future states, aria-labels, reusable for A3+)HouseholdSetupFormcomponent with disabled-until-valid Continue button and server error display+page.server.tsload guard (redirects to/plannerif user already has a household) + POST action callingPOST /v1/households→ redirect303 /household/staples+page.svelte— desktop: 300px progress sidebar + flex form area (max-width 420px); mobile: "Schritt 1 von 3" eyebrow + form/household/staplespage as redirect target for A3Deferred: Invite link generation — per team resolution, not A2's concern (subsequent page).
Test plan
npm run test)npm run check)/household/setup/household/staples/planner🤖 Generated with Claude Code
🧑💻 Kai — Frontend Engineer
Verdict: ⚠️ Approved with concerns
Good TDD discipline, clean Svelte 5 runes usage throughout. A few things I want addressed before merge.
Blockers
1.
aria-hidden="true"on<aside>is a test workaround masquerading as production code+page.svelte:21— The sidebar is markedaria-hidden="true"to prevent duplicate text matches in JSDOM because both the sidebar and the form say "Haushalt benennen". This is the wrong fix. The<aside>is semantically meaningful content (progress indicator with accessible step labels) — hiding it from screen readers silently removes functionality.The correct fix is to update the test query to be more specific:
Remove
aria-hidden="true"from the aside, and remove thedefaultIgnorechange fromtest-setup.ts— that's a global side effect that will affect all future tests.2. Wrong Tailwind syntax for font-family in
HouseholdSetupForm.svelteLine 44:
font-['var(--font-display)']— the quotes around the CSS var are incorrect. Tailwind doesn't need quotes here. It should befont-[var(--font-display)](same as how it's done correctly inProgressSidebar.svelteat line 44:font-[var(--font-display)]). With the current syntax the font will not apply.Suggestions
3. Load guard belongs in hooks, not
+page.server.tsMy own rule: auth and session checks live in
hooks.server.ts. The "already has household" guard is a routing concern, not page-local business logic. Consider ahasHouseholdcheck in the hook that redirects onboarding users who have already completed setup. That said, this is navigable UX logic so I'll let it slide for now if the team disagrees — but I want the conversation logged.4.
circleClass()andlabelClass()inProgressSidebar.sveltereferencecurrentStepfrom outer scope inside plain functionsThis works because Svelte 5 tracks fine-grained reactivity through template expressions, but it's subtle.
$derivedwould make the intent explicit and surface any future reactivity issues earlier:Minor — not blocking.
5.
HouseholdSetupForm.svelteheading font sizetext-[24px]on line 44, but the spec shows28pxfor the desktop form heading. On mobile24pxis fine but on desktop this should scale up. The existing auth forms use responsivemd:text-[28px]— same pattern should apply here.🎨 Atlas — UI/UX Designer
Verdict: ⚠️ Approved with concerns
Layout structure and token usage are mostly right. Two things need fixing before this ships visually.
Blockers
1.
aria-hidden="true"on the progress sidebar — accessibility violation+page.svelte:21— The ProgressSidebar has properly labelled steps (aria-label="Schritt 1",aria-current="step") that I specifically designed for screen reader users. Wrapping the entire<aside>inaria-hiddenthrows all of that away. Users on VoiceOver or NVDA will land on the form with no indication of where they are in the onboarding flow. This is a WCAG 2.2 AA failure (Success Criterion 1.3.1 — Info and Relationships). The sidebar must be accessible.Fix the test query instead —
getByRole('heading', { name: ... })is unambiguous.2. Wrong font-family syntax — heading will render in system font
HouseholdSetupForm.svelte:44—font-['var(--font-display)']has extraneous quotes. Tailwind's arbitrary-value syntax doesn't quote CSS variable references. The correct class isfont-[var(--font-display)]. Without this fix theh1renders in DM Sans (body font) instead of Fraunces, which breaks the visual hierarchy on this screen.Suggestions
3. Heading font size doesn't scale for desktop
HouseholdSetupForm.svelte:44—text-[24px]is flat across all breakpoints. The spec shows28pxfor the desktop form heading. The auth forms (SignupForm.svelte) already use the correct responsive pattern:text-[18px] md:text-[28px]. Apply the same here.4. Heading font-weight should be 500, not 600
font-semibold= 600, which is my stated ceiling. For display headings in Fraunces the spec usesfont-weight: 500(font-medium). 600 is technically within the limit but it looks heavier than intended — Fraunces at 600 has noticeable slab-serif boldness that doesn't fit the calm onboarding tone. Change tofont-medium.5. Form subtitle text is missing responsive sizing
HouseholdSetupForm.svelte:46— The subtitle usestext-[14px]flat, which is fine for desktop but tight on mobile. The auth forms usetext-[12px] md:text-[14px]. Low priority but worth aligning for consistency.6. ProgressSidebar subtitle text for step 1 is a bit literal
ProgressSidebar.svelte:7— "Deiner Familie einen Namen geben" reads slightly awkward as a subtitle. The spec shows no subtitle text beyond the step label itself for future steps. Suggest either trimming the subtitles or aligning them with the spec copy: just "Smith family" (the household name) for completed steps, and nothing for future steps. Not blocking — leave as-is if the copy has been signed off.🔒 Sable — Security Engineer
Verdict: ✅ Approved
Clean implementation for an authenticated onboarding screen. No significant vulnerabilities. Notes below.
Observations
Auth gate is solid
/household/setupis not inPUBLIC_ROUTESinhooks.server.ts, sohooks.server.tsenforces session validity and calls/v1/auth/mebefore this page is ever reached. Unauthenticated users are redirected to/loginbefore+page.server.tsload or action ever runs. ✅CSRF coverage
The form uses
use:enhance, which relies on SvelteKit's built-in Origin header check for CSRF protection on form actions. No custom CSRF token needed. ✅No XSS vectors
formError,error, and all user-supplied values are rendered via Svelte template interpolation ({value}), which auto-escapes. No{@html}usage anywhere in this PR. ✅Load guard correctness
+page.server.ts:6—locals.haushalt?.iduses optional chaining, so it handles the edge case wherehaushaltis undefined (e.g., if the hooks call somehow set no household). Redirects to/plannerfor users who already completed setup. ✅Suggestions (non-blocking)
1.
fail(500, ...)leaks internal status to the client+page.server.ts:26— Returningfail(500, ...)sends HTTP 500 back to the browser on a backend error. The error message is generic ("Haushalt konnte nicht erstellt werden...") which is fine, but the 500 status code itself can be fingerprinted by automated scanners. Considerfail(503, ...)(Service Unavailable) orfail(400, ...)with a generic user-facing message — both are less informative to an attacker about whether the backend is down vs. rejecting the request. Low severity.2. No household name length validation on the server action
+page.server.ts:15-17— The action only checks that the name is non-empty. If the backend enforces a max length (e.g., 100 chars), a user could submit a 10,000-character name and the action would forward it to the API. Add aname.length > 100guard (or whatever the backend constraint is) to fail fast before the API call and return a clear error. Defense in depth.3. Invite token scope reminder (out of scope for this PR)
The load guard prevents users with an existing household from reaching A2 again, which closes off the "create a second household by replaying the request" vector. When invite generation is implemented, ensure the backend enforces
UNIQUE(user_account_id)onhousehold_memberas a backstop. Noted for the A3/invite issue.🧪 QA Engineer — Test Coverage
Verdict: ⚠️ Approved with concerns
Good unit coverage on all new components and the server action. One structural issue with the test setup, and a few gaps worth closing.
Blockers
1. Global
defaultIgnorechange intest-setup.tsis a fragile workaroundThe change to exclude
[aria-hidden="true"] *from text queries was added to solve a duplicate-text problem caused by the sidebar and form sharing the heading "Haushalt benennen". This is a leaky fix — it modifies query behavior globally for all 183 tests going forward, and it papers over a semantic problem in the production code (aria-hiddenon meaningful content).The correct fix is to remove
aria-hiddenfrom the<aside>, drop thedefaultIgnorechange, and update the test query inpage.test.tsto be specific:The
ProgressSidebar.test.tstests are unaffected since that component is tested standalone.Suggestions
2. Missing E2E test for the A1→A2 critical path
The comment thread on issue #19 called out: "E2E — full onboarding flow: Signup (A1) → household setup (A2) → A3 as a single journey test. This is a critical path." No E2E test was added. I understand A3 is a stub, so a full journey isn't possible yet — but a
signup → lands on /household/setupE2E test is testable today. Suggest creating ae2e/onboarding.spec.tswith at least that smoke test before merging.3. No test for maximum name length
page.server.test.ts— The action has no max-length guard (noted by Sable too). There's no test asserting that an excessively long name is rejected. Once the max-length validation is added, the test should cover the boundary:name.length === maxLengthpasses,name.length === maxLength + 1fails.4.
page.test.ts— thegetByTestId('step-1')test works only because ofaria-hiddenworkaroundpage.test.ts:28—getByTestIddoesn't respectdefaultIgnore, so it finds the sidebar step regardless ofaria-hidden. But if thearia-hiddenworkaround is removed (as it should be), thegetByText('Haushalt benennen')query in the page test will start failing again. Fixing the query togetByRole('heading', ...)is necessary alongside removing the workaround.5. No test for
ProgressSidebarwithcurrentStep=3(all steps completed)ProgressSidebar.test.ts— Tests covercurrentStep=1andcurrentStep=2but notcurrentStep=3. When step 3 is active, steps 1 and 2 should bedata-state="completed". Worth a quick test to confirm then < currentSteplogic handles the final step correctly.6. The "shows validation error when submitting with empty name" test comment is misleading
HouseholdSetupForm.test.ts:38— The comment says "override disabled to allow submit attempt by typing then clearing" but the test never actually clicks a disabled button (disabled buttons don't fire click events). What actually happens is: typing setstouched=true, clearing the field triggers the$derivederror. The error appears before the button is clicked. The test works correctly — but the comment should explain the actual mechanism to avoid confusion for future maintainers.🗄️ Backend Engineer
Verdict: ✅ Approved
This PR is purely frontend. The backend API (
POST /v1/households,POST /v1/households/mine/invites) was already implemented. Reviewing the frontend's usage of those endpoints.All clear
API call is correct
+page.server.ts:22-24—api.POST('/v1/households', { body: { name } })matches theCreateHouseholdRequestschema exactly ({ name: string }). The response is typed via the generated schema. ✅apiClient(fetch)usage is correctSvelteKit's
fetchis forwarded so session cookies are included in the backend request. Without this, thePOST /v1/householdsendpoint would return 401. ✅Error check is correct
+page.server.ts:26— Checking botherrorand!data?.databefore proceeding. The API client returns{ data, error }and either can be falsy without the other — checking both is the right pattern. ✅Suggestions (non-blocking)
1. No max-length validation before the API call
The frontend action validates that the name is non-empty but not that it's within a reasonable length. If the backend enforces a
VARCHAR(100)constraint (or similar), a 10,000-character submission will get a backend error, which shows the generic "couldn't create" message instead of a helpful "name too long" message. Add aname.length > 100guard with a German-language validation message to match the backend constraint and give the user actionable feedback.2. The
responsekey frommockSuccess()in tests is unusedpage.server.test.ts:68—mockSuccess()includes aresponseobject with aheaders.getmock, matching the signup action pattern. But the household setup action doesn't readresponse.headers(no session cookie to extract — the user is already authenticated). The mock is harmless, just unnecessary noise. Minor.✅ Review concerns addressed — 7 commits
All blockers and suggestions from the 5-persona review have been resolved.
Blockers fixed
aria-hidden="true"on progress sidebar (Kai, Atlas, QA)e5614ccaria-hidden, revertedtest-setup.tsdefaultIgnore, updatedpage.test.tsto usegetByRole('heading', ...)font-['var(--font-display)'](Kai, Atlas)6652548font-[var(--font-display)]— Fraunces now applies correctlySuggestions implemented
36dfea3text-[18px] md:text-[28px],font-semibold→font-medium3742364text-[12px] md:text-[14px]2d16044name.length > 100guard, 2 new tests (boundary + over-limit)currentStep=3test for ProgressSidebar (QA)01a321cdata-state="completed"responsemock (QA, Backend)7c66dcaresponsekey removed frommockSuccess()Stats