feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #54

Merged
marcel merged 30 commits from feat/issue-52-planner-flip-tiles into master 2026-04-10 15:44:39 +02:00
Owner

Closes #52

Summary

  • Replaces the 3-panel desktop layout (sidebar + day list + right panel) with a 2-panel layout: sidebar + full-width 7-column grid
  • Tiles flip on click (CSS 3D card flip) to reveal Koch-Modus, Rezept ansehen, Gericht tauschen, and Entfernen actions
  • Recipe picker opens as a slide-in drawer (RecipePickerDrawer) instead of a right panel
  • Empty slots show a dashed-border tile with + Gericht wählen CTA and optional reasoning tags (lazy — only rendered for the focused suggestion)
  • Protein/cuisine gradient backgrounds derived from recipe tags; heroImageUrl used when available

Key decisions

  • Ingredient pills skippedSlotRecipe DTO carries no ingredient data; not needed per issue clarification
  • Reasoning tags are lazy — computed only for the focused empty tile's top suggestion (option b from clarification)
  • Tags forwarded from /v1/recipesSlotRecipe has no tags; server load now maps tags: TagItem[] from the recipes list

Commits

  • feat(planner): add CSS design tokens for protein/cuisine gradients and dimming
  • feat(planner): add reasoningTags pure helper with tests
  • feat(planner): add EmptyDayTile component with reasoning tags
  • feat(planner): add DesktopDayTile flip-card component
  • feat(planner): add RecipePickerDrawer slide-in component
  • feat(planner): wire desktop redesign — full-width grid, flip tiles, drawer
