feat(recipes): B2 — Recipe detail view with hero, ingredients, steps #37
Reference in New Issue
Block a user
Delete Branch "feat/issue-24-recipe-detail"
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
RecipeHerowith green-tint (no-image) and image+overlay+white-text variantsIngredientList— read-only ingredient rows with quantity + unit + nameStepList— numbered circles (28px, green-dark) with step instructions sorted by stepNumber+page.server.ts— fetchesGET /v1/recipes/{id}, throws 404 on error+page.svelte— mobile stacked layout, desktop 2-col ingredients|stepsCloses #24
Test plan
npm run check— 0 errors, 0 warnings🤖 Generated with Claude Code
👨💻 Kai — Frontend Engineer
Verdict: 🚫 Changes requested
Blockers
Tags never rendered in
RecipeHero.svelteThe
recipe.tagsprop is accepted and typed, but nothing in the template renders them. The spec explicitly requires tag pills in the hero ("Pills: time, effort, serves, tags" on desktop). All that data is being fetched and discarded. Add tag pills to the pills row — they should render just like effort/serves pills.IngredientList.sveltedoesn't sort bysortOrderStepListcorrectly sorts bystepNumberusing$derived.IngredientListiteratesingredientsdirectly without sorting. The API returns asortOrderfield on each ingredient — they should be sorted by it. Otherwise ingredient display order is database-insertion-order, which is unpredictable. Apply the same pattern:const sortedIngredients = $derived([...ingredients].sort(...)).No Edit link on the page
The spec requires an "Edit" button in the desktop topbar:
→ /recipes/{id}/edit. There's no<a href="/recipes/{recipe.id}/edit">anywhere on the page or in the hero. This is a missing acceptance criterion.Suggestions
Local types proliferating again
RecipeHeroPropsinRecipeHero.svelte,IngredientinIngredientList.svelte,StepinStepList.svelte, andRecipeDatain+page.svelteall define inline types. We now have$lib/recipes/types.ts— extend it withRecipeDetail,Ingredient, andSteptypes and import them. Otherwise the same divergence risk we addressed in PR #36 comes back immediately.Back link has no design system styling
text-smis a plain Tailwind class — not a design system token. Should usetext-[13px] font-sans font-medium text-[var(--color-text-muted)]or similar. Visually it's probably close enough, but it's inconsistent with how we style navigation elements.font-[400]is redundantFraunces (display font) has a default weight of 400 — specifying
font-[400]adds noise without effect. Just omit it.🎨 Atlas — UI/UX Designer
Verdict: 🚫 Changes requested
Blockers
Tag pills missing from hero
The spec says "Pills: time, effort, serves, tags" in both mobile and desktop hero. The tags prop is plumbed through to
RecipeHerobut the template renders nothing for them. Each tag should render as a pill identical in style to the effort/serves pills. Tags are the filtering metadata the user cares about most after time and effort.No Edit button in desktop topbar
The spec requires an "Edit" button in the desktop topbar area that links to B3 (
/recipes/{id}/edit). The current implementation has no such button anywhere. Style: ghost/outline treatment —border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px].Suggestions
Hero has no minimum height for image variant
When
heroImageUrlis set, the<img>isabsolute inset-0 object-cover w-full h-full— but the container has no explicit height. It's sized only by the padding and the inner content. On a recipe with a short name and few pills, the hero could render very small. Recommendmin-h-[200px] md:min-h-[240px]on the hero container.Back link styling
class="text-sm"renders as14pxDM Sans without any token — should usetext-[13px] font-sans font-medium text-[var(--color-text-muted)]. Minor but inconsistent with design system.Step circle accessibility
The numbered step circles (
data-testid="step-circle") are presentationaldivelements rendering a number. The<ol>already provides ordering semantics. Consideraria-hidden="true"on the circle div so screen readers don't announce the step number twice (once from the circle, once from the list position).🧪 QA Engineer
Verdict: ⚠️ Approved with concerns
The test structure is good — each component has its own test file, behavior is tested from the user's perspective, and the load function has proper 404 coverage. But several paths that exist in the implementation aren't covered.
Missing tests (non-blocking but should be addressed)
RecipeHero.test.ts— no tag pill testsTags are typed in the prop but never rendered (a separate blocker). Even once tags are implemented, there's no test that tag names appear as pills. Add a test with
recipe.tags = [{ id: 't1', name: 'Pasta' }]and assertscreen.getByText('Pasta')is in the document.IngredientList.test.ts— no sortOrder testIngredientList currently doesn't sort. Once sorting is added, there should be a test that passes ingredients in the wrong order and asserts the DOM order matches sortOrder ascending.
page.test.ts— no Edit link testOnce the Edit button is added, test:
screen.getByRole('link', { name: /bearbeiten/i })has href/recipes/r1/edit.page.server.test.ts— only tests 404 error pathThe load function throws
error(404, ...)for all API failures — 403, 500, and 404 alike. There's only one error test. Consider adding tests for a 403 response (different household) — the expected behavior is the same (404 thrown), which is intentional per security design, but it's worth an explicit test to document that decision.No test for recipe with image variant in page test
page.test.tsonly tests the no-image case (heroImageUrl: undefined). Add a variant with a realheroImageUrland assert the<img>is rendered.What's solid
StepListsort test with shuffled input ✅IngredientListno-remove-button assertion ✅rejects.toMatchObject✅data-testidand thebg-[var(--green-tint)]hero test ✅🔒 Sable — Security Engineer
Verdict: ✅ Approved
B2 is a read-only detail view. Checked against the threat model — nothing alarming.
What I Verified
Access control:
+page.server.tsthrowserror(404, ...)for any API error, including 403 (recipe from a different household). This is the correct behavior per the security model I outlined in the issue — returning 404 instead of 403 avoids revealing to an attacker whether the resource exists. ✅XSS: No
{@html}anywhere in the new components. Recipe name, effort, serves, cookTimeMin, ingredient names, step instructions — all rendered as Svelte text interpolations (auto-escaped). ✅Image URL:
<img src={recipe.heroImageUrl}>inRecipeHero.svelte— same analysis as PR #36. Backend-controlled value; SSRF/injection prevention is the backend's responsibility at write time. ✅Information leakage: The load function surfaces no error details to the client — just a 404 page. API status codes, error messages, and internal paths stay server-side. ✅
Multi-tenancy: Auth is centralized in
hooks.server.ts. The load function uses SvelteKit's server-sidefetchwhich carries session cookies. The backend enforces household isolation at the API level. ✅No auth checks in +page.server.ts: Correct — per project convention, role guards belong in hooks, not individual load functions. ✅
🖥️ Backend Engineer
Verdict: ✅ Approved
Frontend-only PR. Checked the server-side load function and data mapping.
What Looks Good
+page.server.tsis correctClean variable naming (
error: apiErroravoids collision with the importederrorfrom@sveltejs/kit). The 404 for all API errors is intentional and consistent with the security design. ✅Data mapping is complete and explicit
All fields from
RecipeDetailResponsethat the UI needs are mapped: id, name, serves, cookTimeMin, effort, heroImageUrl, ingredients (with ingredientId, name, quantity, unit, sortOrder), steps (with stepNumber, instruction), tags (with id, name, tagType). ✅Non-null assertions are justified
data.id!anddata.name!— these fields are marked optional in the OpenAPI schema (a common codegen quirk for required fields) but are logically required by the recipe entity. The assertion is acceptable here. ✅Observation
sortOrderis mapped but not usedThe
sortOrderfield is correctly mapped from the API response in+page.server.ts. However,IngredientList.sveltedoesn't actually sort by it (noted by Kai as a blocker). The backend data contract is correctly honored — the UI just needs to use the field.