Files
kochwas/docs/superpowers/plans/2026-04-19-editor-split.md
hsiegeln 015cb432fb docs(plans): Editor-Split Implementierungsplan (Tier 4 Item B)
5-Task-Plan fuer 4 Sub-Components: ImageUploadBox, IngredientRow,
StepList, TimeDisplay. Parent-owned state bleibt im Parent, Sub-
Components rendern bare Content damit Parent-Scoped-CSS greift.
Keine Component-Unit-Tests (etablierter Codebase-Stil), Manual-
Smoke + existierende e2e-Specs decken Regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:28:30 +02:00

898 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Editor-Split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split the monolithic `RecipeEditor.svelte` (628 L) and pull one readability-oriented block out of `RecipeView.svelte` (398 L) by extracting 4 focused Svelte components: `ImageUploadBox`, `IngredientRow`, `StepList`, `TimeDisplay`. No behavior changes, just structure.
**Architecture:** Parent-owned state stays in the parent (`RecipeEditor` still owns `ingredients: DraftIng[]`, `steps: DraftStep[]`). Sub-components receive props + callbacks and render their own template + scoped CSS. Shared draft types land in `src/lib/components/recipe-editor-types.ts` so sub-components and parent agree on the shape. `RecipeView.TimeDisplay` is pure presentational with no state.
**Tech Stack:** Svelte 5 runes (`$props`, `$state`, `$derived`), TypeScript-strict, no new runtime deps.
---
## Why this is worth doing
- `RecipeEditor.svelte:42-89` (Bild-Upload) and `RecipeEditor.svelte:313-334` (Zubereitung) are each self-contained logic-islands with their own state and handlers. Extracting them caps the file a Claude can reason about in one shot.
- `IngredientRow` renders 10 lines of template with 5 ARIA labels and 6 grid-columns — a natural single-responsibility unit.
- `TimeDisplay` is pure formatting; owning it as a component lets future phases (preview, card hover) reuse it.
## What we are NOT doing
- No refactor of `RecipeView`'s tabs / servings-stepper / ingredient-display. Those work fine as-is; roadmap only names the 4 above.
- No component unit tests (kochwas has none for components; the e2e `recipe-detail.spec.ts` still covers View behavior, and edit-flow is manually smoked).
- No `<style global>` extraction. Small CSS duplication (`.add`, `.del` buttons) is accepted.
- No prop-type sharing via `<script module>` blocks. A `.ts` sibling file is simpler.
## Design Snapshot
**Shared types**`src/lib/components/recipe-editor-types.ts`:
```ts
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };
```
**Component APIs (locked before implementation):**
```ts
// ImageUploadBox.svelte
type Props = {
recipeId: number;
imagePath: string | null; // initial value; component owns its own state after
onchange: (path: string | null) => void;
};
// IngredientRow.svelte
type Props = {
ing: DraftIng; // passed by reference — bind:value=ing.* works transparently
idx: number;
total: number; // for "last row? disable move-down"
onmove: (dir: -1 | 1) => void;
onremove: () => void;
};
// StepList.svelte
type Props = {
steps: DraftStep[]; // passed by reference
onadd: () => void;
onremove: (idx: number) => void;
};
// TimeDisplay.svelte
type Props = {
prepTimeMin: number | null;
cookTimeMin: number | null;
totalTimeMin: number | null;
};
```
**Render-wrapping pattern:** The parent keeps the `<section class="block"><h2>…</h2> … </section>` wrappers. Sub-components render bare content (no outer utility-class wrapper), so the parent's scoped `.block` / `h2` styling continues to apply.
---
## Task 1: Extract `ImageUploadBox`
**Files:**
- Create: `src/lib/components/ImageUploadBox.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: Create the new component**
```svelte
<!-- src/lib/components/ImageUploadBox.svelte -->
<script lang="ts">
import { ImagePlus, ImageOff } from 'lucide-svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
type Props = {
recipeId: number;
imagePath: string | null;
onchange: (path: string | null) => void;
};
let { recipeId, imagePath: initial, onchange }: Props = $props();
let imagePath = $state<string | null>(initial);
let uploading = $state(false);
let fileInput: HTMLInputElement | null = $state(null);
const imageSrc = $derived(
imagePath === null
? null
: /^https?:\/\//i.test(imagePath)
? imagePath
: `/images/${imagePath}`
);
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!requireOnline('Der Bild-Upload')) return;
uploading = true;
try {
const fd = new FormData();
fd.append('file', file);
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'POST', body: fd },
'Upload fehlgeschlagen'
);
if (!res) return;
const body = await res.json();
imagePath = body.image_path;
onchange(imagePath);
} finally {
uploading = false;
}
}
async function removeImage() {
if (imagePath === null) return;
const ok = await confirmAction({
title: 'Bild entfernen?',
message: 'Das Rezept wird danach ohne Titelbild angezeigt.',
confirmLabel: 'Entfernen',
destructive: true
});
if (!ok) return;
if (!requireOnline('Das Entfernen')) return;
uploading = true;
try {
const res = await asyncFetch(
`/api/recipes/${recipeId}/image`,
{ method: 'DELETE' },
'Entfernen fehlgeschlagen'
);
if (!res) return;
imagePath = null;
onchange(null);
} finally {
uploading = false;
}
}
</script>
<div class="image-row">
<div class="image-preview" class:empty={!imageSrc}>
{#if imageSrc}
<img src={imageSrc} alt="" />
{:else}
<span class="placeholder">Kein Bild</span>
{/if}
</div>
<div class="image-actions">
<button
class="btn"
type="button"
onclick={() => fileInput?.click()}
disabled={uploading}
>
<ImagePlus size={16} strokeWidth={2} />
<span>{imagePath ? 'Bild ersetzen' : 'Bild hochladen'}</span>
</button>
{#if imagePath}
<button class="btn ghost" type="button" onclick={removeImage} disabled={uploading}>
<ImageOff size={16} strokeWidth={2} />
<span>Entfernen</span>
</button>
{/if}
{#if uploading}
<span class="upload-status">Lade …</span>
{/if}
</div>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif"
class="file-input"
onchange={onFileChosen}
/>
</div>
<p class="image-hint">Max. 10 MB. JPG, PNG, WebP, GIF oder AVIF.</p>
<style>
.image-row {
display: flex;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.image-preview {
width: 160px;
aspect-ratio: 16 / 10;
border-radius: 10px;
overflow: hidden;
background: #eef3ef;
border: 1px solid #e4eae7;
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-preview.empty {
display: grid;
place-items: center;
color: #999;
font-size: 0.85rem;
}
.image-preview .placeholder {
padding: 0 0.5rem;
text-align: center;
}
.image-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.upload-status {
color: #666;
font-size: 0.9rem;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-hint {
margin: 0.6rem 0 0;
color: #888;
font-size: 0.8rem;
}
.btn {
padding: 0.55rem 0.85rem;
border-radius: 10px;
border: 1px solid #cfd9d1;
background: white;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
min-height: 40px;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn.ghost {
color: #666;
}
.btn:disabled {
opacity: 0.6;
cursor: progress;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
Remove lines 3089 (imagePath/uploading/fileInput state, imageSrc derived, onFileChosen, removeImage).
Remove these imports at the top:
```ts
import { Plus, Trash2, ChevronUp, ChevronDown, ImagePlus, ImageOff } from 'lucide-svelte';
import { confirmAction } from '$lib/client/confirm.svelte';
import { asyncFetch } from '$lib/client/api-fetch-wrapper';
import { requireOnline } from '$lib/client/require-online';
```
Replace with (Task 1 needs only Plus + Trash2 + Chevrons — the image-specific imports move to the sub-component; `confirmAction`/`asyncFetch`/`requireOnline` stay for future tasks):
```ts
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
import ImageUploadBox from '$lib/components/ImageUploadBox.svelte';
```
Remove the image-related CSS (`.image-row`, `.image-preview*`, `.image-actions`, `.image-actions .btn`, `.upload-status`, `.file-input`, `.image-hint`, `.image-block` — those live in the sub-component now).
Replace the Bild section in the template:
```svelte
<section class="block">
<h2>Bild</h2>
<ImageUploadBox
recipeId={recipe.id}
imagePath={recipe.image_path}
onchange={(p) => onimagechange?.(p)}
/>
</section>
```
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
Expected: 0 errors, 196/196 tests pass.
- [ ] **Step 4: Manual smoke**
```bash
npm run dev
```
Open any saved recipe → edit → upload an image → verify it shows up and `onimagechange` fires (parent's state updates). Remove the image → confirms the confirm-dialog and removes. Bail out if either flow breaks.
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/ImageUploadBox.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): ImageUploadBox als eigenstaendige Component
Isoliert den Bild-Upload-Flow (File-Input, Preview, Entfernen-Dialog)
aus dem RecipeEditor. Parent haelt nur noch den <section>-Wrapper und
reicht recipe.id + image_path rein, kriegt Aenderungen per onchange
callback zurueck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Extract types + `IngredientRow`
**Files:**
- Create: `src/lib/components/recipe-editor-types.ts`
- Create: `src/lib/components/IngredientRow.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: Types file**
```ts
// src/lib/components/recipe-editor-types.ts
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };
```
- [ ] **Step 2: IngredientRow component**
```svelte
<!-- src/lib/components/IngredientRow.svelte -->
<script lang="ts">
import { Trash2, ChevronUp, ChevronDown } from 'lucide-svelte';
import type { DraftIng } from './recipe-editor-types';
type Props = {
ing: DraftIng;
idx: number;
total: number;
onmove: (dir: -1 | 1) => void;
onremove: () => void;
};
let { ing, idx, total, onmove, onremove }: Props = $props();
</script>
<li class="ing-row">
<div class="move">
<button
class="move-btn"
type="button"
aria-label="Zutat nach oben"
disabled={idx === 0}
onclick={() => onmove(-1)}
>
<ChevronUp size={14} strokeWidth={2.5} />
</button>
<button
class="move-btn"
type="button"
aria-label="Zutat nach unten"
disabled={idx === total - 1}
onclick={() => onmove(1)}
>
<ChevronDown size={14} strokeWidth={2.5} />
</button>
</div>
<input class="qty" type="text" bind:value={ing.qty} placeholder="Menge" aria-label="Menge" />
<input class="unit" type="text" bind:value={ing.unit} placeholder="Einheit" aria-label="Einheit" />
<input class="name" type="text" bind:value={ing.name} placeholder="Zutat" aria-label="Zutat" />
<input class="note" type="text" bind:value={ing.note} placeholder="Notiz" aria-label="Notiz" />
<button class="del" type="button" aria-label="Zutat entfernen" onclick={onremove}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
<style>
.ing-row {
display: grid;
grid-template-columns: 28px 70px 70px 1fr 1fr 40px;
gap: 0.35rem;
align-items: center;
}
.move {
display: flex;
flex-direction: column;
gap: 2px;
}
.move-btn {
width: 28px;
height: 20px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 6px;
cursor: pointer;
color: #555;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.move-btn:hover:not(:disabled) {
background: #f4f8f5;
}
.move-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ing-row input {
padding: 0.5rem 0.55rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.9rem;
min-height: 38px;
font-family: inherit;
min-width: 0;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
@media (max-width: 560px) {
.ing-row {
grid-template-columns: 28px 70px 1fr 40px;
grid-template-areas:
'move qty name del'
'move unit unit del'
'note note note note';
}
.ing-row .move {
grid-area: move;
}
.ing-row .qty {
grid-area: qty;
}
.ing-row .unit {
grid-area: unit;
}
.ing-row .name {
grid-area: name;
}
.ing-row .note {
grid-area: note;
}
.ing-row .del {
grid-area: del;
}
}
</style>
```
- [ ] **Step 3: Wire up `RecipeEditor.svelte`**
Replace the local `DraftIng` / `DraftStep` type declarations (lines 100106) with:
```ts
import type { DraftIng, DraftStep } from '$lib/components/recipe-editor-types';
import IngredientRow from '$lib/components/IngredientRow.svelte';
```
In the template, swap the `<li class="ing-row">` block for:
```svelte
{#each ingredients as ing, idx (idx)}
<IngredientRow
{ing}
{idx}
total={ingredients.length}
onmove={(dir) => moveIngredient(idx, dir)}
onremove={() => removeIngredient(idx)}
/>
{/each}
```
Remove the CSS for `.ing-row`, `.move`, `.move-btn`, `.ing-row input`, `.del`, and the `@media (max-width: 560px)` block — all now live in `IngredientRow.svelte`.
Remove the unused imports `ChevronUp`, `ChevronDown`, `Trash2` from RecipeEditor (they moved to the sub-component, but wait — `Trash2` is also used for step-remove. Keep `Trash2`, remove the two Chevrons).
- [ ] **Step 4: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 5: Manual smoke**
Open any recipe in edit mode. Add an ingredient, type into all 4 fields, reorder up/down, remove one. Verify save persists the ordering.
- [ ] **Step 6: Commit**
```bash
git add src/lib/components/recipe-editor-types.ts src/lib/components/IngredientRow.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): IngredientRow + shared types
IngredientRow rendert eine einzelne editierbare Zutat-Zeile. DraftIng
und DraftStep sind jetzt in recipe-editor-types.ts, damit Parent und
Sub-Components auf dieselbe Form referenzieren.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Extract `StepList`
**Files:**
- Create: `src/lib/components/StepList.svelte`
- Modify: `src/lib/components/RecipeEditor.svelte`
- [ ] **Step 1: StepList component**
```svelte
<!-- src/lib/components/StepList.svelte -->
<script lang="ts">
import { Plus, Trash2 } from 'lucide-svelte';
import type { DraftStep } from './recipe-editor-types';
type Props = {
steps: DraftStep[];
onadd: () => void;
onremove: (idx: number) => void;
};
let { steps, onadd, onremove }: Props = $props();
</script>
<ol class="step-list">
{#each steps as step, idx (idx)}
<li class="step-row">
<span class="num">{idx + 1}</span>
<textarea
bind:value={step.text}
rows="3"
placeholder="Schritt beschreiben …"
></textarea>
<button class="del" type="button" aria-label="Schritt entfernen" onclick={() => onremove(idx)}>
<Trash2 size={16} strokeWidth={2} />
</button>
</li>
{/each}
</ol>
<button class="add" type="button" onclick={onadd}>
<Plus size={16} strokeWidth={2} />
<span>Schritt hinzufügen</span>
</button>
<style>
.step-list {
list-style: none;
padding: 0;
margin: 0 0 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.step-row {
display: grid;
grid-template-columns: 32px 1fr 40px;
gap: 0.5rem;
align-items: start;
}
.num {
width: 32px;
height: 32px;
background: #2b6a3d;
color: white;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 600;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.step-row textarea {
padding: 0.55rem 0.7rem;
border: 1px solid #cfd9d1;
border-radius: 8px;
font-size: 0.95rem;
font-family: inherit;
resize: vertical;
min-height: 70px;
}
.del {
width: 40px;
height: 40px;
border: 1px solid #f1b4b4;
background: white;
color: #c53030;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.del:hover {
background: #fdf3f3;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.55rem 0.9rem;
border: 1px dashed #cfd9d1;
background: white;
color: #2b6a3d;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.add:hover {
background: #f4f8f5;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeEditor.svelte`**
Add import:
```ts
import StepList from '$lib/components/StepList.svelte';
```
Replace the entire Zubereitung `<section class="block">` template block (starting `<section class="block">` with `<h2>Zubereitung</h2>` through the add-step button):
```svelte
<section class="block">
<h2>Zubereitung</h2>
<StepList {steps} onadd={addStep} onremove={removeStep} />
</section>
```
**CSS audit — what stays and what goes in the parent:**
Parent's template after Tasks 13 still contains:
- `<section class="block"><h2>Bild</h2><ImageUploadBox .../></section>` — no `.block` inner styles needed beyond what's in parent.
- `<div class="meta">` — still here. Keep `.meta`, `.field`, `.row`, `.small`, `.lbl`.
- `<section class="block"><h2>Zutaten</h2><ul class="ing-list">{#each ..}<IngredientRow/>{/each}</ul><button class="add">...</button></section>` — still uses `.ing-list` and `.add`.
- `<section class="block"><h2>Zubereitung</h2><StepList/></section>` — no inner CSS.
- `<div class="foot"><button class="btn ghost">...</button><button class="btn primary">...</button></div>` — keeps `.foot`, `.btn`, `.btn.ghost`, `.btn.primary`, `.btn:disabled`.
So parent CSS after Task 3 keeps: `.editor`, `.meta`, `.field`, `.lbl`, `.row`, `.small`, `.block`, `.block h2`, `.ing-list` (the `<ul>` wrapper), `.add` (for "Zutat hinzufügen"), `.foot`, `.btn` and variants.
Drop from parent CSS in Task 3: `.step-list`, `.step-row`, `.num`, `.step-row textarea`, `.del`.
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 4: Manual smoke**
Open any recipe → edit → add a step, type, remove, save. Verify steps persist with correct ordering.
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/StepList.svelte src/lib/components/RecipeEditor.svelte
git commit -m "$(cat <<'EOF'
refactor(editor): StepList als eigenstaendige Component
Zubereitungs-Liste mit Add + Remove als Sub-Component. Parent steuert
nur noch den Wrapper und reicht steps + die zwei Callbacks rein.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Extract `TimeDisplay` (RecipeView)
**Files:**
- Create: `src/lib/components/TimeDisplay.svelte`
- Modify: `src/lib/components/RecipeView.svelte`
- [ ] **Step 1: TimeDisplay component**
```svelte
<!-- src/lib/components/TimeDisplay.svelte -->
<script lang="ts">
type Props = {
prepTimeMin: number | null;
cookTimeMin: number | null;
totalTimeMin: number | null;
};
let { prepTimeMin, cookTimeMin, totalTimeMin }: Props = $props();
const summary = $derived.by(() => {
const parts: string[] = [];
if (prepTimeMin) parts.push(`Vorb. ${prepTimeMin} min`);
if (cookTimeMin) parts.push(`Kochen ${cookTimeMin} min`);
if (!prepTimeMin && !cookTimeMin && totalTimeMin)
parts.push(`Gesamt ${totalTimeMin} min`);
return parts.join(' · ');
});
</script>
{#if summary}
<p class="times">{summary}</p>
{/if}
<style>
.times {
margin: 0 0 0.25rem;
color: #666;
font-size: 0.9rem;
}
</style>
```
- [ ] **Step 2: Wire up `RecipeView.svelte`**
Add import:
```ts
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
```
Remove the local `timeSummary()` function (lines 4552).
Replace the `{#if timeSummary()}<p class="times">...</p>{/if}` block in the template with:
```svelte
<TimeDisplay
prepTimeMin={recipe.prep_time_min}
cookTimeMin={recipe.cook_time_min}
totalTimeMin={recipe.total_time_min}
/>
```
Remove the `.times` CSS from RecipeView (it's in the sub-component now).
- [ ] **Step 3: Run checks**
```bash
npm run check
npm test
```
- [ ] **Step 4: Manual smoke**
Open any recipe → verify the time line still shows the same content (Vorb. / Kochen / Gesamt).
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/TimeDisplay.svelte src/lib/components/RecipeView.svelte
git commit -m "$(cat <<'EOF'
refactor(view): TimeDisplay als eigenstaendige Component
timeSummary-Formatierung in eine wiederverwendbare Component
gezogen. RecipeView liefert nur noch die drei Werte — zukuenftige
Call-Sites (Preview, Hover-Cards) koennen dieselbe Logik reusen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Self-review + push
- [ ] **Step 1: Line-count audit**
```bash
wc -l src/lib/components/RecipeEditor.svelte src/lib/components/RecipeView.svelte src/lib/components/ImageUploadBox.svelte src/lib/components/IngredientRow.svelte src/lib/components/StepList.svelte src/lib/components/TimeDisplay.svelte
```
Expected shape (approximate, ±10%):
- `RecipeEditor.svelte`: 628 → ~330370
- `RecipeView.svelte`: 398 → ~380
- `ImageUploadBox.svelte`: ~160
- `IngredientRow.svelte`: ~110
- `StepList.svelte`: ~100
- `TimeDisplay.svelte`: ~30
- [ ] **Step 2: Full test + typecheck**
```bash
npm test
npm run check
```
Both green.
- [ ] **Step 3: Git log review**
```bash
git log --oneline main..HEAD
```
Expected 4 commits:
1. `refactor(editor): ImageUploadBox als eigenstaendige Component`
2. `refactor(editor): IngredientRow + shared types`
3. `refactor(editor): StepList als eigenstaendige Component`
4. `refactor(view): TimeDisplay als eigenstaendige Component`
- [ ] **Step 4: Remote E2E after push**
```bash
git push -u origin editor-split
```
CI builds branch-tagged image. After deploy to `kochwas-dev.siegeln.net`:
```bash
npm run test:e2e:remote
```
Expected: 40/42 green (same as Search-State-Store baseline). `recipe-detail.spec.ts` (6 tests) specifically exercises the View side — must be clean.
Manual UAT pass on `https://kochwas-dev.siegeln.net/`:
- Edit a recipe → upload + remove image.
- Add / reorder / remove an ingredient → save → verify persistence on reload.
- Add / remove a step → save → verify.
- Check time-summary rendering on any recipe with prep/cook/total times set.
- [ ] **Step 5: Merge to main**
Once UAT is clean:
```bash
git checkout main
git merge --no-ff editor-split
git push origin main
```
---
## Risk Notes
- **Prop-reference mutability.** `IngredientRow` and `StepList` receive `ing` / `steps` by reference and use `bind:value` on their own `<input>` / `<textarea>` elements. Svelte 5 handles this correctly — writes propagate to the parent's `$state` array. Verified pattern with existing `searchFilterStore` usage and similar bind-through-prop in older Svelte 5 components in this codebase.
- **Confirm-dialog scope.** `ImageUploadBox` imports `confirmAction` directly rather than using a prop-callback. Consistent with the rest of the codebase (`confirmAction` is a global).
- **Scoped CSS duplication.** `.del` and `.add` button styles exist in multiple sub-components. Accepted — the alternative (global button classes) is out of scope for this phase.
- **No component unit tests.** Risk: a structural mistake (bad prop passing, missing callback wiring) wouldn't be caught by logic-layer tests. Mitigation: manual smoke test + `npm run check` type-safety + existing e2e coverage on RecipeView side.
## Deferred — NOT in this plan
- **Component unit tests with `@testing-library/svelte`:** Would add Vitest+browser setup. Worth doing in a separate phase once the project acquires a second component-refactor candidate.
- **Edit-flow E2E spec:** `tests/e2e/remote/recipe-edit.spec.ts` would cover the editor end-to-end. Valuable, but out of scope here — this phase is structural extraction, not test coverage expansion.
- **Extract `RecipeHero` / `ServingsStepper` / `TabSwitcher` from RecipeView:** Not on the roadmap. Add to a future phase if RecipeView grows further.