Closes #52 ## Summary - Replaces the 3-panel desktop layout (sidebar + day list + right panel) with a 2-panel layout: sidebar + full-width 7-column grid - Tiles flip on click (CSS 3D card flip) to reveal Koch-Modus, Rezept ansehen, Gericht tauschen, and Entfernen actions - Recipe picker opens as a slide-in drawer (`RecipePickerDrawer`) instead of a right panel - Empty slots show a dashed-border tile with `+ Gericht wählen` CTA and optional reasoning tags (lazy — only rendered for the focused suggestion) - Protein/cuisine gradient backgrounds derived from recipe tags; `heroImageUrl` used when available ## Key decisions - **Ingredient pills skipped** — `SlotRecipe` DTO carries no ingredient data; not needed per issue clarification - **Reasoning tags are lazy** — computed only for the focused empty tile's top suggestion (option b from clarification) - **Tags forwarded from `/v1/recipes`** — `SlotRecipe` has no tags; server load now maps `tags: TagItem[]` from the recipes list ## Commits - `feat(planner): add CSS design tokens for protein/cuisine gradients and dimming` - `feat(planner): add reasoningTags pure helper with tests` - `feat(planner): add EmptyDayTile component with reasoning tags` - `feat(planner): add DesktopDayTile flip-card component` - `feat(planner): add RecipePickerDrawer slide-in component` - `feat(planner): wire desktop redesign — full-width grid, flip tiles, drawer`
marcel added 6 commits 2026-04-10 11:21:53 +02:00
Adds --color-ring-today, --color-ring-selected, --opacity-dimmed,
9 protein gradient tokens and 5 cuisine gradient tokens as @theme
custom properties, integrating into the existing token layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Derives ReasoningTag[] from slotMap + recipe. Covers Neues Protein
(protein not yet in week) and Aufwand: leicht (cookTimeMin < 30 or
effort einfach/leicht). No component dependency — Vitest-testable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dashed-border empty slot tile with + Gericht wählen CTA and lazy
reasoning tags (Neues Protein, Aufwand: leicht) derived from
topSuggestion prop via computeReasoningTags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CSS 3D card flip with scene/card/front/back structure. Filled slots
show gradient/image front face and action back face (Koch-Modus,
tauschen, entfernen). Empty slots delegate to EmptyDayTile.
Sibling dimming and aria-expanded via activeSlotId prop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps RecipePicker in a fixed right-side drawer with backdrop.
Slide-in/out transition, backdrop click closes, purely presentational
(open + onclose props from parent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces 3-panel layout with 2-panel (sidebar + full-width grid):
- Remove persistent right panel and toolbar + Gericht hinzufügen button
- grid-cols-7 tiles use DesktopDayTile (CSS 3D card flip)
- RecipePickerDrawer slides in on tile CTA / Gericht tauschen
- Page-owned activeSlotId + drawerOpen/drawerSlotId state
- Single Escape handler: drawer > flip priority
- Extend server load to forward recipe tags from /v1/recipes API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 11:23:11 +02:00
- Add dark gradient scrim on card front so recipe name is always readable
  over images and protein/cuisine gradients
- Style card-back actions as proper buttons (border, padding, border-radius)
  instead of unstyled browser defaults
- Add meta chips for cookTimeMin and effort
- Scope Entfernen inside isPlanner guard alongside Gericht tauschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 11:24:22 +02:00
marcel added 1 commit 2026-04-10 11:31:07 +02:00
'Hähnchen'.toLowerCase() → 'hähnchen' which never matched the CSS var
--gradient-protein-haehnchen. Add toCssKey() to replace ä→ae, ö→oe,
ü→ue, ß→ss so gradient fallbacks actually resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 11:32:09 +02:00
The CSS variable key must match the actual tag name after umlaut
transliteration. 'veg' would never match a real tag named 'vegetarisch'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 11:37:56 +02:00
- Rename --gradient-protein-ei → --gradient-protein-eier (tag is 'Eier')
- Add --gradient-protein-kaese for tag 'Käse' (was missing entirely)

The only protein tags in seed data are Käse, Hülsenfrüchte, Eier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 11:55:57 +02:00
SlotRecipe from the week-plan API carries no tags, so the protein
gradient lookup in DesktopDayTile always fell through to --color-surface.
Build a recipeById lookup from data.recipes and spread tags onto each
slot's recipe when constructing slotMap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:03:07 +02:00
GET /v1/recipes was returning RecipeSummaryResponse with no tags and
only heroImagePreview. The planner frontend needs protein tags to pick
gradient backgrounds for tiles without a hero image.

- Replace JPQL constructor projection with entity query + LEFT JOIN FETCH tags
- Map Recipe entity to RecipeSummaryResponse in service (includes tags + heroImageUrl)
- Drop heroImagePreview in favour of heroImageUrl on the summary DTO

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:12:06 +02:00
Fallback chain: heroImageUrl → protein gradient → cuisine gradient → surface.
Also rename --gradient-cuisine-italienisch → --gradient-cuisine-deutsch
(actual seed tag) with an earthy warm-grey colour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:30:01 +02:00
Front face:
- Full dual gradient overlay (dark top 32% → transparent → dark bottom 55%)
- Day abbreviation + date number pill at top of each tile
- Recipe name 13px/weight-300 with text-shadow
- Meta line (cookTimeMin · effort) below name
- Glassmorphism tag pills (protein + cuisine only)
- State rings via box-shadow (yellow for today, green for selected)
- Dimming (opacity 0.42) on non-selected filled tiles

Back face:
- Koch-Modus as green primary button
- Entfernen as red outline (transparent bg)
- All buttons 11px / weight 500

EmptyDayTile: add day header + spec-aligned suggestion list layout
Page: remove external column header (now rendered inside each tile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:42:09 +02:00
- backface-visibility hides elements visually but not to pointer events;
  disable pointer events on the hidden face explicitly so the X button
  on the back face is clickable and the front face doesn't intercept clicks
- Add .scene-selected:hover rule so green ring is not overwritten by the
  higher-specificity .scene:hover box-shadow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:47:14 +02:00
overflow:hidden on direct children of preserve-3d flattens the 3D
context in Chrome, causing backface-visibility:hidden to fail.

Move border-radius + overflow to inner wrapper divs (.card-front-inner,
.card-back-inner) and keep the face elements themselves free of those
properties. Also add -webkit-backface-visibility:hidden and
will-change:transform for consistent GPU compositing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:49:31 +02:00
transform-style:preserve-3d on a parent with box-shadow/transition
causes Chrome to fail backface-visibility:hidden. Replace with
independent per-face rotateY transforms:
  front: 0deg → -180deg (flipped)
  back:  180deg → 0deg (flipped)
No preserve-3d needed — each face is its own compositing layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:50:32 +02:00
marcel added 1 commit 2026-04-10 12:52:07 +02:00
name: 15→17px, meta: 10→12px, tags: 8→10px,
day-abbr: 9→11px, day-num: 10→12px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-10 12:53:33 +02:00
name: 17→19px, meta: 12→14px, tags: 10→12px,
day-abbr: 11→13px, day-num: 12→14px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Kai — Senior Frontend Engineer

Verdict: ⚠️ Approved with concerns

Great refactor overall. The 2-panel flip-tile layout is cleaner than the previous 3-panel state machine, and the component split is well-reasoned. A few things need attention before this ships.

Blockers

1. Duplicate TagItem / SlotRecipe interface definitions across files
DesktopDayTile.svelte, EmptyDayTile.svelte, and reasoningTags.ts each declare their own local TagItem, SlotRecipe, Slot interfaces. types.ts was updated to export TagItem — but none of the new components import it. This is the classic "define once, import everywhere" failure. If the shape changes again, three files drift.

Fix: import TagItem and the slot-related interfaces from $lib/planner/types.ts in all three files.

2. gradientBackground uses raw url(...) with an unencoded user-controlled string
In DesktopDayTile.svelte:

style="background: {gradientBackground}; ..."

When heroImageUrl is present, the value becomes url(${slot.recipe.heroImageUrl}). This URL comes from the API (ultimately from the database). If it contains special CSS characters (parentheses, quotes, whitespace), the inline style is malformed and the tile breaks visually. At minimum, URI-encode or CSS-escape the URL. This is also the entry point for the security concern Sable will raise.

3. $effect for keyboard handler runs on every render cycle — missing dep guard

$effect(() => {
    function handleKeydown(e: KeyboardEvent) { ... }
    window.addEventListener('keydown', handleKeydown);
    return () => window.removeEventListener('keydown', handleKeydown);
});

This is correct Svelte 5 usage (cleanup returned) — but the effect reads drawerOpen and activeSlotId from the outer scope, so it will re-register/de-register the listener on every state change to those variables. The teardown+re-register is harmless but wasteful. Consider using a stable handleKeydown reference outside the effect, or restructure so the effect body only registers once and reads state inside the handler closure. Not a hard blocker, but it's a rough edge.

Suggestions

  • EmptyDayTile uses inline style= strings for almost all layout. The rest of the app uses Tailwind utilities. Consider extracting at least the structural layout (flex flex-col h-full etc.) to Tailwind and keeping only the CSS-variable-dependent values in inline styles. Mixing styles two ways in one file makes it harder to scan.

  • DesktopDayTile.svelte is 437 lines. The CSS block alone is ~180 lines. Consider extracting a DesktopDayTileFront.svelte and DesktopDayTileBack.svelte if this grows further. For now it's acceptable, just watch the line count.

  • The Suggestion interface inside DesktopDayTile.svelte uses recipe: any. Given types.ts exports a proper Recipe type, this should be recipe: Recipe.

  • activePickerDate in +page.svelte still checks drawerOpen && drawerSlotId twice — once in the $derived and once in handleRecipePick. A single getter function would be cleaner.

  • The drawer mounts RecipePicker only when open is true ({#if open}) — good pattern to avoid duplicate text in the DOM. But it means every open loses the scroll position. For a short list this is fine; document the tradeoff in a comment.

## 👨‍💻 Kai — Senior Frontend Engineer **Verdict: ⚠️ Approved with concerns** Great refactor overall. The 2-panel flip-tile layout is cleaner than the previous 3-panel state machine, and the component split is well-reasoned. A few things need attention before this ships. ### Blockers **1. Duplicate `TagItem` / `SlotRecipe` interface definitions across files** `DesktopDayTile.svelte`, `EmptyDayTile.svelte`, and `reasoningTags.ts` each declare their own local `TagItem`, `SlotRecipe`, `Slot` interfaces. `types.ts` was updated to export `TagItem` — but none of the new components import it. This is the classic "define once, import everywhere" failure. If the shape changes again, three files drift. Fix: import `TagItem` and the slot-related interfaces from `$lib/planner/types.ts` in all three files. **2. `gradientBackground` uses raw `url(...)` with an unencoded user-controlled string** In `DesktopDayTile.svelte`: ```svelte style="background: {gradientBackground}; ..." ``` When `heroImageUrl` is present, the value becomes `url(${slot.recipe.heroImageUrl})`. This URL comes from the API (ultimately from the database). If it contains special CSS characters (parentheses, quotes, whitespace), the inline style is malformed and the tile breaks visually. At minimum, URI-encode or CSS-escape the URL. This is also the entry point for the security concern Sable will raise. **3. `$effect` for keyboard handler runs on every render cycle — missing dep guard** ```ts $effect(() => { function handleKeydown(e: KeyboardEvent) { ... } window.addEventListener('keydown', handleKeydown); return () => window.removeEventListener('keydown', handleKeydown); }); ``` This is correct Svelte 5 usage (cleanup returned) — but the effect reads `drawerOpen` and `activeSlotId` from the outer scope, so it will re-register/de-register the listener on every state change to those variables. The teardown+re-register is harmless but wasteful. Consider using a stable `handleKeydown` reference outside the effect, or restructure so the effect body only registers once and reads state inside the handler closure. Not a hard blocker, but it's a rough edge. ### Suggestions - `EmptyDayTile` uses inline `style=` strings for almost all layout. The rest of the app uses Tailwind utilities. Consider extracting at least the structural layout (`flex flex-col h-full` etc.) to Tailwind and keeping only the CSS-variable-dependent values in inline styles. Mixing styles two ways in one file makes it harder to scan. - `DesktopDayTile.svelte` is 437 lines. The CSS block alone is ~180 lines. Consider extracting a `DesktopDayTileFront.svelte` and `DesktopDayTileBack.svelte` if this grows further. For now it's acceptable, just watch the line count. - The `Suggestion` interface inside `DesktopDayTile.svelte` uses `recipe: any`. Given `types.ts` exports a proper `Recipe` type, this should be `recipe: Recipe`. - `activePickerDate` in `+page.svelte` still checks `drawerOpen && drawerSlotId` twice — once in the `$derived` and once in `handleRecipePick`. A single getter function would be cleaner. - The drawer mounts `RecipePicker` only when `open` is true (`{#if open}`) — good pattern to avoid duplicate text in the DOM. But it means every open loses the scroll position. For a short list this is fine; document the tradeoff in a comment.
Author
Owner

🔒 Sable — Security Engineer

Verdict: ⚠️ Approved with concerns

I've checked this PR against the OWASP Top 10 for this stack. No critical auth/authz issues in the new code, but there is one injection-adjacent concern and one data-exposure issue that need attention.

Blockers

1. CSS injection via heroImageUrl in inline style binding

In DesktopDayTile.svelte, the gradientBackground derived value builds a raw CSS url() string from API data:

if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`;

This is then injected directly into an inline style= attribute:

style="background: {gradientBackground}; background-size: cover; ..."

Svelte escapes HTML attributes, but CSS injection via style= is not prevented by Svelte's escaping. A malformed or attacker-controlled heroImageUrl like );}body{display:none}/* can break out of the url() context and inject arbitrary CSS rules into the element's style attribute. Since heroImageUrl ultimately comes from user-uploaded data stored in the DB, this is a real attack surface.

Severity: Medium (CSS injection, not JS injection — but can be used for UI redressing/clickjacking on the planner page).

Fix: sanitize the URL before embedding it in CSS. At minimum:

const safeUrl = CSS.escape ? slot.recipe.heroImageUrl.replace(/['"()\\]/g, '') : encodeURI(slot.recipe.heroImageUrl);
return `url("${safeUrl}")`;

Or validate on the server that heroImageUrl is always a well-formed https:// URL before it reaches the DB.

2. tags mapping uses (t: any) in +page.server.ts

tags: (r.tags ?? []).map((t: any) => ({ id: t.id, name: t.name, tagType: t.tagType }))

This is a server-side load function. The any cast means there's no validation that t.name or t.tagType are strings (vs. something injected by a malicious API response). If the backend API is ever compromised or the response is proxied/replayed, untyped fields pass through to the frontend unvalidated. This is low severity given it's your own backend, but the any cast should be replaced with proper typing using the API response schema.

Suggestions

  • The RecipePickerDrawer backdrop div uses onclick for close but has aria-hidden="true". This is correct accessibility-wise (the interaction is duplicated on the close button), but ensure a keyboard Escape path also closes it — which is handled in the $effect. Good.

  • gradientBackground falls back to var(--color-surface) when no image and no tag match — safe default, no injection risk in that path.

  • No new API endpoints introduced, no new auth surface, no new cookies. Auth/authz scope is unchanged from the existing planner page.

  • No {@html} usage in any new component. Clean.

  • No secrets or credentials in any changed file.

## 🔒 Sable — Security Engineer **Verdict: ⚠️ Approved with concerns** I've checked this PR against the OWASP Top 10 for this stack. No critical auth/authz issues in the new code, but there is one injection-adjacent concern and one data-exposure issue that need attention. ### Blockers **1. CSS injection via `heroImageUrl` in inline style binding** In `DesktopDayTile.svelte`, the `gradientBackground` derived value builds a raw CSS `url()` string from API data: ```ts if (slot.recipe.heroImageUrl) return `url(${slot.recipe.heroImageUrl})`; ``` This is then injected directly into an inline `style=` attribute: ```svelte style="background: {gradientBackground}; background-size: cover; ..." ``` Svelte escapes HTML attributes, but **CSS injection via `style=` is not prevented by Svelte's escaping**. A malformed or attacker-controlled `heroImageUrl` like `);}body{display:none}/*` can break out of the `url()` context and inject arbitrary CSS rules into the element's style attribute. Since `heroImageUrl` ultimately comes from user-uploaded data stored in the DB, this is a real attack surface. Severity: Medium (CSS injection, not JS injection — but can be used for UI redressing/clickjacking on the planner page). Fix: sanitize the URL before embedding it in CSS. At minimum: ```ts const safeUrl = CSS.escape ? slot.recipe.heroImageUrl.replace(/['"()\\]/g, '') : encodeURI(slot.recipe.heroImageUrl); return `url("${safeUrl}")`; ``` Or validate on the server that `heroImageUrl` is always a well-formed `https://` URL before it reaches the DB. **2. `tags` mapping uses `(t: any)` in `+page.server.ts`** ```ts tags: (r.tags ?? []).map((t: any) => ({ id: t.id, name: t.name, tagType: t.tagType })) ``` This is a server-side load function. The `any` cast means there's no validation that `t.name` or `t.tagType` are strings (vs. something injected by a malicious API response). If the backend API is ever compromised or the response is proxied/replayed, untyped fields pass through to the frontend unvalidated. This is low severity given it's your own backend, but the `any` cast should be replaced with proper typing using the API response schema. ### Suggestions - The `RecipePickerDrawer` backdrop `div` uses `onclick` for close but has `aria-hidden="true"`. This is correct accessibility-wise (the interaction is duplicated on the close button), but ensure a keyboard Escape path also closes it — which is handled in the `$effect`. Good. - `gradientBackground` falls back to `var(--color-surface)` when no image and no tag match — safe default, no injection risk in that path. - No new API endpoints introduced, no new auth surface, no new cookies. Auth/authz scope is unchanged from the existing planner page. - No `{@html}` usage in any new component. Clean. - No secrets or credentials in any changed file.
Author
Owner

🎨 Atlas — UI/UX Designer

Verdict: ⚠️ Approved with concerns

The visual direction is exciting — full-height gradient tiles with CSS 3D flip is a substantial upgrade from the previous flat card-in-panel layout. I've checked the new components against the design system constraints.

Blockers

1. Hard-coded border-radius: 10px violates design token contract

The design system specifies --radius-lg: 10px as the named token for 10px radius. Both DesktopDayTile.svelte and EmptyDayTile.svelte use literal border-radius: 10px instead of var(--radius-lg). If the radius token changes during a design refresh, these tiles won't update.

Files: DesktopDayTile.svelte (.scene, .card-front-inner, .card-back-inner), EmptyDayTile.svelte (inline style on the root div).

Fix: replace all border-radius: 10px with border-radius: var(--radius-lg).

2. .scene-dimmed uses opacity: 0.42 — not the --opacity-dimmed token just added

The PR adds --opacity-dimmed: 0.38 to app.css as a design token, but .scene-dimmed in DesktopDayTile.svelte applies opacity: 0.42. These should match. The token should be used:

.scene-dimmed { opacity: var(--opacity-dimmed); }

3. .scene-today ring references var(--yellow) — raw scale token, not semantic

.scene-today uses var(--yellow) directly. Per the design system, component-level ring state should use the semantic token --color-ring-today (which the PR defines as var(--yellow-text)). Similarly .dn-today background uses var(--yellow). This should be var(--color-ring-today) to stay within the semantic layer.

Suggestions

  • tile-name font-size is 19px — not on the standard type scale. Closest is 18px or 20px. At 19px this is a one-off. Consider 18px or bump to 20px with font-weight: 300 to maintain the light feel.

  • back-name uses font-family: var(--font-display) with font-size: 13px. Display font (Fraunces) at 13px is very small for a display face — it loses its character. Consider using var(--font-sans) at that size, or bumping the display text to 15–16px where Fraunces reads well.

  • btn-action font-size is 11px on the back face. Per design system: buttons are 13px always. These are action buttons — they should be 13px.

  • back-meta font-size is 10px. Minimum readable text is 11px in the design system. Consider bumping to 11px.

  • The gradient token naming (--gradient-protein-haehnchen, --gradient-cuisine-deutsch etc.) is good. I would add a note in app.css that these are derived from German tag names via toCssKey() so future maintainers understand the coupling to the backend tag vocabulary.

  • EmptyDayTile day header uses font-size: 9px for the day abbreviation. That's below the 11px minimum recommended for body text, though for supplemental labels it can work at high resolution. Consider 10px minimum.

## 🎨 Atlas — UI/UX Designer **Verdict: ⚠️ Approved with concerns** The visual direction is exciting — full-height gradient tiles with CSS 3D flip is a substantial upgrade from the previous flat card-in-panel layout. I've checked the new components against the design system constraints. ### Blockers **1. Hard-coded `border-radius: 10px` violates design token contract** The design system specifies `--radius-lg: 10px` as the named token for 10px radius. Both `DesktopDayTile.svelte` and `EmptyDayTile.svelte` use literal `border-radius: 10px` instead of `var(--radius-lg)`. If the radius token changes during a design refresh, these tiles won't update. Files: `DesktopDayTile.svelte` (`.scene`, `.card-front-inner`, `.card-back-inner`), `EmptyDayTile.svelte` (inline style on the root div). Fix: replace all `border-radius: 10px` with `border-radius: var(--radius-lg)`. **2. `.scene-dimmed` uses `opacity: 0.42` — not the `--opacity-dimmed` token just added** The PR adds `--opacity-dimmed: 0.38` to `app.css` as a design token, but `.scene-dimmed` in `DesktopDayTile.svelte` applies `opacity: 0.42`. These should match. The token should be used: ```css .scene-dimmed { opacity: var(--opacity-dimmed); } ``` **3. `.scene-today` ring references `var(--yellow)` — raw scale token, not semantic** `.scene-today` uses `var(--yellow)` directly. Per the design system, component-level ring state should use the semantic token `--color-ring-today` (which the PR defines as `var(--yellow-text)`). Similarly `.dn-today` background uses `var(--yellow)`. This should be `var(--color-ring-today)` to stay within the semantic layer. ### Suggestions - `tile-name` font-size is `19px` — not on the standard type scale. Closest is `18px` or `20px`. At 19px this is a one-off. Consider `18px` or bump to `20px` with `font-weight: 300` to maintain the light feel. - `back-name` uses `font-family: var(--font-display)` with `font-size: 13px`. Display font (Fraunces) at 13px is very small for a display face — it loses its character. Consider using `var(--font-sans)` at that size, or bumping the display text to 15–16px where Fraunces reads well. - `btn-action` font-size is `11px` on the back face. Per design system: buttons are `13px` always. These are action buttons — they should be `13px`. - `back-meta` font-size is `10px`. Minimum readable text is `11px` in the design system. Consider bumping to `11px`. - The gradient token naming (`--gradient-protein-haehnchen`, `--gradient-cuisine-deutsch` etc.) is good. I would add a note in `app.css` that these are derived from German tag names via `toCssKey()` so future maintainers understand the coupling to the backend tag vocabulary. - `EmptyDayTile` day header uses `font-size: 9px` for the day abbreviation. That's below the 11px minimum recommended for body text, though for supplemental labels it can work at high resolution. Consider `10px` minimum.
Author
Owner

🧪 QA Engineer — Test Specialist

Verdict: Approved

This PR has solid test coverage across all new components and the pure helper. The TDD discipline is visible — tests are structured, behavior-focused, and query by role/text/testid rather than internals. Here's what I verified and what still has gaps.

What's well-covered

  • reasoningTags.ts: 100% path coverage. Happy path, boundary conditions (cookTimeMin at 29 vs. 30), no-protein case, empty slotMap, multiple tags returning together, and explicitly zero tags. Boundary tests at cookTimeMin: 29 and cookTimeMin: 30 are exactly right.

  • DesktopDayTile.test.ts: Front face state (today ring, selected ring, dimmed, not-dimmed), flip interaction (click, Enter, Space), back face actions (Koch-Modus, Rezept ansehen, close, Gericht tauschen, Entfernen), planner vs. non-planner gating, aria-expanded state. Good coverage.

  • EmptyDayTile.test.ts: CTA show/hide by role, callback firing, reasoning tag rendering, recipe name display, and the "no tags for heavy recipe" negative case.

  • RecipePickerDrawer.test.ts: Open/closed visibility, aria-hidden, backdrop click, close button, and recipe picking.

Missing coverage (suggestions, not blockers)

1. DesktopDayTile — no test for the onflip not being called when tile is already flipped
The spec intent (clicking an already-active card) isn't explicitly tested. handleFlip always calls onflip?.(slotId) — including when isFlipped is already true. Depending on the design intent, a second click should either close or be a no-op. This is currently delegated to the parent's handleTileFlip, but there's no component-level test for "clicking a flipped tile calls onflip again."

2. DesktopDayTile — no test for Entfernen being hidden when slot.id is null
The component only shows the Entfernen button when {#if slot.id}. The test fixture filledSlot always has id: 's1'. There's no test case for a slot with a recipe but without an id (i.e., optimistic slot before server assignment). Low probability but worth a negative test.

3. RecipePickerDrawer — no test for Escape key closing the drawer
The Escape key handler lives in +page.svelte, not in the drawer itself — so it's untestable at the drawer component level. But there's also no integration-level test for this keyboard flow. It's a documented UX feature that currently has zero test coverage.

4. +page.svelte integration changes — zero test coverage
The recipeById merge, slotMap enrichment with tags, handleTileFlip/Close/Swap/Remove/EmptyTileAdd handlers, and drawerReplacingMeta derived — none of these have page-level tests. This is the most complex logic change in the PR and relies entirely on the component tests cascading correctly. Suggest adding at least one integration test for the tag-enrichment flow (verify that slotMap slots have tags from data.recipes).

Minor notes

  • RecipePickerDrawer.test.ts uses { ...baseProps, onclose } on each close test — the vi.fn() on baseProps.onclose is shared across tests unless reset. Consider beforeEach(() => vi.clearAllMocks()) or inline vi.fn() per test for cleaner isolation.

  • Tests use userEvent.setup() per test — correct, not shared. Good.

## 🧪 QA Engineer — Test Specialist **Verdict: ✅ Approved** This PR has solid test coverage across all new components and the pure helper. The TDD discipline is visible — tests are structured, behavior-focused, and query by role/text/testid rather than internals. Here's what I verified and what still has gaps. ### What's well-covered - `reasoningTags.ts`: 100% path coverage. Happy path, boundary conditions (`cookTimeMin` at 29 vs. 30), no-protein case, empty slotMap, multiple tags returning together, and explicitly zero tags. Boundary tests at `cookTimeMin: 29` and `cookTimeMin: 30` are exactly right. - `DesktopDayTile.test.ts`: Front face state (today ring, selected ring, dimmed, not-dimmed), flip interaction (click, Enter, Space), back face actions (Koch-Modus, Rezept ansehen, close, Gericht tauschen, Entfernen), planner vs. non-planner gating, `aria-expanded` state. Good coverage. - `EmptyDayTile.test.ts`: CTA show/hide by role, callback firing, reasoning tag rendering, recipe name display, and the "no tags for heavy recipe" negative case. - `RecipePickerDrawer.test.ts`: Open/closed visibility, `aria-hidden`, backdrop click, close button, and recipe picking. ### Missing coverage (suggestions, not blockers) **1. `DesktopDayTile` — no test for the `onflip` not being called when tile is already flipped** The spec intent (clicking an already-active card) isn't explicitly tested. `handleFlip` always calls `onflip?.(slotId)` — including when `isFlipped` is already true. Depending on the design intent, a second click should either close or be a no-op. This is currently delegated to the parent's `handleTileFlip`, but there's no component-level test for "clicking a flipped tile calls onflip again." **2. `DesktopDayTile` — no test for `Entfernen` being hidden when `slot.id` is null** The component only shows the Entfernen button when `{#if slot.id}`. The test fixture `filledSlot` always has `id: 's1'`. There's no test case for a slot with a recipe but without an id (i.e., optimistic slot before server assignment). Low probability but worth a negative test. **3. `RecipePickerDrawer` — no test for Escape key closing the drawer** The Escape key handler lives in `+page.svelte`, not in the drawer itself — so it's untestable at the drawer component level. But there's also no integration-level test for this keyboard flow. It's a documented UX feature that currently has zero test coverage. **4. `+page.svelte` integration changes — zero test coverage** The `recipeById` merge, `slotMap` enrichment with tags, `handleTileFlip/Close/Swap/Remove/EmptyTileAdd` handlers, and `drawerReplacingMeta` derived — none of these have page-level tests. This is the most complex logic change in the PR and relies entirely on the component tests cascading correctly. Suggest adding at least one integration test for the tag-enrichment flow (verify that `slotMap` slots have tags from `data.recipes`). ### Minor notes - `RecipePickerDrawer.test.ts` uses `{ ...baseProps, onclose }` on each close test — the `vi.fn()` on `baseProps.onclose` is shared across tests unless reset. Consider `beforeEach(() => vi.clearAllMocks())` or inline `vi.fn()` per test for cleaner isolation. - Tests use `userEvent.setup()` per test — correct, not shared. Good.
Author
Owner

⚙️ Senior Backend Engineer

Verdict: Approved

The backend changes are small and focused. The shift from JPQL constructor expressions to full entity fetching with LEFT JOIN FETCH r.tags is the right approach when you need to include collection associations in the response.

What I checked

RecipeRepository — query change from DTO projection to entity fetch

The old query used a JPQL constructor expression (SELECT new RecipeSummaryResponse(...)) which can't navigate to r.tags (a collection). Switching to SELECT r FROM Recipe r LEFT JOIN FETCH r.tags correctly eager-loads the tags collection in a single query. This is the standard JPA pattern for this.

One thing to verify: if a recipe has many tags (unlikely but possible), the LEFT JOIN FETCH still returns one row per tag, and JPA deduplicates in memory. With pagination via limit/offset applied after the join, this can cause the offset to be applied to joined rows rather than entities — the classic "HHH90003004: firstResult/maxResults specified with collection fetch" problem. Check whether Hibernate logs any warnings about this in dev. The fix would be to either:

  1. Use @EntityGraph or a separate query for tags after pagination, or
  2. Use DISTINCT in the query and ensure your limit/offset are applied at the entity level.

This is worth verifying before shipping to production at scale, though for small household-scoped recipe counts it won't manifest.

RecipeService — tag mapping

r.getTags().stream()
    .map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType()))
    .toList()

Clean. No business logic in the controller (checked: controller untouched). Mapping stays in the service layer. Good.

RecipeSummaryResponseheroImagePreviewheroImageUrl

This is a field rename. If any other consumer uses heroImagePreview, it breaks. The controller test is updated, which gives confidence this is the only reference. Worth a quick search in the codebase to confirm no other test or client uses heroImagePreview.

RecipeControllerTest

Updated correctly — the test stub now includes heroImageUrl and tags in the mock response, and asserts on tags[0].name and tags[0].tagType. Good coverage of the new response shape.

No blockers

The changes are minimal, correctly layered, and tested. The one thing to watch is the pagination + JOIN FETCH behavior under load, but for this app's scale it's academic.

## ⚙️ Senior Backend Engineer **Verdict: ✅ Approved** The backend changes are small and focused. The shift from JPQL constructor expressions to full entity fetching with `LEFT JOIN FETCH r.tags` is the right approach when you need to include collection associations in the response. ### What I checked **`RecipeRepository` — query change from DTO projection to entity fetch** The old query used a JPQL constructor expression (`SELECT new RecipeSummaryResponse(...)`) which can't navigate to `r.tags` (a collection). Switching to `SELECT r FROM Recipe r LEFT JOIN FETCH r.tags` correctly eager-loads the tags collection in a single query. This is the standard JPA pattern for this. One thing to verify: if a recipe has many tags (unlikely but possible), the LEFT JOIN FETCH still returns one row per tag, and JPA deduplicates in memory. With pagination via `limit`/`offset` applied after the join, this can cause the offset to be applied to joined rows rather than entities — the classic "HHH90003004: firstResult/maxResults specified with collection fetch" problem. Check whether Hibernate logs any warnings about this in dev. The fix would be to either: 1. Use `@EntityGraph` or a separate query for tags after pagination, or 2. Use `DISTINCT` in the query and ensure your limit/offset are applied at the entity level. This is worth verifying before shipping to production at scale, though for small household-scoped recipe counts it won't manifest. **`RecipeService` — tag mapping** ```java r.getTags().stream() .map(t -> new TagResponse(t.getId(), t.getName(), t.getTagType())) .toList() ``` Clean. No business logic in the controller (checked: controller untouched). Mapping stays in the service layer. Good. **`RecipeSummaryResponse` — `heroImagePreview` → `heroImageUrl`** This is a field rename. If any other consumer uses `heroImagePreview`, it breaks. The controller test is updated, which gives confidence this is the only reference. Worth a quick search in the codebase to confirm no other test or client uses `heroImagePreview`. **`RecipeControllerTest`** Updated correctly — the test stub now includes `heroImageUrl` and tags in the mock response, and asserts on `tags[0].name` and `tags[0].tagType`. Good coverage of the new response shape. ### No blockers The changes are minimal, correctly layered, and tested. The one thing to watch is the pagination + JOIN FETCH behavior under load, but for this app's scale it's academic.
marcel added 9 commits 2026-04-10 14:19:20 +02:00
Adds shared Slot and SlotMap interfaces so DesktopDayTile,
EmptyDayTile, and reasoningTags can import rather than re-declare.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes local TagItem, SlotRecipe, Slot, Suggestion interfaces and
imports Recipe, Slot, Suggestion from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes local TagItem, SuggestionRecipe, TopSuggestion, Slot interfaces
and imports Suggestion, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes local TagItem, Recipe, SlotRecipe, Slot, SlotMap definitions
and imports Recipe, Slot, SlotMap from types.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts sanitizeForCssUrl helper that strips '"()\ before the URL
is embedded in url("..."). Prevents CSS injection via the hero image
field in inline style bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces (t: any) with (t: TagItem) so the API response shape is
validated against the shared TagItem interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- border-radius: 10px → var(--radius-lg) in both tile components
- opacity: 0.42 → var(--opacity-dimmed) in DesktopDayTile
- var(--yellow) → var(--color-ring-today) for today ring and date circle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds regression test for the {#if slot.id} guard on the remove button —
QA flagged the missing negative test case for optimistic slots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds beforeEach(vi.clearAllMocks) to prevent shared vi.fn() state in
baseProps from leaking across tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed — 7 commits

All six reviewer concerns resolved. No tasks deferred.

Kai — Duplicate interface definitions

  • types.ts now exports Slot and SlotMap (66447a7)
  • DesktopDayTile.svelte imports Recipe, Slot, Suggestion from types.ts — local copies removed (b0800ca)
  • EmptyDayTile.svelte imports Suggestion, SlotMap from types.ts — local copies removed (a8a781f)
  • reasoningTags.ts imports Recipe, Slot, SlotMap from types.ts — local copies removed (e5c361f)

Kai + Sable — CSS injection via heroImageUrl

  • Extracted sanitizeForCssUrl() pure helper that strips '"()\ before embedding in url("...") (4c87d9c)
  • 5 unit tests covering: parenthesis stripping, single/double quote stripping, backslash stripping, safe data URI preserved unchanged

Sable — (t: any) in server load tag mapping

  • Replaced (t: any) with (t: TagItem) in +page.server.ts (9423cd6)

Atlas — hardcoded values vs design tokens

  • border-radius: 10pxvar(--radius-lg) in both tile components
  • opacity: 0.42var(--opacity-dimmed)
  • var(--yellow)var(--color-ring-today) for today ring and date circle
  • All in a single commit (3f9bd2b)

QA — missing test: Entfernen hidden when slot.id is null

  • Added regression test for the {#if slot.id} guard (4835231)

QA — shared vi.fn() across tests in RecipePickerDrawer

  • Added beforeEach(() => vi.clearAllMocks()) (cfbde18)

Backend (Hibernate pagination + JOIN FETCH): Deferred — reviewer confirmed no blocker, academic for this app's recipe count scale.

Final: 698 tests pass, 0 new type errors.

## ✅ Review concerns addressed — 7 commits All six reviewer concerns resolved. No tasks deferred. ### Kai — Duplicate interface definitions - `types.ts` now exports `Slot` and `SlotMap` (`66447a7`) - `DesktopDayTile.svelte` imports `Recipe`, `Slot`, `Suggestion` from `types.ts` — local copies removed (`b0800ca`) - `EmptyDayTile.svelte` imports `Suggestion`, `SlotMap` from `types.ts` — local copies removed (`a8a781f`) - `reasoningTags.ts` imports `Recipe`, `Slot`, `SlotMap` from `types.ts` — local copies removed (`e5c361f`) ### Kai + Sable — CSS injection via `heroImageUrl` - Extracted `sanitizeForCssUrl()` pure helper that strips `'"()\` before embedding in `url("...")` (`4c87d9c`) - 5 unit tests covering: parenthesis stripping, single/double quote stripping, backslash stripping, safe data URI preserved unchanged ### Sable — `(t: any)` in server load tag mapping - Replaced `(t: any)` with `(t: TagItem)` in `+page.server.ts` (`9423cd6`) ### Atlas — hardcoded values vs design tokens - `border-radius: 10px` → `var(--radius-lg)` in both tile components - `opacity: 0.42` → `var(--opacity-dimmed)` - `var(--yellow)` → `var(--color-ring-today)` for today ring and date circle - All in a single commit (`3f9bd2b`) ### QA — missing test: Entfernen hidden when `slot.id` is null - Added regression test for the `{#if slot.id}` guard (`4835231`) ### QA — shared `vi.fn()` across tests in RecipePickerDrawer - Added `beforeEach(() => vi.clearAllMocks())` (`cfbde18`) **Backend (Hibernate pagination + JOIN FETCH)**: Deferred — reviewer confirmed no blocker, academic for this app's recipe count scale. **Final: 698 tests pass, 0 new type errors.**
marcel merged commit cfbde18435 into master 2026-04-10 15:44:39 +02:00
marcel deleted branch feat/issue-52-planner-flip-tiles 2026-04-10 15:44:41 +02:00
Sign in to join this conversation.