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

25 KiB
Raw Permalink Blame History

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 typessrc/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 3089 (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 100106) 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 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
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 4552).

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 → ~330370

  • 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:

  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
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. 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.