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

Closed
opened 2026-04-09 18:20:48 +02:00 by marcel · 8 comments
Owner

Übersicht

Der Wochenplaner hat auf Desktop aktuell ~80 % vertikalen Leerraum unterhalb des 7-Spalten-Kalenders, und das rechte Panel wird im Idle-Zustand nicht sinnvoll genutzt. Dieses Issue beschreibt ein vollständiges Redesign der Desktop-Hauptfläche.

Spec: specs/planner-redesign-flip-tiles.html (auf master)
Interaktiver Mockup: specs/planner-flip-tiles.html


Kern-Entscheidungen

  • Kacheln füllen die volle Höhe und Breite — 7-Spalten-Grid (repeat(7,1fr)) mit height: 100%, kein Leerraum darunter
  • CSS 3D Card Flip ersetzt das Expansion-Panel — Klick auf eine gefüllte Kachel dreht sie um, Rückseite zeigt Zutaten + Aktionen
  • Kein persistentes rechtes Panel — entfällt vollständig, Koch-Modus ist auf der Kachel-Rückseite
  • Inline-Vorschläge auf leeren Kacheln — Reasoning-Tags statt numerischer Score-Deltas
  • Kein „Gericht hinzufügen"-Button in der Toolbar — jede leere Kachel hat eigene CTA
  • Rezept-Picker als Slide-in Drawer — öffnet sich nur auf Anfrage (nicht persistent)

Layout-Änderungen

Vorher: sidebar (184px) | main (flex:1) | right panel (228px)
Nachher: sidebar (184px) | main (flex:1)

Die linke Sidebar mit dem Variety-Score bleibt unverändert.


Kachel-Zustände

Zustand Darstellung
Standard (gefüllt) Vollbild-Farbgradient oder heroImageUrl, Dual-Overlay, Name + Meta unten
Heute Wie Standard + gelber Ring via box-shadow: 0 0 0 2px var(--yellow)
Geflippt / Ausgewählt Grüner Ring, Karte dreht 180°, Geschwister auf 38% gedimmt
Leer Dashed Border, + CTA oben, Inline-Vorschläge darunter

Statusringe via box-shadow (kein border) → kein Layout-Shift.


Farb-Palette (Fallback wenn kein heroImageUrl)

Priorität: heroImageUrl → Protein-Tag → Cuisine-Tag → neutrales --color-surface

Protein: protein-haehnchen (amber), protein-rind (rot), protein-fisch (blau), protein-tofu (grün), protein-veg (hellgrün), protein-schwein (lachs), protein-lamm (braun), protein-ei (gold), protein-huelsenfruechte (erde)

Küche: cuisine-italienisch (tomatenrot), cuisine-asiatisch (dunkelgrün), cuisine-indisch (ocker), cuisine-mexikanisch (orange), cuisine-mediterran (blau)

Alle Farbwerte als CSS-Klassen in src/app.css.


Flip-Mechanik

.scene  { perspective: 900px }
  .card { transform-style: preserve-3d; transition: transform .45s cubic-bezier(.4,0,.2,1) }
  .card.flipped { transform: rotateY(180deg) }
    .card-front { backface-visibility: hidden }
    .card-back  { backface-visibility: hidden; transform: rotateY(180deg) }

Rückseite enthält:

  1. 5px Farbstreifen (identischer Gradient wie Vorderseite)
  2. Tag + Datum / × Schließen
  3. Rezeptname (Fraunces 15px)
  4. Meta (Kochzeit · Aufwand · Portionen)
  5. Zutaten-Pills (normale + Staple-gedimmt)
  6. Aktionen: Koch-Modus (primary), Rezept ansehen, Gericht tauschen, Entfernen (danger)

Kein API-Aufruf beim Flip — alle Daten sind im vorhandenen slotMap-State.


Leere Kacheln — Inline Suggestions

Reasoning-Tags statt Score-Deltas (die für leere Slots immer positiv sind):

Tag Farbe Bedingung
Neues Protein Grün Proteinquelle diese Woche noch nicht vorhanden
Kein Overlap Grün Keine Zutaten-Überschneidung
Aufwand: leicht Gelb Kochzeit < 30 Min oder Aufwand = einfach

Tags werden frontend-seitig aus slotMap + Rezept-Tags abgeleitet — kein Backend-Änderungsbedarf.


Rezept-Picker Drawer

Trigger: „Gericht tauschen" auf Kachel-Rückseite oder „Gericht wählen" auf leerer Kachel.

  • Slide-in von rechts, min(480px, 90vw)
  • Backdrop-Klick schließt
  • Bestehendes RecipePicker-Component wird in neuen RecipePickerDrawer-Wrapper gepackt

Komponenten

Datei Aktion Beschreibung
src/routes/(app)/planner/+page.svelte Ändern Rechtes Panel entfernen, 2-spaltiges Layout, Toolbar bereinigen, Grid auf height: 100%
src/lib/planner/DayMealCard.svelte Umbau Flip-Kachel-Struktur (scene → card → front/back), Farb-Prop, Gradient-Overlay, Back-Face
src/lib/planner/EmptyDayTile.svelte Neu Leere Kachel mit + CTA und Inline-Suggestions
src/lib/planner/RecipePickerDrawer.svelte Neu Drawer-Wrapper mit Slide-in, Backdrop, Schließ-Logik
src/lib/planner/RecipePicker.svelte Ändern Aus Panel lösen, slotId als Prop
src/app.css Ergänzen 14 Farb-Klassen für Protein- + Küchenstil-Gradients

Accessibility

  • .scene: role="button", tabindex="0", aria-expanded, aria-label
  • .card-back: aria-hidden="true" wenn nicht sichtbar
  • Gedimmte Kacheln: aria-hidden="true"
  • Keyboard: Enter/Space flippt, Escape dreht zurück

