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>
25 KiB
Editor-Split Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plansto 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) andRecipeEditor.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.IngredientRowrenders 10 lines of template with 5 ARIA labels and 6 grid-columns — a natural single-responsibility unit.TimeDisplayis 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.tsstill covers View behavior, and edit-flow is manually smoked). - No
<style global>extraction. Small CSS duplication (.add,.delbuttons) is accepted. - No prop-type sharing via
<script module>blocks. A.tssibling file is simpler.
Design Snapshot
Shared types — src/lib/components/recipe-editor-types.ts:
export type DraftIng = {
qty: string;
unit: string;
name: string;
note: string;
};
export type DraftStep = { text: string };
Component APIs (locked before implementation):
// 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
<!-- 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 30–89 (imagePath/uploading/fileInput state, imageSrc derived, onFileChosen, removeImage).
Remove these imports at the top:
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):
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:
<section class="block">
<h2>Bild</h2>
<ImageUploadBox
recipeId={recipe.id}
imagePath={recipe.image_path}
onchange={(p) => onimagechange?.(p)}
/>
</section>
- Step 3: Run checks
npm run check
npm test
Expected: 0 errors, 196/196 tests pass.
- Step 4: Manual smoke
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
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
// 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
<!-- 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 100–106) with:
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:
{#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
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
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
<!-- 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:
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):
<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 1–3 still contains:
<section class="block"><h2>Bild</h2><ImageUploadBox .../></section>— no.blockinner 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-listand.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
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
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
<!-- 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:
import TimeDisplay from '$lib/components/TimeDisplay.svelte';
Remove the local timeSummary() function (lines 45–52).
Replace the {#if timeSummary()}<p class="times">...</p>{/if} block in the template with:
<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
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
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
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 → ~330–370 -
RecipeView.svelte: 398 → ~380 -
ImageUploadBox.svelte: ~160 -
IngredientRow.svelte: ~110 -
StepList.svelte: ~100 -
TimeDisplay.svelte: ~30 -
Step 2: Full test + typecheck
npm test
npm run check
Both green.
- Step 3: Git log review
git log --oneline main..HEAD
Expected 4 commits:
refactor(editor): ImageUploadBox als eigenstaendige Componentrefactor(editor): IngredientRow + shared typesrefactor(editor): StepList als eigenstaendige Componentrefactor(view): TimeDisplay als eigenstaendige Component
- Step 4: Remote E2E after push
git push -u origin editor-split
CI builds branch-tagged image. After deploy to kochwas-dev.siegeln.net:
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:
git checkout main
git merge --no-ff editor-split
git push origin main
Risk Notes
- Prop-reference mutability.
IngredientRowandStepListreceiveing/stepsby reference and usebind:valueon their own<input>/<textarea>elements. Svelte 5 handles this correctly — writes propagate to the parent's$statearray. Verified pattern with existingsearchFilterStoreusage and similar bind-through-prop in older Svelte 5 components in this codebase. - Confirm-dialog scope.
ImageUploadBoximportsconfirmActiondirectly rather than using a prop-callback. Consistent with the rest of the codebase (confirmActionis a global). - Scoped CSS duplication.
.deland.addbutton 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 checktype-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.tswould cover the editor end-to-end. Valuable, but out of scope here — this phase is structural extraction, not test coverage expansion. - Extract
RecipeHero/ServingsStepper/TabSwitcherfrom RecipeView: Not on the roadmap. Add to a future phase if RecipeView grows further.