feat(planner): desktop redesign — flip tiles, full-width grid, no right panel #52
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Ü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(aufmaster)Interaktiver Mockup:
specs/planner-flip-tiles.htmlKern-Entscheidungen
repeat(7,1fr)) mitheight: 100%, kein Leerraum darunterLayout-Änderungen
Die linke Sidebar mit dem Variety-Score bleibt unverändert.
Kachel-Zustände
heroImageUrl, Dual-Overlay, Name + Meta untenbox-shadow: 0 0 0 2px var(--yellow)+CTA oben, Inline-Vorschläge darunterStatusringe via
box-shadow(keinborder) → kein Layout-Shift.Farb-Palette (Fallback wenn kein
heroImageUrl)Priorität:
heroImageUrl→ Protein-Tag → Cuisine-Tag → neutrales--color-surfaceProtein:
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
Rückseite enthält:
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):
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.
min(480px, 90vw)RecipePicker-Component wird in neuenRecipePickerDrawer-Wrapper gepacktKomponenten
src/routes/(app)/planner/+page.svelteheight: 100%src/lib/planner/DayMealCard.sveltesrc/lib/planner/EmptyDayTile.sveltesrc/lib/planner/RecipePickerDrawer.sveltesrc/lib/planner/RecipePicker.svelteslotIdals Propsrc/app.cssAccessibility
.scene:role="button",tabindex="0",aria-expanded,aria-label.card-back:aria-hidden="true"wenn nicht sichtbararia-hidden="true"Enter/Spaceflippt,Escapedreht zurückOut of Scope
🧑💻 Kai — Frontend Engineer
Questions & Observations
Flip state as
$state(): The spec shows.card.flippedas 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$statein the parent grid (e.g.,activeSlotId) passed as a prop makes sibling dimming andaria-hiddentrivial 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 fromslotMap+ recipe tags. This derivation must use$derived()— never$:labels, never$effect(). A pure helper function called inside$derived()insideEmptyDayTile.sveltekeeps 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 asopenprop +onClosecallback), so the drawer is purely presentational and easier to test.SSR safety: The flip is purely visual — no SSR concerns. But confirm
slotMapis populated from+page.server.tsbefore any tile renders, not lazily populated in an$effect.perspectivevstransform-style: CSSperspectiveshould be on.scene, andtransform-style: preserve-3don.card. Do not set both on the same element — mixing them breaks 3D compositing in some browsers (Safari especially).Suggestions
data-testid="day-meal-card-{slotId}"on.sceneelements for stable Playwright and Testing Library selectors.on:keydownon.scene(or a global listener scoped to the active flip). If using a global listener, clean it up in the$effectreturn function to avoid leaking listeners across navigations.DayMealCard.test.tsred-first. Cover: standard state render, flip on Enter/click, back-face content visible, Escape closes,aria-expandedtoggles correctly.🎨 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@themevariable 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.--yellowand green ring tokens: The today-ring usesvar(--yellow)and the flipped ring uses a green variant. Are--yellowand 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
heroImageUrltiles: 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.38in 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
@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.+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.🧪 QA Engineer — Test Coverage
Questions & Observations
Tile states matrix: Four distinct states, four sets of tests. Confirm each has a dedicated component test:
box-shadow,aria-labelincludes today indicatoraria-expanded: true, back face visible, frontaria-hidden, green ring+CTA visible, suggestion tags rendered per fixtureFlip interaction coverage:
Enter/Spaceon.scene→ flips (keyboard parity)Escape→ unflipsRecipePickerDrawer:
slotIdEscapewhile drawer is open → closes drawer. Does this conflict with the card flipEscapehandler? Define the priority order.Reasoning tags (EmptyDayTile):
slotMapfixtures 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?__fixtures__ortestHelpersfile, 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 CSStransition: nonerule is applied when the media query is active.Suggestions
data-testidattributes to:.scenewrapper,.card-front,.card-back, drawer backdrop, each reasoning tag. Stable selectors = stable tests.+page.server.tsdata in component tests or use a seeded E2E environment.🔒 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:
slotIdbelongs to the requesting user's household (IDOR protection)slotMapdata provenance: The reasoning tags and card-back content are derived fromslotMapstate. ConfirmslotMapis populated in+page.server.tsfrom 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 asclass="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-hiddenon dimmed tiles and focus trapping: When siblings arearia-hidden="true"while a tile is flipped, ensure they also lose keyboard focus (tabindex="-1"orinert). 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: DENYorContent-Security-Policy: frame-ancestors 'none'in SvelteKit hooks — overlay-heavy pages are a standard clickjacking target.Suggestions
shouldReturn403WhenSlotBelongsToDifferentHousehold(). This makes the IDOR invariant explicit and regression-proof rather than relying on implicit coverage.⚙️ 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
slotMapdata.RecipeSummaryResponsecompleteness 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 currentRecipeSummaryResponseDTO, 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-computedReasoningTag[]per slot — avoids duplicating the derivation logic across frontend and backend once the rules exceed simple tag matching.Suggestions
RecipeSummaryResponseagainst 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.🎨 Atlas — UI/UX Designer — Design system decisions (follow-up discussion)
Working through the open items from my earlier review. All 8 resolved.
Resolved
14 gradient colours — Declared as
@themeCSS custom properties (--gradient-protein-haehnchen,--gradient-cuisine-italienisch, etc.), consumed viabackground: var(--gradient-...). Integrates into the existing token layer, not flat utility classes.Ring colour tokens — Two new semantic aliases registered in the token layer:
--color-ring-today: var(--yellow-text)--color-ring-selected: var(--green-dark)Hero image overlay opacity — Spec'd explicitly to ensure WCAG 4.5:1 compliance:
Starting value; revisit with real images if contrast is insufficient.
Card back font-weight — Fraunces 15px,
font-weight: 600(system cap). Drop to 500 if it reads too heavy.Empty tile dashed border —
border: 1px dashed var(--color-border). No new token.Dimmed siblings opacity — Formalised as
--opacity-dimmed: 0.38in the token layer. Reusable for future overlay/passive-state patterns.Ingredient pills — New
src/lib/planner/IngredientPill.sveltecomponent from the start (not inline). Props:staple: boolean— when true, appliesopacity: var(--opacity-dimmed)border: 1px solid var(--color-border),border-radius: var(--radius-full),padding: 2px 8px,font-size: 12px,font-weight: 500,color: var(--color-text)prefers-reduced-motionfallback — Mandatory in the flip CSS: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.🧑💻 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
activeSlotIdownership —$state<string | null>(null)in+page.svelte, passed down as props to tiles. No store. Sibling dimming andaria-hiddenderived via$derived()from it in each tile.RecipePickerDrawer state — Page-owned:
drawerOpen: booleananddrawerSlotId: string | nullas$state()in+page.svelte. Drawer receivesopenprop +onClosecallback — purely presentational, no internal state.Flip close trigger — × button is the only close trigger (plus Escape).
.sceneclick always setsactiveSlotId(no toggle logic). × button minimum touch target: 40×40px.Escape key priority — Single
$effectkeydown listener in+page.svelte. Priority order: drawer open → close drawer; elseactiveSlotIdset → clear it. Listener cleaned up in the$effectreturn function. No competing handlers in child components.Reasoning tag helper —
src/lib/planner/reasoningTags.ts— pure function, no component dependency. Signature:computeReasoningTags(slotMap, recipe): ReasoningTag[]. Called inside$derived()inEmptyDayTile.svelte. Unit tested directly with Vitest — no component render needed.Prerequisite — must resolve before implementation starts
slotMapSSR provenance ⚠️ — Must confirmslotMapis fully populated from+page.server.tsbefore 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
DayMealCardandEmptyDayTileare considered done.✅ Implementation complete —
feat/issue-52-planner-flip-tilesAll 6 tasks done, 691 tests green (69 new),
npm run checkclean.Commits
f2071caf37f20d2b7a7ccd20cd532cebf50f97cf49What 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@themecustom properties.reasoningTags.ts— PurecomputeReasoningTags(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ählenCTA and lazy reasoning tags (shown only whentopSuggestionprop is provided — option b).DesktopDayTile.svelte— CSS 3D flip tile (scene → card → front/back).activeSlotIdprop drives flipping and sibling dimming. Filled slots show gradient/image front face + action back face (Koch-Modus, tauschen, entfernen). Empty slots delegate toEmptyDayTile.data-testid="day-meal-card-{slotDate}"to avoid collisions with mobileDayMealCard.RecipePickerDrawer.svelte— Fixed right-side drawer wrappingRecipePicker. Slide-in transition, backdrop click closes, purely presentational (page-ownedopen+onclose). RecipePicker only mounts when open.+page.svelte(desktop overhaul) — Removed right panel and+ Gericht hinzufügentoolbar button. Desktop now: sidebar + full-heightgrid-cols-7withDesktopDayTile. Page-ownedactiveSlotId,drawerOpen,drawerSlotIdper Kai's architecture. Single$effectEscape handler (drawer > flip priority).+page.server.tsextended to forwardtagsfrom/v1/recipesAPI (enables protein/cuisine gradient logic without backend changes).Decisions made
SlotRecipeDTO has no ingredient list, and the issue explicitly states "kein Backend-Änderungsbedarf".DayMealCardleft completely unchanged.