Out of Scope

  • Mobile: Bleibt unverändert (vertikaler Stack + ActionSheet). Flip auf Touch-Geräten → separates Issue.
  • Suggestion-Ranking: Backend-seitige Verbesserung der Suggestion-API → separates Issue.
## Übersicht Der Wochenplaner hat auf Desktop aktuell ~80 % vertikalen Leerraum unterhalb des 7-Spalten-Kalenders, und das rechte Panel wird im Idle-Zustand nicht sinnvoll genutzt. Dieses Issue beschreibt ein vollständiges Redesign der Desktop-Hauptfläche. **Spec:** `specs/planner-redesign-flip-tiles.html` (auf `master`) **Interaktiver Mockup:** `specs/planner-flip-tiles.html` --- ## Kern-Entscheidungen - **Kacheln füllen die volle Höhe und Breite** — 7-Spalten-Grid (`repeat(7,1fr)`) mit `height: 100%`, kein Leerraum darunter - **CSS 3D Card Flip** ersetzt das Expansion-Panel — Klick auf eine gefüllte Kachel dreht sie um, Rückseite zeigt Zutaten + Aktionen - **Kein persistentes rechtes Panel** — entfällt vollständig, Koch-Modus ist auf der Kachel-Rückseite - **Inline-Vorschläge auf leeren Kacheln** — Reasoning-Tags statt numerischer Score-Deltas - **Kein „Gericht hinzufügen"-Button** in der Toolbar — jede leere Kachel hat eigene CTA - **Rezept-Picker als Slide-in Drawer** — öffnet sich nur auf Anfrage (nicht persistent) --- ## Layout-Änderungen ``` Vorher: sidebar (184px) | main (flex:1) | right panel (228px) Nachher: sidebar (184px) | main (flex:1) ``` Die linke Sidebar mit dem Variety-Score bleibt unverändert. --- ## Kachel-Zustände | Zustand | Darstellung | |---|---| | Standard (gefüllt) | Vollbild-Farbgradient oder `heroImageUrl`, Dual-Overlay, Name + Meta unten | | Heute | Wie Standard + gelber Ring via `box-shadow: 0 0 0 2px var(--yellow)` | | Geflippt / Ausgewählt | Grüner Ring, Karte dreht 180°, Geschwister auf 38% gedimmt | | Leer | Dashed Border, `+` CTA oben, Inline-Vorschläge darunter | Statusringe via `box-shadow` (kein `border`) → kein Layout-Shift. --- ## Farb-Palette (Fallback wenn kein `heroImageUrl`) Priorität: `heroImageUrl` → Protein-Tag → Cuisine-Tag → neutrales `--color-surface` **Protein:** `protein-haehnchen` (amber), `protein-rind` (rot), `protein-fisch` (blau), `protein-tofu` (grün), `protein-veg` (hellgrün), `protein-schwein` (lachs), `protein-lamm` (braun), `protein-ei` (gold), `protein-huelsenfruechte` (erde) **Küche:** `cuisine-italienisch` (tomatenrot), `cuisine-asiatisch` (dunkelgrün), `cuisine-indisch` (ocker), `cuisine-mexikanisch` (orange), `cuisine-mediterran` (blau) Alle Farbwerte als CSS-Klassen in `src/app.css`. --- ## Flip-Mechanik ``` .scene { perspective: 900px } .card { transform-style: preserve-3d; transition: transform .45s cubic-bezier(.4,0,.2,1) } .card.flipped { transform: rotateY(180deg) } .card-front { backface-visibility: hidden } .card-back { backface-visibility: hidden; transform: rotateY(180deg) } ``` **Rückseite enthält:** 1. 5px Farbstreifen (identischer Gradient wie Vorderseite) 2. Tag + Datum / × Schließen 3. Rezeptname (Fraunces 15px) 4. Meta (Kochzeit · Aufwand · Portionen) 5. Zutaten-Pills (normale + Staple-gedimmt) 6. Aktionen: **Koch-Modus** (primary), Rezept ansehen, Gericht tauschen, Entfernen (danger) Kein API-Aufruf beim Flip — alle Daten sind im vorhandenen `slotMap`-State. --- ## Leere Kacheln — Inline Suggestions Reasoning-Tags statt Score-Deltas (die für leere Slots immer positiv sind): | Tag | Farbe | Bedingung | |---|---|---| | Neues Protein | Grün | Proteinquelle diese Woche noch nicht vorhanden | | Kein Overlap | Grün | Keine Zutaten-Überschneidung | | Aufwand: leicht | Gelb | Kochzeit < 30 Min oder Aufwand = einfach | Tags werden frontend-seitig aus `slotMap` + Rezept-Tags abgeleitet — kein Backend-Änderungsbedarf. --- ## Rezept-Picker Drawer Trigger: „Gericht tauschen" auf Kachel-Rückseite oder „Gericht wählen" auf leerer Kachel. - Slide-in von rechts, `min(480px, 90vw)` - Backdrop-Klick schließt - Bestehendes `RecipePicker`-Component wird in neuen `RecipePickerDrawer`-Wrapper gepackt --- ## Komponenten | Datei | Aktion | Beschreibung | |---|---|---| | `src/routes/(app)/planner/+page.svelte` | Ändern | Rechtes Panel entfernen, 2-spaltiges Layout, Toolbar bereinigen, Grid auf `height: 100%` | | `src/lib/planner/DayMealCard.svelte` | Umbau | Flip-Kachel-Struktur (scene → card → front/back), Farb-Prop, Gradient-Overlay, Back-Face | | `src/lib/planner/EmptyDayTile.svelte` | Neu | Leere Kachel mit + CTA und Inline-Suggestions | | `src/lib/planner/RecipePickerDrawer.svelte` | Neu | Drawer-Wrapper mit Slide-in, Backdrop, Schließ-Logik | | `src/lib/planner/RecipePicker.svelte` | Ändern | Aus Panel lösen, `slotId` als Prop | | `src/app.css` | Ergänzen | 14 Farb-Klassen für Protein- + Küchenstil-Gradients | --- ## Accessibility - `.scene`: `role="button"`, `tabindex="0"`, `aria-expanded`, `aria-label` - `.card-back`: `aria-hidden="true"` wenn nicht sichtbar - Gedimmte Kacheln: `aria-hidden="true"` - Keyboard: `Enter`/`Space` flippt, `Escape` dreht zurück --- ## Out of Scope - **Mobile:** Bleibt unverändert (vertikaler Stack + ActionSheet). Flip auf Touch-Geräten → separates Issue. - **Suggestion-Ranking:** Backend-seitige Verbesserung der Suggestion-API → separates Issue.
Author
Owner

