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
Möhren
Zwiebeln
Knoblauch
Paprika
Zucchini
Aubergine
Tomaten
Spinat
+10 weitere …
--radius-xl border-radius (same as SettingsCard)
--color-surface tile background
--color-border border (rest state)
--color-border-hover border on hover
--shadow-card elevation (rest)
--shadow-raised elevation (hover)
28px padding (all sides, same as SettingsCard)
16px / 500 category title (same as SettingsCard)
--green-tint / --green-dark badge when selected > 0
--color-subtle / muted badge 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)
Salz
Pfeffer
Kurkuma
Zimt
Thymian
Oregano
Paprika
Koriander
+16 weitere …
Partial
Olivenöl
Rapsöl
Kokosöl
Butter
Ghee
Sesamöl
All selected
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.
Möhren
Zwiebeln
Knoblauch
Paprika
Tomaten
Zucchini
Aubergine
Brokkoli
+10 weitere …
Salz
Pfeffer
Kurkuma
Zimt
Thymian
Oregano
Paprika
Koriander
+16 weitere …
Olivenöl
Rapsöl
Kokosöl
Butter
Ghee
Sesamöl
Butter
Milch
Joghurt
Parmesan
Reis
Nudeln
Quinoa
Haferflocken
Linsen
Kichererbsen
+3 weitere …
Hühnerbrust
Hackfleisch
Lachs
Thunfisch
Eier
Speck
+5 weitere …
Mobile — single col
← Einstellungen
Vorräte
23 gewählt
Automatisch gespeichert.
Möhren
Zwiebeln
Knoblauch
Zucchini
Paprika
Aubergine
Tomaten
Spinat
+10 weitere …
Salz
Pfeffer
Kurkuma
Thymian
Oregano
Zimt
Paprika
Koriander
+16 weitere …
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
/household/staples — same tile shell applied to CategorySection
Vorräte
23 gewählt
Möhren
Zwiebeln
Knoblauch
Zucchini
Paprika
Tomaten
Aubergine
Brokkoli
+10 weitere …
Salz
Pfeffer
Kurkuma
Thymian
Oregano
Paprika
Koriander
Zimt
+16 weitere …
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)
Salz
Pfeffer
Kurkuma
Thymian
+20 weitere …
+ Zutat hinzufügen
2 — Eingabe aktiv
Salz
Pfeffer
Kurkuma
Thymian
+20 weitere …
3 — Zutat hinzugefügt (optimistic)
Salz
Pfeffer
Kurkuma
Thymian
Safran
+20 weitere …
+ Zutat hinzufügen
4 — Duplikat-Fehler
Salz
Pfeffer
Kurkuma
Thymian
+20 weitere …
Desktop — Tile mit aktiver Eingabe (Gemüse-Tile, Eingabe offen)
Vorräte
23 gewählt
Automatisch gespeichert.
Möhren
Zwiebeln
Knoblauch
Paprika
Zucchini
Tomaten
Aubergine
Brokkoli
+10 weitere …
Salz
Pfeffer
Kurkuma
Thymian
Oregano
Paprika
+18 weitere …
+ Zutat hinzufügen
Mobile — Tile mit Eingabe
Vorräte
23 gewählt
Möhren
Zwiebeln
Zucchini
Paprika
+14 weitere …
Salz
Pfeffer
Kurkuma
+21 weitere …
+ Zutat hinzufügen
{name}
{selectedCount} / {totalCount}
Alle
Keine
{#each visibleChips as ingredient (ingredient.id)}
onToggle(ingredient.id, value)}
/>
{/each}
{#if hasOverflow}
{ expanded = true; }}
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]
bg-transparent border-none underline underline-offset-2
py-[3px] px-[2px]"
>+{overflowCount} weitere …
{/if}
─────────────────────────────────────────────────────────────────
## 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}
{ addMode = true; addInput = ''; addError = ''; }}
class="font-[var(--font-sans)] text-[12px] font-medium tracking-[0.02em]
text-[var(--color-text-muted)] bg-transparent border-none cursor-pointer"
>+ Zutat hinzufügen
{: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"
/>
✓
{ addMode = false; addError = ''; }}
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]
bg-transparent border-none cursor-pointer px-[4px]"
>✕
{#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' } });
};
-->