A3 / D3 — Vorräte Settings

Tile redesign · unified with Settings + Members page language

route /household/staples
context settings
Atlas · 2026-04-10
Analysis

Why the current page looks out of place

Current — plain divs

  • No surface background, no border, no shadow — floats naked on the page
  • Feels like a different app than /settings and /members
  • 3-col grid has no visual container to balance uneven category sizes
  • No count feedback on category level
  • Chip overflow creates page-length sprawl for large categories

Tile approach — matches SettingsCard shell

  • Each category in a tile: radius-xl, shadow-card, surface bg — identical to SettingsCard
  • Tile acts as a visual container that naturally caps chip overflow
  • Count badge + Alle/Keine in tile header replaces raw category label
  • Grid: grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px] — identical to /settings
  • Zero new patterns — reuses the language the user already trusts
Tile Anatomy
CategoryTile — token mapping
Gemüse 7 / 18
Möhren Zwiebeln Knoblauch Paprika Zucchini Aubergine Tomaten Spinat
--radius-xlborder-radius (same as SettingsCard)
--color-surfacetile background
--color-borderborder (rest state)
--color-border-hoverborder on hover
--shadow-cardelevation (rest)
--shadow-raisedelevation (hover)
28pxpadding (all sides, same as SettingsCard)
16px / 500category title (same as SettingsCard)
--green-tint / --green-darkbadge when selected > 0
--color-subtle / mutedbadge when selected === 0

Tile does not have a link/href — it is an interactive container, not a navigation element. Hover state is retained for visual feedback (user can see the tile is a unit), but no cursor:pointer on the tile itself.

Tile States
Empty (0 selected)
Gewürze 0 / 24
Salz Pfeffer Kurkuma Zimt Thymian Oregano Paprika Koriander
Partial
Öle & Fette 2 / 6
Olivenöl Rapsöl Kokosöl Butter Ghee Sesamöl
All selected
Milchprodukte 4 / 4
Butter Milch Joghurt Parmesan
Page Previews
Desktop — /settings page header + 2-col tile grid
← Einstellungen

Vorräte

23 gewählt

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

Gemüse 7 / 18
Möhren Zwiebeln Knoblauch Paprika Tomaten Zucchini Aubergine Brokkoli
Gewürze 0 / 24
Salz Pfeffer Kurkuma Zimt Thymian Oregano Paprika Koriander
Öle & Fette 2 / 6
Olivenöl Rapsöl Kokosöl Butter Ghee Sesamöl
Milchprodukte 4 / 4
Butter Milch Joghurt Parmesan
Getreide & Körner 0 / 9
Reis Nudeln Quinoa Haferflocken Linsen Kichererbsen
Fleisch & Fisch 3 / 11
Hühnerbrust Hackfleisch Lachs Thunfisch Eier Speck
Mobile — single col
← Einstellungen

Vorräte

23 gewählt

Automatisch gespeichert.

Gemüse 7/18
Möhren Zwiebeln Knoblauch Zucchini Paprika Aubergine Tomaten Spinat
Gewürze 0/24
Salz Pfeffer Kurkuma Thymian Oregano Zimt Paprika Koriander
Öle & Fette 2/6
Olivenöl Rapsöl Kokosöl Butter Sesamöl
Context — how /settings and /household/staples sit side by side
/settings — same tile shell, consistent visual weight

Einstellungen

Vorräte

23

Vorräte bearbeiten →
Haushalt

3 Mitglieder

Mitglieder anzeigen →
Profil

Max Mustermann

Profil bearbeiten →
/household/staples — same tile shell applied to CategorySection

Vorräte

23 gewählt
Gemüse 7 / 18
Möhren Zwiebeln Knoblauch Zucchini Paprika Tomaten Aubergine Brokkoli
Gewürze 0 / 24
Salz Pfeffer Kurkuma Thymian Oregano Paprika Koriander Zimt
Seed Flow — Haushalt ohne Zutaten

Wann und wie wird der Katalog befüllt?

Backend — automatisch beim Erstellen des Haushalts

  • Flyway-Migration oder HouseholdService seed bei POST /v1/households
  • Seed-Daten: ~100 typisch deutsche Grundzutaten in 8 Kategorien
  • Alle Zutaten mit isStaple = false — Nutzer entscheidet selbst
  • Kategorien werden mitgeneriert (gleicher Haushalt-Scope)
  • Danach normal editierbar: toggle, add, PATCH name/kategorie