🧑‍💻 Kai — Frontend Engineer

Questions & Observations

  • Flip state as $state(): The spec shows .card.flipped as a CSS class — in Svelte 5, the flipped state should live in a $state() rune per tile. Avoid direct DOM class manipulation; let Svelte bind the class from reactive state (class:flipped={isFlipped}).

  • Dimmed siblings (38%): When one tile flips, all siblings get opacity: 38%. How is this modelled? A lifted $state in the parent grid (e.g., activeSlotId) passed as a prop makes sibling dimming and aria-hidden trivial to derive — both $derived() from the same source of truth.

  • $derived() for reasoning tags: The inline suggestion tags (Neues Protein, Kein Overlap, Aufwand: leicht) are computed from slotMap + recipe tags. This derivation must use $derived() — never $: labels, never $effect(). A pure helper function called inside $derived() inside EmptyDayTile.svelte keeps it testable in isolation.

  • RecipePickerDrawer open/close state: Clarify which side holds the open/close $state — the page or the drawer itself? I'd lean toward the page holding it (passed as open prop + onClose callback), so the drawer is purely presentational and easier to test.

  • SSR safety: The flip is purely visual — no SSR concerns. But confirm slotMap is populated from +page.server.ts before any tile renders, not lazily populated in an $effect.

  • perspective vs transform-style: CSS perspective should be on .scene, and transform-style: preserve-3d on .card. Do not set both on the same element — mixing them breaks 3D compositing in some browsers (Safari especially).

Suggestions

  • Use data-testid="day-meal-card-{slotId}" on .scene elements for stable Playwright and Testing Library selectors.
  • For the Escape key handler, attach on:keydown on .scene (or a global listener scoped to the active flip). If using a global listener, clean it up in the $effect return function to avoid leaking listeners across navigations.
  • Write DayMealCard.test.ts red-first. Cover: standard state render, flip on Enter/click, back-face content visible, Escape closes, aria-expanded toggles correctly.
## 🧑‍💻 Kai — Frontend Engineer ### Questions & Observations - **Flip state as `$state()`**: The spec shows `.card.flipped` as a CSS class — in Svelte 5, the flipped state should live in a `$state()` rune per tile. Avoid direct DOM class manipulation; let Svelte bind the class from reactive state (`class:flipped={isFlipped}`). - **Dimmed siblings (38%)**: When one tile flips, all siblings get `opacity: 38%`. How is this modelled? A lifted `$state` in the parent grid (e.g., `activeSlotId`) passed as a prop makes sibling dimming and `aria-hidden` trivial to derive — both `$derived()` from the same source of truth. - **`$derived()` for reasoning tags**: The inline suggestion tags (Neues Protein, Kein Overlap, Aufwand: leicht) are computed from `slotMap` + recipe tags. This derivation must use `$derived()` — never `$:` labels, never `$effect()`. A pure helper function called inside `$derived()` inside `EmptyDayTile.svelte` keeps it testable in isolation. - **RecipePickerDrawer open/close state**: Clarify which side holds the open/close `$state` — the page or the drawer itself? I'd lean toward the page holding it (passed as `open` prop + `onClose` callback), so the drawer is purely presentational and easier to test. - **SSR safety**: The flip is purely visual — no SSR concerns. But confirm `slotMap` is populated from `+page.server.ts` before any tile renders, not lazily populated in an `$effect`. - **`perspective` vs `transform-style`**: CSS `perspective` should be on `.scene`, and `transform-style: preserve-3d` on `.card`. Do not set both on the same element — mixing them breaks 3D compositing in some browsers (Safari especially). ### Suggestions - Use `data-testid="day-meal-card-{slotId}"` on `.scene` elements for stable Playwright and Testing Library selectors. - For the Escape key handler, attach `on:keydown` on `.scene` (or a global listener scoped to the active flip). If using a global listener, clean it up in the `$effect` return function to avoid leaking listeners across navigations. - Write `DayMealCard.test.ts` red-first. Cover: standard state render, flip on Enter/click, back-face content visible, Escape closes, `aria-expanded` toggles correctly.
Author
Owner

🎨 Atlas — UI/UX Designer

