diff --git a/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md b/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
new file mode 100644
index 0000000..0596962
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-21-photo-recipe-magic-design.md
@@ -0,0 +1,272 @@
+# Foto-Rezept-Magie — Design Spec
+
+**Status:** approved (brainstorming)
+**Datum:** 2026-04-21
+**Ziel-Release:** v1.3.0
+
+## 1. Motivation & Scope
+
+Nutzer sollen ein **gedrucktes oder handgeschriebenes Rezept** fotografieren können. Das Foto wird an ein Vision-LLM (Gemini 2.5 Flash) gesendet, dort zu einer strukturierten Recipe-Shape extrahiert, und direkt in einen vorausgefüllten `RecipeEditor` gepackt — der Nutzer korrigiert bei Bedarf und speichert. Das Foto selbst wird nie persistiert.
+
+**In Scope (v1):**
+
+- Einzelnes Foto von gedrucktem Rezept ODER Handschrift.
+- Extraktion von Titel, Portionen, Zeiten, Zutaten (mit Menge/Einheit/Name), Zubereitungsschritten.
+- Auslöse-Button als `Camera`-Icon (lucide) im Header, disabled wenn offline oder ohne API-Key.
+- Direkter Flow in den `RecipeEditor` (kein separater Preview-Schritt).
+- Server-seitiger Gemini-Call mit structured-output-Schema, Zod-Validierung, 1× Retry bei Schema-Fehler.
+- Hartes Nicht-Speichern des Fotos nach dem Call.
+
+**Explizit Out-of-Scope (v1):**
+
+- Multi-Foto (Kochbuch-Doppelseite): Endpoint nimmt ein Bild entgegen; Erweiterung auf Array bei Bedarf.
+- Extraktion von `image_path` aus dem Bild (Dish-Crop aus Kochbuchseite).
+- Foto-Backup / Persistenz des Input-Fotos.
+- Claude als Fallback.
+- Interpretierende Felder: `cuisine`, `category`, `tags`, freie `description`.
+- Foto-vom-Gericht → AI-erfindet-Rezept (anderer Use-Case).
+
+## 2. User Flow
+
+```
+Header (Camera-Icon, lucide)
+ │
+ ▼
+/new/from-photo (File-Picker: )
+ │
+ ▼ Nutzer wählt/knipst Foto — File bleibt nur im Browser-State
+ │
+ ▼ POST /api/recipes/extract-from-photo (multipart/form-data)
+ │ Server: MIME + Größe validieren, sharp-Preprocess, Gemini-Call, Response
+ │ Foto wird NICHT persistiert.
+ │
+ ▼
+Seite swappt Spinner →
+ │
+ ▼ Nutzer korrigiert, klickt „Speichern" (Editor-Save-Pfad — ob bestehend oder neu: siehe §11)
+ │
+ ▼
+/recipes/:id
+```
+
+**Invarianten:**
+
+- Extraktion erzeugt **kein** DB-Record. Erst der Save-Klick im Editor schreibt.
+- Bei Tab-Close während Extraktion: kein Müll in der DB, AbortController-fähiger Fetch.
+- Offline: Kamera-Icon im Header nicht geklickbar; falls trotzdem Route geöffnet, klare Offline-Meldung.
+
+## 3. Komponenten & Dateien
+
+**Neue Dateien**
+
+| Pfad | Zweck |
+|---|---|
+| `src/routes/new/from-photo/+page.svelte` | Shell. States: `idle` / `loading` / `success` / `error:`. |
+| `src/lib/server/ai/gemini-client.ts` | Thin wrapper: `extractRecipeFromImage(buffer, mime): Promise>`. Liest `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` aus `$env/dynamic/private`. |
+| `src/lib/server/ai/recipe-extraction-prompt.ts` | System-Prompt (DE) + JSON-Schema für Gemini `responseSchema`. Isoliert, weil iterabel. |
+| `src/lib/server/ai/image-preprocess.ts` | `sharp`-basierter Resize (≤1600px lange Kante) + JPEG re-encode (quality 85) + Metadata-Strip. HEIC → JPEG. |
+| `src/routes/api/recipes/extract-from-photo/+server.ts` | POST. Multipart-Parse, Validierung, preprocess, Gemini-Call, Zod-Validierung, Response. |
+| `src/lib/client/photo-upload.svelte.ts` | Frontend-Store für Upload-Zustand. |
+| `tests/unit/ai/recipe-extraction-prompt.test.ts` | Schema-Ping, Retry-Pfad, Zod-Ablehnung. |
+| `tests/unit/ai/image-preprocess.test.ts` | Resize, HEIC, Metadata-Strip. |
+| `tests/unit/ai/gemini-client.test.ts` | Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler. |
+| `tests/api/extract-from-photo.test.ts` | Happy-Path, 413, 415, 422 (NO_RECIPE_IN_IMAGE). |
+| `tests/e2e/remote/photo-import.spec.ts` | Kamera-Icon, Upload-Fixture (Endpoint gestubt), Editor-Prefill, Save, Offline-State. |
+| `tests/fixtures/photo-recipe/` | 3 Fixture-Fotos: gedrucktes Rezept, Handschrift, No-Recipe-Bild. |
+
+**Geänderte Dateien**
+
+| Pfad | Änderung |
+|---|---|
+| `src/routes/+layout.svelte` | Header: `Camera`-Icon, `aria-label="Rezept aus Foto erstellen"`. Nur gerendert wenn `GEMINI_API_KEY` gesetzt (Graceful Degradation). Disabled wenn offline (`networkStore`). Führt zu `/new/from-photo`. |
+| `src/lib/components/RecipeEditor.svelte` | Akzeptiert optionale `initialData?: Partial`-Prop. Wenn gesetzt, Felder vorbefüllen, kein DB-Round-trip. Heute liest der Editor über eine Rezept-ID — dieser Pfad wird abstrahiert. |
+| `Dockerfile` | `sharp` im Native-Build-Stage ergänzen (wie `better-sqlite3`). |
+| `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` | Env-Vars `GEMINI_API_KEY`, `GEMINI_MODEL`, `GEMINI_TIMEOUT_MS` ergänzen. |
+| `docs/OPERATIONS.md` | Abschnitt zu Gemini-Config + Recreate-Hinweis bei Env-Änderung. |
+| `docs/ARCHITECTURE.md` | AI-Extraktionspfad ergänzen. |
+| `CLAUDE.md` | Zeile in Gotcha-Tabelle: Graceful Degradation ohne Key + `sharp` im Build-Stage. |
+
+**Keine DB-Migration.** Recipe-Shape bleibt; der Endpoint produziert ein `Partial` im Response-Body.
+
+## 4. API-Contract
+
+**`POST /api/recipes/extract-from-photo`**
+
+Request: `multipart/form-data`
+
+- `photo`: File. Erlaubt: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`.
+- Max 8 MB (vor Preprocess).
+
+Response 200:
+
+```json
+{
+ "recipe": {
+ "title": "Zürcher Geschnetzeltes",
+ "description": "Mit dem Zauberstab aus dem Kochbuch geholt.",
+ "servings_default": 4,
+ "servings_unit": "Portionen",
+ "prep_time_min": 20,
+ "cook_time_min": 15,
+ "total_time_min": null,
+ "cuisine": null,
+ "category": null,
+ "image_path": null,
+ "source_url": null,
+ "source_domain": null,
+ "ingredients": [
+ { "position": 1, "quantity": 500, "unit": "g", "name": "Kalbsgeschnetzeltes", "note": null, "section": null },
+ { "position": 2, "quantity": 200, "unit": "ml", "name": "Rahm", "note": null, "section": null }
+ ],
+ "steps": [
+ { "position": 1, "text": "Fleisch in heißer Pfanne kurz anbraten, herausnehmen." }
+ ],
+ "tags": []
+ }
+}
+```
+
+Response Fehler-Codes:
+
+| Status | `code` | Bedeutung |
+|---|---|---|
+| 413 | `PAYLOAD_TOO_LARGE` | Photo > 8 MB. |
+| 415 | `UNSUPPORTED_MEDIA_TYPE` | MIME nicht in der Whitelist. |
+| 422 | `NO_RECIPE_IN_IMAGE` | AI-Output valide, aber `title` leer oder (`ingredients.length === 0` UND `steps.length === 0`). |
+| 429 | `AI_RATE_LIMITED` | Gemini 429 durchgereicht. |
+| 503 | `AI_TIMEOUT` | Gemini-Timeout (Default 20 s). |
+| 503 | `AI_FAILED` | Gemini-5xx nach 1 Retry ODER Schema-Validierung nach 1 Retry fehlgeschlagen. |
+| 503 | `AI_NOT_CONFIGURED` | `GEMINI_API_KEY` leer — Endpoint sollte dann ohnehin nicht erreichbar sein via UI, belt-and-suspenders. |
+
+## 5. Prompt-Strategie
+
+Datei: `src/lib/server/ai/recipe-extraction-prompt.ts`
+
+- **Sprache:** Deutsch.
+- **Rolle:** „Du bist ein Rezept-Extraktions-Assistent."
+- **Regeln:**
+ - Nur was lesbar auf dem Bild steht, ins Ergebnis. Sonst `null` oder leeres Array.
+ - Zutatenmengen: Zahl in `quantity`, Einheit separat (`g`, `ml`, `EL`, `TL`, `Stück`, `Prise`…).
+ - Bruchteile (`½`, `¼`, `1 ½`) zu Dezimalzahlen.
+ - Zubereitungsschritte: pro erkennbarer Nummerierung/Absatz ein Schritt.
+ - `description` explizit auf festen Satz setzen (Default im Code: *final phrasing in planning — Kandidaten siehe §11*). Die AI ändert das nicht.
+- **Output:** Gemini `responseMimeType: "application/json"` + `responseSchema`. Strict-typed, keine zusätzlichen Keys.
+- **Temperature:** `0.1`.
+- **Retry bei Schema-Fehler:** Genau 1 zusätzlicher Call mit Appendix „Dein letztes JSON war invalid. Schema: … Bitte nur JSON zurück." Dann `AI_FAILED`.
+
+Zod-Schema spiegelt das Response-Schema serverseitig und wird auf die Gemini-Antwort angewendet.
+
+## 6. Fehlerbehandlung
+
+**Client-Zustände auf `/new/from-photo`:**
+
+| State | UI |
+|---|---|
+| `idle` | `Camera`-Button groß mittig, Text „Foto wählen oder aufnehmen". Hilfetext: „Gedrucktes Rezept oder Handschrift. Eine Seite, scharf, gut ausgeleuchtet." |
+| `loading` | `Loader2` (spin) + Text „Lese das Rezept…". `X`-Button für Abbrechen (AbortController). |
+| `success` | ``. Top-Banner mit `Wand2`: „Aus Foto erstellt — bitte prüfen und ggf. korrigieren." Verschwindet nach erstem Feld-Edit. |
+| `error: NO_RECIPE_IN_IMAGE` | Yellow-Box. Buttons: `Camera` „Anderes Foto" (→ idle), `FilePlus` „Leer anlegen" (→ leerer Editor). |
+| `error: AI_TIMEOUT` / `AI_RATE_LIMITED` / `AI_FAILED` | Red-Toast, Grund. `RotateCw` „Nochmal versuchen" — reused das gleiche File-Objekt, kein Re-Upload durch den Nutzer. |
+| `error: PAYLOAD_TOO_LARGE` | Toast „Foto zu groß (max 8 MB). In besserer Beleuchtung neu aufnehmen." |
+| Offline (auf Route) | Hinweis „Diese Funktion braucht Internet." |
+
+**A11y:** Lade-State `aria-live="polite"`, Fehler-Boxen `role="alert"`, Kamera-Icon mit `aria-label`.
+
+**Server-Seite:**
+
+- Gemini-Call mit `AbortSignal`, Default-Timeout `GEMINI_TIMEOUT_MS` (20000).
+- Retry-Matrix:
+
+ | Gemini-Signal | Verhalten |
+ |---|---|
+ | 429 | `AI_RATE_LIMITED`, kein Retry. |
+ | Network/5xx | 1× Retry mit 500 ms backoff, dann `AI_FAILED`. |
+ | Invalid JSON | 1× Retry mit Append-Prompt, dann `AI_FAILED`. |
+ | Valid JSON aber Schema-invalid | gleicher Pfad wie Invalid JSON. |
+ | Timeout | `AI_TIMEOUT`, kein Retry. |
+
+- **Logging:** `console.warn` mit `{ code, durationMs, imageKB }` — **ohne** Prompt/Response-Inhalt (Privacy).
+
+**Icons (alle aus `lucide-svelte`):**
+
+| Zweck | Icon |
+|---|---|
+| Header-Button | `Camera` |
+| Lade-State | `Loader2` (spin) |
+| Erfolgs-Banner | `Wand2` |
+| Fehler | `AlertTriangle` |
+| „Nochmal versuchen" | `RotateCw` |
+| „Anderes Foto" | `Camera` |
+| „Leer anlegen" | `FilePlus` |
+| Abbrechen (Loading) | `X` |
+
+## 7. Sicherheit / Missbrauch
+
+- **Rate-Limit:** 10 Requests/Min pro IP, simple In-Memory-Throttle im Endpoint. Schützt vor versehentlichem Dauer-Tappen und Kosten-Runaways. Übertrieben für's Heimnetz, aber billig einzubauen.
+- **MIME-Validierung nicht blind client-seitig** — Buffer-Header prüfen (`sharp` metadata) nach Empfang.
+- **`.heic`/`.heif`** funktioniert, wenn `sharp` mit `libheif` gebaut ist (beim offiziellen sharp-arm64-Build dabei). Fixture-Test dafür.
+- **Kein Auth** (Kochwas-Policy). Key stays server-side.
+- **Privacy-Statement** im OPERATIONS.md: „Fotos gehen einmal an Google Gemini und werden danach nicht gespeichert. Gemini nutzt API-Daten im Paid-Tier nicht für Training."
+
+## 8. Konfiguration
+
+Env-Vars (alle in `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example` ergänzen):
+
+| Var | Default | Zweck |
+|---|---|---|
+| `GEMINI_API_KEY` | — (required) | Ohne Key: Feature graceful deaktiviert. |
+| `GEMINI_MODEL` | `gemini-2.5-flash` | Modell-Wechsel (z.B. auf `gemini-2.5-pro`) ohne Rebuild. |
+| `GEMINI_TIMEOUT_MS` | `20000` | Timeout für Vision-Call. |
+
+**Wichtig:** Env-Änderungen greifen erst nach `docker compose up -d --force-recreate`, nicht nach `restart` (siehe Auto-Memory `project_deploy_env_recreate.md`).
+
+## 9. Testing
+
+**Unit (Vitest, mocked Gemini):**
+
+- `image-preprocess.test.ts`: Resize, HEIC→JPEG, Metadata-Strip, JPEG-Qualität.
+- `recipe-extraction-prompt.test.ts`: Prompt enthält Schema; Zod akzeptiert gültige Response; Zod lehnt invalide Response ab; Retry-Logik greift genau 1×.
+- `gemini-client.test.ts`: Timeout, 429-no-retry, 5xx-1x-retry, Network-Fehler.
+
+**API (SvelteKit-Endpoint, gemockter Gemini-Client):**
+
+- `tests/api/extract-from-photo.test.ts`: Happy-Path mit Fixture-JPEG; 413 bei >8MB; 415 bei nicht-Bild; 422 bei Titel-OK-aber-0-Ingredients-UND-0-Steps; 503 mit `AI_NOT_CONFIGURED` wenn Key fehlt.
+
+**E2E (Playwright gegen `kochwas-dev.siegeln.net`):**
+
+- `tests/e2e/remote/photo-import.spec.ts`: Kamera-Icon-Klick; File-Upload (Endpoint gestubt, kein echter Gemini-Call); Editor-Prefill; Save; Redirect auf `/recipes/:id`; Kamera-Icon-disabled bei `context.setOffline(true)`.
+
+**Fixtures:** `tests/fixtures/photo-recipe/`: gedruckte Seite, Handschrift-Karte, No-Recipe-Bild.
+
+**Explizit nicht getestet:** Die Gemini-Vision-Qualität selbst. Das ist Model-Verhalten, nicht unser Code. Manuelle Verifikation nach Deploy.
+
+## 10. PWA / Service Worker
+
+- `/new/from-photo` in den Shell-Pre-Cache aufnehmen.
+- Feature funktioniert nur online — Offline-State wird bewusst gehandhabt (siehe §6).
+- Service-Worker ändert nichts am Extract-Endpoint (keine SW-Cachung für `/api/recipes/extract-from-photo`).
+
+## 11. Offene Kleinigkeiten (in Planung zu entscheiden)
+
+- **Finale Phrasing der `description`** — Kandidaten:
+ - „Mit dem Zauberstab aus dem Kochbuch geholt."
+ - „Foto-Magie frisch aus dem Ofen."
+ - „Aus dem Bild herbeigezaubert."
+ - „Ein Klick, ein Foto, fertig."
+- **Save-Endpoint:** Ob der bestehende Editor-Save-Endpoint das Anlegen eines Rezepts aus Scratch unterstützt, oder ob `insertRecipe` über einen neuen POST `/api/recipes` exponiert werden muss — vor dem Planning prüfen.
+- **Sharp Build-Stage:** Verifizieren, dass das offizielle `sharp`-npm-Package auf arm64 mit libheif-Support ausgeliefert wird; andernfalls Build-Stage-Rezept ähnlich zu `better-sqlite3`.
+- **Rate-Limit-Impl:** In-Memory-LRU oder Redis-like überflüssig — `Map` reicht.
+
+## 12. Akzeptanz-Kriterien
+
+- [ ] Kamera-Icon in der Kopfzeile sichtbar, führt zu `/new/from-photo`.
+- [ ] Kamera-Icon unsichtbar wenn `GEMINI_API_KEY` leer.
+- [ ] Kamera-Icon disabled wenn offline (`networkStore.online === false`).
+- [ ] File-Picker öffnet mobile Rückkamera direkt (`capture="environment"`).
+- [ ] Gedrucktes Fixture-Rezept wird vom Prompt + Mock-Gemini-Response in gültige Recipe-Shape überführt.
+- [ ] Handschrift-Fixture ebenso (Mock).
+- [ ] No-Recipe-Fixture → 422 `NO_RECIPE_IN_IMAGE` → UI zeigt Yellow-Box mit beiden Buttons.
+- [ ] Editor öffnet mit vorbefüllten Feldern, Nutzer kann editieren, Speichern navigiert zu `/recipes/:id`.
+- [ ] Foto-Datei wird nach Request nicht auf Disk gefunden (Test-Assertion im API-Test).
+- [ ] Build im Dockerfile-arm64-Stage erfolgreich mit `sharp`.
+- [ ] `npm test` + `npm run check` grün.