Seed-Katalog — 8 Kategorien (Auswahl)

  • Gemüse: Möhren, Zwiebeln, Knoblauch, Kartoffeln, Tomaten, Paprika, Spinat, Brokkoli, Zucchini, Lauch, Sellerie, Kohl, Blumenkohl, Kürbis, Süßkartoffeln …
  • Gewürze: Salz, Pfeffer, Kurkuma, Kreuzkümmel, Paprikapulver, Zimt, Thymian, Oregano, Rosmarin, Lorbeer, Muskat, Curry, Chili …
  • Öle & Essig: Olivenöl, Rapsöl, Sesamöl, Kokosöl, Apfelessig, Balsamico …
  • Milch & Käse: Butter, Milch, Sahne, Joghurt, Parmesan, Mozzarella, Quark …
  • Getreide & Hülsenfrüchte: Reis, Nudeln, Linsen, Kichererbsen, Haferflocken, Quinoa, Couscous …
  • Fleisch & Fisch: Hähnchenbrust, Hackfleisch, Lachs, Thunfisch (Dose), Eier, Speck …
  • Backzutaten: Mehl, Zucker, Backpulver, Hefe, Speisestärke, Vanille …
  • Saucen & Würzmittel: Tomatenmark, Sojasoße, Senf, Ketchup, Brühe, Kokosmilch …
Add-Ingredient Flow — Inline pro Tile

Jedes Category-Tile hat am unteren Ende einen stillen „+ Zutat hinzufügen" Trigger. Der Flow bleibt inline — kein Modal, kein Sheet. Neue Zutat erscheint sofort als ausgewählter Chip.

1 — Rest (Trigger sichtbar)
Gewürze 0 / 24
Salz Pfeffer Kurkuma Thymian
2 — Eingabe aktiv
Gewürze 0 / 24
Salz Pfeffer Kurkuma Thymian

Enter zum Speichern · Esc zum Abbrechen

3 — Zutat hinzugefügt (optimistic)
Gewürze 1 / 25
Salz Pfeffer Kurkuma Thymian Safran
4 — Duplikat-Fehler
Gewürze 0 / 24
Salz Pfeffer Kurkuma Thymian

„Pfeffer" existiert bereits in dieser Kategorie.

Desktop — Tile mit aktiver Eingabe (Gemüse-Tile, Eingabe offen)

Vorräte

23 gewählt

Automatisch gespeichert.

Gemüse 7 / 18
Möhren Zwiebeln Knoblauch Paprika Zucchini Tomaten Aubergine Brokkoli
Gewürze 0 / 24
Salz Pfeffer Kurkuma Thymian Oregano Paprika
Mobile — Tile mit Eingabe

Vorräte

23 gewählt
Gemüse 7/18
Möhren Zwiebeln Zucchini Paprika
Gewürze 0/24
Salz Pfeffer Kurkuma
───────────────────────────────────────────────────────────────── ## 2. StaplesManager.svelte — grid + selectAll/deselectAll wiring GRID CLASS CHANGE: BEFORE: "grid grid-cols-1 gap-[24px_32px] md:grid-cols-3" AFTER: "grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-[820px]" // matches /settings page exactly TOTAL COUNT COMPUTATION (new): totalSelected = $derived(Object.values(stapleState).filter(Boolean).length) SELECT ALL HANDLER (new): function handleSelectAll(categoryId: string) { const category = categories.find(c => c.id === categoryId); if (!category) return; for (const ing of category.ingredients) { if (!stapleState[ing.id]) { stapleState[ing.id] = true; getPatcher(ing.id)(ing.id, true); } } } DESELECT ALL HANDLER (new): function handleDeselectAll(categoryId: string) { const category = categories.find(c => c.id === categoryId); if (!category) return; for (const ing of category.ingredients) { if (stapleState[ing.id]) { stapleState[ing.id] = false; getPatcher(ing.id)(ing.id, false); } } } PASS TO CategorySection: onSelectAll={() => handleSelectAll(category.id)} onDeselectAll={() => handleDeselectAll(category.id)} EXPOSE totalSelected as prop or slot for +page.svelte to render in header. ───────────────────────────────────────────────────────────────── ## 3. +page.svelte (settings context) — h1 row CHANGE header to match /settings and /members exactly: ← Einstellungen

Vorräte

{totalSelected} gewählt

Automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

NOTE: mb-8 (32px) before grid matches /settings page spacing. ───────────────────────────────────────────────────────────────── ## 4. Token reference table | Token | Value | Applied to | |--------------------------|----------|-------------------------------| | --radius-xl | 16px | tile border-radius | | --color-surface | #F5F4EE | tile background | | --color-border | #D8D7D0 | tile border (rest) | | --color-border-hover | #C0BFB8 | tile border (hover) | | --shadow-card | ... | tile elevation (rest) | | --shadow-raised | ... | tile elevation (hover) | | 28px | — | tile padding (desktop) | | 20px | — | tile padding (mobile, md:28px)| | --green-tint/dark | — | badge when selected > 0 | | --color-subtle/muted | — | badge when selected === 0 | | --color-text-muted | #6B6A63 | action btn text, overflow btn | | --color-border | #D8D7D0 | action btn border | | 16px / 500 / --font-sans | — | category title (= SettingsCard title) | | gap-4 (16px) | — | grid gap (= /settings grid) | | sm:grid-cols-2 | — | grid breakpoint | | max-w-[820px] | — | grid max-width | ## 5. No-op: StapleChip.svelte No changes needed. Chip tokens and hover/focus styles remain identical. ## 6. Mobile adjustment On mobile (< sm), tile padding reduces to 20px: class="p-[20px] md:p-[28px]" Title font-size stays 16px/500 (no mobile reduction — tile is smaller but label stays legible). ═══════════════════════════════════════════════════════════════════ SEED FLOW ═══════════════════════════════════════════════════════════════════ ## 7. Backend — Household seed on creation TRIGGER: after successful household creation (HouseholdService or Flyway data migration) SCOPE: per-household (all seeded rows carry the new household's ID) DEFAULT: isStaple = false for all seeded ingredients CATEGORIES TO SEED (with sortOrder): 1 Gemüse 2 Gewürze & Kräuter 3 Öle & Essig 4 Milch & Käse 5 Getreide & Hülsenfrüchte 6 Fleisch & Fisch 7 Backzutaten 8 Saucen & Würzmittel SEED INGREDIENTS (representative list — full list in seed SQL/Java constant): Gemüse: Möhren, Zwiebeln, Knoblauch, Kartoffeln, Tomaten, Paprika (rot), Paprika (gelb), Spinat, Brokkoli, Zucchini, Lauch, Sellerie, Blumenkohl, Kohl, Kürbis, Süßkartoffeln, Aubergine, Gurke, Erbsen (TK), Mais (Dose) Gewürze & Kräuter: Salz, Pfeffer (schwarz), Kurkuma, Kreuzkümmel, Paprikapulver (edelsüß), Paprikapulver (scharf), Zimt, Thymian, Oregano, Rosmarin, Lorbeerblätter, Muskatnuss, Currypulver, Chilliflocken, Koriander (gemahlen), Petersilie, Schnittlauch, Basilikum Öle & Essig: Olivenöl, Rapsöl, Sesamöl, Kokosöl, Apfelessig, Balsamico-Essig, Weinessig Milch & Käse: Butter, Milch (3,5%), Sahne, Schmand, Joghurt (natur), Parmesan, Mozzarella, Gouda, Quark, Frischkäse, Feta Getreide & Hülsenfrüchte: Reis (Langkorn), Basmati-Reis, Nudeln, Spaghetti, Linsen (rote), Linsen (grüne), Kichererbsen (Dose), Kidneybohnen (Dose), Haferflocken, Quinoa, Couscous, Bulgur, Polenta Fleisch & Fisch: Hähnchenbrust, Hähnchenkeule, Hackfleisch (gemischt), Rinderhack, Lachs (Filet), Thunfisch (Dose), Garnelen (TK), Speck, Eier Backzutaten: Mehl (Typ 405), Mehl (Typ 550), Zucker, Brauner Zucker, Backpulver, Natron, Hefe (trocken), Speisestärke, Vanillezucker, Puderzucker, Kakaopulver, Schokolade (Zartbitter) Saucen & Würzmittel: Tomatenmark, Passierte Tomaten, Tomaten (Dose, ganz), Sojasoße, Senf (mittelscharf), Ketchup, Mayonnaise, Gemüsebrühe, Hühnerbrühe, Kokosmilch, Worcestershire-Sauce, Tabasco, Honig, Ahornsirup IMPLEMENTATION NOTE: Option A (preferred): Java constant in HouseholdSeedService.java, called from HouseholdService.createHousehold() after persisting the household entity. Option B: Flyway repeatable migration R__seed_ingredients.sql — only if global seed makes sense (it doesn't here: ingredients are household-scoped). Deduplication: before inserting, check if category/ingredient with same name (citext) already exists for the household — skip if found. This makes the seed idempotent (safe to re-run in dev). ═══════════════════════════════════════════════════════════════════ ADD INGREDIENT FLOW ═══════════════════════════════════════════════════════════════════ ## 8. Backend — new POST /v1/ingredients endpoint REQUEST: POST /v1/ingredients Content-Type: application/json { "name": "Fenchel", "categoryId": "uuid-of-category" } categoryId is optional — if omitted, ingredient has no category RESPONSE: 201 Created { "id": "new-uuid", "name": "Fenchel", "isStaple": true, "categoryId": "..." } NOTE: newly user-created ingredients default to isStaple = true (user explicitly added it — reasonable to assume they want it tracked) ERROR 409 Conflict: { "error": "DUPLICATE_INGREDIENT", "message": "..." } Backend uses citext so "fenchel" == "Fenchel" == "FENCHEL" AUTHORIZATION: planner role (same as PATCH) ## 9. Frontend — CategorySection.svelte additions NEW PROP: onAddIngredient: (name: string) => Promise<{ id: string; name: string } | 'duplicate' | 'error'> NEW STATE: addMode: boolean = false addInput: string = '' addError: '' | 'duplicate' | 'error' = '' addHighlightId: string | null = null // ID of duplicate chip to highlight briefly TILE FOOTER (below chip cloud, always rendered):
{#if !addMode} {:else}
{ if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') { addMode = false; addError = ''; } }} class="flex-1 min-w-0 font-[var(--font-sans)] text-[13px] text-[var(--color-text)] bg-[var(--color-page)] border rounded-[var(--radius-md)] px-[10px] py-[6px] {addError ? 'border-[var(--color-error)]' : 'border-[var(--green-light)]'} focus:outline-none" />
{#if addError === 'duplicate'}

„{addInput}" existiert bereits in dieser Kategorie.

{:else if addError === 'error'}

Konnte nicht gespeichert werden.

{/if} {/if}
ADD HANDLER: async function handleAdd() { const name = addInput.trim(); if (!name) return; addError = ''; const result = await onAddIngredient(name); if (result === 'duplicate') { addError = 'duplicate'; // find matching ingredient by name (citext: case-insensitive compare) const existing = ingredients.find(i => i.name.toLowerCase() === name.toLowerCase()); if (existing) { addHighlightId = existing.id; setTimeout(() => { addHighlightId = null; }, 2000); } } else if (result === 'error') { addError = 'error'; } else { // success — optimistically add chip (selected = true, from POST response) // parent (StaplesManager) handles state update addMode = false; addInput = ''; addError = ''; } } DUPLICATE CHIP HIGHLIGHT: In StapleChip.svelte, accept optional highlight prop: let { ..., highlight = false }: { ...; highlight?: boolean } = $props(); add class: highlight ? 'outline-2 outline-offset-2 outline-[var(--green-light)]' : '' ## 10. StaplesManager.svelte — onAddIngredient handler async function handleAddIngredient(categoryId: string, name: string) { const res = await fetch('/household/staples/ingredients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, categoryId }) }); if (res.status === 409) return 'duplicate'; if (!res.ok) return 'error'; const created: { id: string; name: string; isStaple: boolean } = await res.json(); // Optimistically add to categories state categories = categories.map(cat => cat.id === categoryId ? { ...cat, ingredients: [...cat.ingredients, created] } : cat ); // Mark as staple (POST returns isStaple: true) stapleState[created.id] = true; return created; } PASS TO CategorySection: onAddIngredient={(name) => handleAddIngredient(category.id, name)} ## 11. New SvelteKit API route: +server.ts at /household/staples/ingredients File: frontend/src/routes/(app)/household/staples/ingredients/+server.ts export const POST: RequestHandler = async ({ request, locals }) => { const { name, categoryId } = await request.json(); const res = await fetch(`${BACKEND}/v1/ingredients`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: ... }, body: JSON.stringify({ name, categoryId }) }); return new Response(await res.text(), { status: res.status, headers: { 'Content-Type': 'application/json' } }); }; -->