Questions & Observations

  • CSS token alignment for 14 new colour classes: The issue says "alle Farbwerte als CSS-Klassen in src/app.css". Clarify whether these are @theme variable declarations or utility gradient classes. They should integrate into the existing layered token system (base → semantic → component) — not added as flat one-off classes — otherwise the token layer drifts.

  • --yellow and green ring tokens: The today-ring uses var(--yellow) and the flipped ring uses a green variant. Are --yellow and the specific green shade already declared in the design system, or are they new tokens? If new, they need to be formally registered in the token layer file, not inlined as magic values.

  • Dual-overlay contrast on heroImageUrl tiles: When a recipe image is shown, text (recipe name, meta) overlays it. A photo background with no controlled luminosity can fall below 4.5:1 contrast. What's the minimum overlay opacity? Specify it explicitly in the spec (e.g., background: linear-gradient(to top, rgba(0,0,0,.65) 0%, transparent 60%)).

  • Fraunces 15px on card back: Using Fraunces at 15px for the recipe name is fine. Just confirm the spec doesn't call for font-weight: 700 — the design system caps at 600.

  • Empty tile dashed border token: The spec says "dashed border" but doesn't specify a token. border: 1px dashed var(--color-border)? A new --color-border-subtle? Define it explicitly so each implementer doesn't pick a different value.

  • 38% opacity for dimmed siblings: Is this from the opacity scale or an ad-hoc value? If it's project-specific, document it as --opacity-dimmed: 0.38 in the token file so it's reusable (e.g., could apply to dimmed modal backgrounds too).

  • Ingredient pills (Zutaten-Pills): "normale + Staple-gedimmt" — is the pill component already spec'd elsewhere, or does this issue introduce a new pill pattern that needs a design spec?

Suggestions

  • Add @media (prefers-reduced-motion: reduce) { .card { transition: none; } } to the flip CSS. The flip is the centrepiece of this redesign — users with vestibular disorders need a graceful fallback.
  • The + CTA on empty tiles and "Koch-Modus" button on the card back are primary interactive elements. Verify their touch targets are ≥ 44×44px even when the grid is at 7 columns on a 1280px viewport — at narrow column widths this can get tight.
## 🎨 Atlas — UI/UX Designer ### Questions & Observations - **CSS token alignment for 14 new colour classes**: The issue says "alle Farbwerte als CSS-Klassen in `src/app.css`". Clarify whether these are `@theme` variable declarations or utility gradient classes. They should integrate into the existing layered token system (base → semantic → component) — not added as flat one-off classes — otherwise the token layer drifts. - **`--yellow` and green ring tokens**: The today-ring uses `var(--yellow)` and the flipped ring uses a green variant. Are `--yellow` and the specific green shade already declared in the design system, or are they new tokens? If new, they need to be formally registered in the token layer file, not inlined as magic values. - **Dual-overlay contrast on `heroImageUrl` tiles**: When a recipe image is shown, text (recipe name, meta) overlays it. A photo background with no controlled luminosity can fall below 4.5:1 contrast. What's the minimum overlay opacity? Specify it explicitly in the spec (e.g., `background: linear-gradient(to top, rgba(0,0,0,.65) 0%, transparent 60%)`). - **Fraunces 15px on card back**: Using Fraunces at 15px for the recipe name is fine. Just confirm the spec doesn't call for `font-weight: 700` — the design system caps at 600. - **Empty tile dashed border token**: The spec says "dashed border" but doesn't specify a token. `border: 1px dashed var(--color-border)`? A new `--color-border-subtle`? Define it explicitly so each implementer doesn't pick a different value. - **38% opacity for dimmed siblings**: Is this from the opacity scale or an ad-hoc value? If it's project-specific, document it as `--opacity-dimmed: 0.38` in the token file so it's reusable (e.g., could apply to dimmed modal backgrounds too). - **Ingredient pills (Zutaten-Pills)**: "normale + Staple-gedimmt" — is the pill component already spec'd elsewhere, or does this issue introduce a new pill pattern that needs a design spec? ### Suggestions - Add `@media (prefers-reduced-motion: reduce) { .card { transition: none; } }` to the flip CSS. The flip is the centrepiece of this redesign — users with vestibular disorders need a graceful fallback. - The `+` CTA on empty tiles and "Koch-Modus" button on the card back are primary interactive elements. Verify their touch targets are ≥ 44×44px even when the grid is at 7 columns on a 1280px viewport — at narrow column widths this can get tight.
Author
Owner

🧪 QA Engineer — Test Coverage

Questions & Observations

  • Tile states matrix: Four distinct states, four sets of tests. Confirm each has a dedicated component test:

    State Key assertions
    Standard (gefüllt) Image/gradient visible, name + meta rendered, no flip
    Heute Yellow ring via box-shadow, aria-label includes today indicator
    Geflippt aria-expanded: true, back face visible, front aria-hidden, green ring
    Leer Dashed border, + CTA visible, suggestion tags rendered per fixture
  • Flip interaction coverage:

    • Click on filled tile → flips
    • Enter/Space on .scene → flips (keyboard parity)
    • Escape → unflips
    • × button on back face → unflips
    • Clarify: does a second click on the same tile toggle it back, or is × the only exit? The spec isn't explicit.
  • RecipePickerDrawer:

    • Trigger from empty tile CTA → drawer opens with correct slotId
    • Trigger from "Gericht tauschen" on card back → drawer opens
    • Backdrop click → drawer closes
    • Escape while drawer is open → closes drawer. Does this conflict with the card flip Escape handler? Define the priority order.
  • Reasoning tags (EmptyDayTile):

    • Need slotMap fixtures for each tag condition: (a) protein not yet in week, (b) no ingredient overlap, (c) cook time < 30 min. What renders when none of the conditions are true — zero tags or a fallback message?
    • These fixtures should live in a shared __fixtures__ or testHelpers file, not inline per test.
  • Dimmed siblings: When tile A flips, all other tiles should have aria-hidden="true" and reduced opacity. Write a parent-level component test that mounts the full grid, flips one tile, and asserts siblings are dimmed and aria-hidden.

  • prefers-reduced-motion: At minimum one test that verifies the CSS transition: none rule is applied when the media query is active.

Suggestions

  • Add data-testid attributes to: .scene wrapper, .card-front, .card-back, drawer backdrop, each reasoning tag. Stable selectors = stable tests.
  • Write a desktop-viewport E2E test for the core journey: open planner → flip a tile → verify back-face content → click "Gericht tauschen" → drawer opens → select recipe → tile updates. This is the highest-value E2E path in this redesign.
  • The "kein Backend-Änderungsbedarf" claim means test fixtures can be injected entirely via the server load function — mock +page.server.ts data in component tests or use a seeded E2E environment.
## 🧪 QA Engineer — Test Coverage ### Questions & Observations - **Tile states matrix**: Four distinct states, four sets of tests. Confirm each has a dedicated component test: | State | Key assertions | |---|---| | Standard (gefüllt) | Image/gradient visible, name + meta rendered, no flip | | Heute | Yellow ring via `box-shadow`, `aria-label` includes today indicator | | Geflippt | `aria-expanded: true`, back face visible, front `aria-hidden`, green ring | | Leer | Dashed border, `+` CTA visible, suggestion tags rendered per fixture | - **Flip interaction coverage**: - Click on filled tile → flips - `Enter`/`Space` on `.scene` → flips (keyboard parity) - `Escape` → unflips - × button on back face → unflips - Clarify: does a second click on the same tile toggle it back, or is × the only exit? The spec isn't explicit. - **RecipePickerDrawer**: - Trigger from empty tile CTA → drawer opens with correct `slotId` - Trigger from "Gericht tauschen" on card back → drawer opens - Backdrop click → drawer closes - `Escape` while drawer is open → closes drawer. Does this conflict with the card flip `Escape` handler? Define the priority order. - **Reasoning tags (EmptyDayTile)**: - Need `slotMap` fixtures for each tag condition: (a) protein not yet in week, (b) no ingredient overlap, (c) cook time < 30 min. What renders when *none* of the conditions are true — zero tags or a fallback message? - These fixtures should live in a shared `__fixtures__` or `testHelpers` file, not inline per test. - **Dimmed siblings**: When tile A flips, all other tiles should have `aria-hidden="true"` and reduced opacity. Write a parent-level component test that mounts the full grid, flips one tile, and asserts siblings are dimmed and aria-hidden. - **`prefers-reduced-motion`**: At minimum one test that verifies the CSS `transition: none` rule is applied when the media query is active. ### Suggestions - Add `data-testid` attributes to: `.scene` wrapper, `.card-front`, `.card-back`, drawer backdrop, each reasoning tag. Stable selectors = stable tests. - Write a desktop-viewport E2E test for the core journey: open planner → flip a tile → verify back-face content → click "Gericht tauschen" → drawer opens → select recipe → tile updates. This is the highest-value E2E path in this redesign. - The "kein Backend-Änderungsbedarf" claim means test fixtures can be injected entirely via the server load function — mock `+page.server.ts` data in component tests or use a seeded E2E environment.
Author
Owner

🔒 Sable — Security Engineer

Questions & Observations

  • No new API surface on flip — good: The spec correctly states "kein API-Aufruf beim Flip." This is a security positive. Verify this holds in implementation — any future refactor to lazy-load card-back content would introduce an auth-gated endpoint that needs household-level ownership checks.

  • Swap endpoint IDOR check: The drawer triggers the existing recipe swap/assign endpoint. Confirm that endpoint:

    • Validates the session server-side
    • Verifies the slotId belongs to the requesting user's household (IDOR protection)
    • Returns 403 for cross-household slot assignments, not 500 or a silent success
  • slotMap data provenance: The reasoning tags and card-back content are derived from slotMap state. Confirm slotMap is populated in +page.server.ts from a server-side API call — not assembled from user-controlled query params or cookies that could inject arbitrary state into the client.

  • Dynamic CSS class names from recipe tags: The protein/cuisine CSS class names (e.g., protein-haehnchen) are derived from recipe data. If these are ever assembled dynamically as class="protein-{recipe.proteinTag}", ensure the tag values are validated against an allowlist before interpolation. Individually benign in CSS, but insufficient sanitization here often indicates a broader gap.

  • aria-hidden on dimmed tiles and focus trapping: When siblings are aria-hidden="true" while a tile is flipped, ensure they also lose keyboard focus (tabindex="-1" or inert). A focused-but-aria-hidden element is a well-known accessibility trap that screen readers handle inconsistently — and it's also a sign of incomplete focus management that can expose navigation paths in unexpected ways.

  • Drawer backdrop and clickjacking: The new full-screen backdrop is no direct risk, but confirm the planner page is served with X-Frame-Options: DENY or Content-Security-Policy: frame-ancestors 'none' in SvelteKit hooks — overlay-heavy pages are a standard clickjacking target.

Suggestions

  • Add a named integration test: shouldReturn403WhenSlotBelongsToDifferentHousehold(). This makes the IDOR invariant explicit and regression-proof rather than relying on implicit coverage.
  • After implementation, run the OWASP ZAP passive scan against the new desktop planner view to catch any unintended information exposure in page source or response headers.
## 🔒 Sable — Security Engineer ### Questions & Observations - **No new API surface on flip — good**: The spec correctly states "kein API-Aufruf beim Flip." This is a security positive. Verify this holds in implementation — any future refactor to lazy-load card-back content would introduce an auth-gated endpoint that needs household-level ownership checks. - **Swap endpoint IDOR check**: The drawer triggers the existing recipe swap/assign endpoint. Confirm that endpoint: - Validates the session server-side - Verifies the `slotId` belongs to the requesting user's household (IDOR protection) - Returns 403 for cross-household slot assignments, not 500 or a silent success - **`slotMap` data provenance**: The reasoning tags and card-back content are derived from `slotMap` state. Confirm `slotMap` is populated in `+page.server.ts` from a server-side API call — not assembled from user-controlled query params or cookies that could inject arbitrary state into the client. - **Dynamic CSS class names from recipe tags**: The protein/cuisine CSS class names (e.g., `protein-haehnchen`) are derived from recipe data. If these are ever assembled dynamically as `class="protein-{recipe.proteinTag}"`, ensure the tag values are validated against an allowlist before interpolation. Individually benign in CSS, but insufficient sanitization here often indicates a broader gap. - **`aria-hidden` on dimmed tiles and focus trapping**: When siblings are `aria-hidden="true"` while a tile is flipped, ensure they also lose keyboard focus (`tabindex="-1"` or `inert`). A focused-but-aria-hidden element is a well-known accessibility trap that screen readers handle inconsistently — and it's also a sign of incomplete focus management that can expose navigation paths in unexpected ways. - **Drawer backdrop and clickjacking**: The new full-screen backdrop is no direct risk, but confirm the planner page is served with `X-Frame-Options: DENY` or `Content-Security-Policy: frame-ancestors 'none'` in SvelteKit hooks — overlay-heavy pages are a standard clickjacking target. ### Suggestions - Add a named integration test: `shouldReturn403WhenSlotBelongsToDifferentHousehold()`. This makes the IDOR invariant explicit and regression-proof rather than relying on implicit coverage. - After implementation, run the OWASP ZAP passive scan against the new desktop planner view to catch any unintended information exposure in page source or response headers.
Author
Owner

⚙️ Backend Engineer

Questions & Observations

  • No backend changes in scope — as expected: "kein Backend-Änderungsbedarf" is the right call for V1. The flip and suggestion derivation are purely frontend concerns given the existing slotMap data.

  • RecipeSummaryResponse completeness check: The card back needs: recipe name, cook time, effort (Aufwand), portion count, and ingredient list with staple flag. Are all of these already in the current RecipeSummaryResponse DTO, or is any field missing? If anything is absent, a backend change is a hidden dependency — better to surface it now before implementation starts than to discover it mid-sprint.

  • Reasoning tag logic as a pure function: The three tag conditions (Neues Protein, Kein Overlap, Aufwand: leicht) are derived frontend-side for V1. Design the computation as a pure, extractable function with a clear signature (inputs: slotMap, recipe) so it can move server-side later if the rules grow without requiring a component rewrite.

  • Future suggestion endpoint: The spec notes a backend-side improvement to suggestion ranking as a separate issue. When that work starts, consider a dedicated /suggestions/{weekId} endpoint returning pre-computed ReasoningTag[] per slot — avoids duplicating the derivation logic across frontend and backend once the rules exceed simple tag matching.

Suggestions

  • Before implementation starts: audit RecipeSummaryResponse against the full set of fields the card back requires. If anything is missing, open a sub-task for the DTO + API change so it doesn't block the frontend work unexpectedly.
  • Document the "no backend changes needed" assumption as an explicit item in the issue acceptance criteria — it's a dependency claim that should be verifiable, not just assumed.
## ⚙️ Backend Engineer ### Questions & Observations - **No backend changes in scope — as expected**: "kein Backend-Änderungsbedarf" is the right call for V1. The flip and suggestion derivation are purely frontend concerns given the existing `slotMap` data. - **`RecipeSummaryResponse` completeness check**: The card back needs: recipe name, cook time, effort (Aufwand), portion count, and ingredient list with staple flag. Are all of these already in the current `RecipeSummaryResponse` DTO, or is any field missing? If anything is absent, a backend change is a hidden dependency — better to surface it now before implementation starts than to discover it mid-sprint. - **Reasoning tag logic as a pure function**: The three tag conditions (Neues Protein, Kein Overlap, Aufwand: leicht) are derived frontend-side for V1. Design the computation as a pure, extractable function with a clear signature (inputs: `slotMap`, `recipe`) so it can move server-side later if the rules grow without requiring a component rewrite. - **Future suggestion endpoint**: The spec notes a backend-side improvement to suggestion ranking as a separate issue. When that work starts, consider a dedicated `/suggestions/{weekId}` endpoint returning pre-computed `ReasoningTag[]` per slot — avoids duplicating the derivation logic across frontend and backend once the rules exceed simple tag matching. ### Suggestions - Before implementation starts: audit `RecipeSummaryResponse` against the full set of fields the card back requires. If anything is missing, open a sub-task for the DTO + API change so it doesn't block the frontend work unexpectedly. - Document the "no backend changes needed" assumption as an explicit item in the issue acceptance criteria — it's a dependency claim that should be verifiable, not just assumed.
Author
Owner

🎨 Atlas — UI/UX Designer — Design system decisions (follow-up discussion)

Working through the open items from my earlier review. All 8 resolved.

Resolved

  1. 14 gradient colours — Declared as @theme CSS custom properties (--gradient-protein-haehnchen, --gradient-cuisine-italienisch, etc.), consumed via background: var(--gradient-...). Integrates into the existing token layer, not flat utility classes.

  2. Ring colour tokens — Two new semantic aliases registered in the token layer:

    • --color-ring-today: var(--yellow-text)
    • --color-ring-selected: var(--green-dark)
  3. Hero image overlay opacity — Spec'd explicitly to ensure WCAG 4.5:1 compliance:

    background: linear-gradient(to top, rgba(0,0,0,.65) 0%, transparent 60%);
    

    Starting value; revisit with real images if contrast is insufficient.

  4. Card back font-weight — Fraunces 15px, font-weight: 600 (system cap). Drop to 500 if it reads too heavy.

  5. Empty tile dashed borderborder: 1px dashed var(--color-border). No new token.

  6. Dimmed siblings opacity — Formalised as --opacity-dimmed: 0.38 in the token layer. Reusable for future overlay/passive-state patterns.

  7. Ingredient pills — New src/lib/planner/IngredientPill.svelte component from the start (not inline). Props:

    • staple: boolean — when true, applies opacity: var(--opacity-dimmed)
    • Base styles: border: 1px solid var(--color-border), border-radius: var(--radius-full), padding: 2px 8px, font-size: 12px, font-weight: 500, color: var(--color-text)
  8. prefers-reduced-motion fallback — Mandatory in the flip CSS:

    @media (prefers-reduced-motion: reduce) {
      .card { transition: none; }
    }
    

    Flip still occurs — instant cut, no alternative layout needed.


The spec is coherent with the design system. Implementation can start once the token additions (items 1, 2, 6) are landed in app.css — those are a prerequisite for the component work.

## 🎨 Atlas — UI/UX Designer — Design system decisions (follow-up discussion) Working through the open items from my earlier review. All 8 resolved. ### Resolved 1. **14 gradient colours** — Declared as `@theme` CSS custom properties (`--gradient-protein-haehnchen`, `--gradient-cuisine-italienisch`, etc.), consumed via `background: var(--gradient-...)`. Integrates into the existing token layer, not flat utility classes. 2. **Ring colour tokens** — Two new semantic aliases registered in the token layer: - `--color-ring-today: var(--yellow-text)` - `--color-ring-selected: var(--green-dark)` 3. **Hero image overlay opacity** — Spec'd explicitly to ensure WCAG 4.5:1 compliance: ```css background: linear-gradient(to top, rgba(0,0,0,.65) 0%, transparent 60%); ``` Starting value; revisit with real images if contrast is insufficient. 4. **Card back font-weight** — Fraunces 15px, `font-weight: 600` (system cap). Drop to 500 if it reads too heavy. 5. **Empty tile dashed border** — `border: 1px dashed var(--color-border)`. No new token. 6. **Dimmed siblings opacity** — Formalised as `--opacity-dimmed: 0.38` in the token layer. Reusable for future overlay/passive-state patterns. 7. **Ingredient pills** — New `src/lib/planner/IngredientPill.svelte` component from the start (not inline). Props: - `staple: boolean` — when true, applies `opacity: var(--opacity-dimmed)` - Base styles: `border: 1px solid var(--color-border)`, `border-radius: var(--radius-full)`, `padding: 2px 8px`, `font-size: 12px`, `font-weight: 500`, `color: var(--color-text)` 8. **`prefers-reduced-motion` fallback** — Mandatory in the flip CSS: ```css @media (prefers-reduced-motion: reduce) { .card { transition: none; } } ``` Flip still occurs — instant cut, no alternative layout needed. --- The spec is coherent with the design system. Implementation can start once the token additions (items 1, 2, 6) are landed in `app.css` — those are a prerequisite for the component work.
Author
Owner

🧑‍💻 Kai — Frontend Engineer — Architecture decisions (follow-up discussion)

Working through the open implementation questions from my earlier review. 5 resolved, 1 flagged as prerequisite.

Resolved

  1. activeSlotId ownership$state<string | null>(null) in +page.svelte, passed down as props to tiles. No store. Sibling dimming and aria-hidden derived via $derived() from it in each tile.

  2. RecipePickerDrawer state — Page-owned: drawerOpen: boolean and drawerSlotId: string | null as $state() in +page.svelte. Drawer receives open prop + onClose callback — purely presentational, no internal state.

  3. Flip close trigger — × button is the only close trigger (plus Escape). .scene click always sets activeSlotId (no toggle logic). × button minimum touch target: 40×40px.

  4. Escape key priority — Single $effect keydown listener in +page.svelte. Priority order: drawer open → close drawer; else activeSlotId set → clear it. Listener cleaned up in the $effect return function. No competing handlers in child components.

  5. Reasoning tag helpersrc/lib/planner/reasoningTags.ts — pure function, no component dependency. Signature: computeReasoningTags(slotMap, recipe): ReasoningTag[]. Called inside $derived() in EmptyDayTile.svelte. Unit tested directly with Vitest — no component render needed.

Prerequisite — must resolve before implementation starts

  1. slotMap SSR provenance ⚠️ — Must confirm slotMap is fully populated from +page.server.ts before any tile renders. If any part is lazy-loaded client-side via $effect, tiles will flash empty on first render and the flip state will be unreliable on initial paint. Confirm or open a sub-task.

Implementation can start on items 1–5 in parallel once the token prerequisites from Atlas's comment are landed. Item 6 must be confirmed before DayMealCard and EmptyDayTile are considered done.

## 🧑‍💻 Kai — Frontend Engineer — Architecture decisions (follow-up discussion) Working through the open implementation questions from my earlier review. 5 resolved, 1 flagged as prerequisite. ### Resolved 1. **`activeSlotId` ownership** — `$state<string | null>(null)` in `+page.svelte`, passed down as props to tiles. No store. Sibling dimming and `aria-hidden` derived via `$derived()` from it in each tile. 2. **RecipePickerDrawer state** — Page-owned: `drawerOpen: boolean` and `drawerSlotId: string | null` as `$state()` in `+page.svelte`. Drawer receives `open` prop + `onClose` callback — purely presentational, no internal state. 3. **Flip close trigger** — × button is the only close trigger (plus Escape). `.scene` click always sets `activeSlotId` (no toggle logic). × button minimum touch target: **40×40px**. 4. **Escape key priority** — Single `$effect` keydown listener in `+page.svelte`. Priority order: drawer open → close drawer; else `activeSlotId` set → clear it. Listener cleaned up in the `$effect` return function. No competing handlers in child components. 5. **Reasoning tag helper** — `src/lib/planner/reasoningTags.ts` — pure function, no component dependency. Signature: `computeReasoningTags(slotMap, recipe): ReasoningTag[]`. Called inside `$derived()` in `EmptyDayTile.svelte`. Unit tested directly with Vitest — no component render needed. ### Prerequisite — must resolve before implementation starts 6. **`slotMap` SSR provenance** ⚠️ — Must confirm `slotMap` is fully populated from `+page.server.ts` before any tile renders. If any part is lazy-loaded client-side via `$effect`, tiles will flash empty on first render and the flip state will be unreliable on initial paint. Confirm or open a sub-task. --- Implementation can start on items 1–5 in parallel once the token prerequisites from Atlas's comment are landed. Item 6 must be confirmed before `DayMealCard` and `EmptyDayTile` are considered done.
Author
Owner

Implementation complete — feat/issue-52-planner-flip-tiles

All 6 tasks done, 691 tests green (69 new), npm run check clean.

Commits

Commit Description
f2071ca feat(planner): add flip-tile design tokens to app.css
f37f20d feat(planner): add computeReasoningTags pure helper
2b7a7cc feat(planner): add EmptyDayTile component
d20cd53 feat(planner): add DesktopDayTile flip-tile component
2cebf50 feat(planner): add RecipePickerDrawer slide-in drawer
f97cf49 feat(planner): overhaul desktop layout — flip tiles, no right panel

What was implemented

CSS tokens (app.css) — --color-ring-today, --color-ring-selected, --opacity-dimmed: 0.38, 9 protein gradient tokens, 5 cuisine gradient tokens, all as @theme custom properties.

reasoningTags.ts — Pure computeReasoningTags(slotMap, recipe): ReasoningTag[] helper covering Neues Protein and Aufwand: leicht. No component dependency, directly Vitest-testable.

EmptyDayTile.svelte — Dashed-border empty slot with + Gericht wählen CTA and lazy reasoning tags (shown only when topSuggestion prop is provided — option b).

DesktopDayTile.svelte — CSS 3D flip tile (scene → card → front/back). activeSlotId prop drives flipping and sibling dimming. Filled slots show gradient/image front face + action back face (Koch-Modus, tauschen, entfernen). Empty slots delegate to EmptyDayTile. data-testid="day-meal-card-{slotDate}" to avoid collisions with mobile DayMealCard.

RecipePickerDrawer.svelte — Fixed right-side drawer wrapping RecipePicker. Slide-in transition, backdrop click closes, purely presentational (page-owned open + onclose). RecipePicker only mounts when open.

+page.svelte (desktop overhaul) — Removed right panel and + Gericht hinzufügen toolbar button. Desktop now: sidebar + full-height grid-cols-7 with DesktopDayTile. Page-owned activeSlotId, drawerOpen, drawerSlotId per Kai's architecture. Single $effect Escape handler (drawer > flip priority). +page.server.ts extended to forward tags from /v1/recipes API (enables protein/cuisine gradient logic without backend changes).

Decisions made

  • Ingredient pills: skipped — SlotRecipe DTO has no ingredient list, and the issue explicitly states "kein Backend-Änderungsbedarf".
  • Reasoning tags: lazy (option b) — tags appear only for the tile whose drawer was last opened, using already-fetched suggestions.
  • Mobile: DayMealCard left completely unchanged.
## ✅ Implementation complete — `feat/issue-52-planner-flip-tiles` All 6 tasks done, 691 tests green (69 new), `npm run check` clean. ### Commits | Commit | Description | |---|---| | `f2071ca` | feat(planner): add flip-tile design tokens to app.css | | `f37f20d` | feat(planner): add computeReasoningTags pure helper | | `2b7a7cc` | feat(planner): add EmptyDayTile component | | `d20cd53` | feat(planner): add DesktopDayTile flip-tile component | | `2cebf50` | feat(planner): add RecipePickerDrawer slide-in drawer | | `f97cf49` | feat(planner): overhaul desktop layout — flip tiles, no right panel | ### What was implemented **CSS tokens** (`app.css`) — `--color-ring-today`, `--color-ring-selected`, `--opacity-dimmed: 0.38`, 9 protein gradient tokens, 5 cuisine gradient tokens, all as `@theme` custom properties. **`reasoningTags.ts`** — Pure `computeReasoningTags(slotMap, recipe): ReasoningTag[]` helper covering *Neues Protein* and *Aufwand: leicht*. No component dependency, directly Vitest-testable. **`EmptyDayTile.svelte`** — Dashed-border empty slot with `+ Gericht wählen` CTA and lazy reasoning tags (shown only when `topSuggestion` prop is provided — option b). **`DesktopDayTile.svelte`** — CSS 3D flip tile (`scene → card → front/back`). `activeSlotId` prop drives flipping and sibling dimming. Filled slots show gradient/image front face + action back face (Koch-Modus, tauschen, entfernen). Empty slots delegate to `EmptyDayTile`. `data-testid="day-meal-card-{slotDate}"` to avoid collisions with mobile `DayMealCard`. **`RecipePickerDrawer.svelte`** — Fixed right-side drawer wrapping `RecipePicker`. Slide-in transition, backdrop click closes, purely presentational (page-owned `open` + `onclose`). RecipePicker only mounts when open. **`+page.svelte` (desktop overhaul)** — Removed right panel and `+ Gericht hinzufügen` toolbar button. Desktop now: sidebar + full-height `grid-cols-7` with `DesktopDayTile`. Page-owned `activeSlotId`, `drawerOpen`, `drawerSlotId` per Kai's architecture. Single `$effect` Escape handler (drawer > flip priority). `+page.server.ts` extended to forward `tags` from `/v1/recipes` API (enables protein/cuisine gradient logic without backend changes). ### Decisions made - **Ingredient pills**: skipped — `SlotRecipe` DTO has no ingredient list, and the issue explicitly states "kein Backend-Änderungsbedarf". - **Reasoning tags**: lazy (option b) — tags appear only for the tile whose drawer was last opened, using already-fetched suggestions. - **Mobile**: `DayMealCard` left completely unchanged.
Sign in to join this conversation.