diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte
new file mode 100644
index 00000000..dd7a838c
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if isInterlude}
+
+ {m.journey_add_interlude()}
+
+ {:else}
+ {index + 1}.
+ {item.document!.title}
+ {/if}
+
+
+
+
+ {#if showRemoveConfirm}
+
+ {m.journey_remove_confirm()}
+
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if showNote}
+
+
+
+
{m.journey_note_save_hint()}
+ {#if !isInterlude}
+
+ {/if}
+
+ {#if noteError}
+
{noteError}
+ {/if}
+
+ {:else if !isInterlude}
+
+
+
+ {/if}
+
diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
new file mode 100644
index 00000000..3123aae7
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
@@ -0,0 +1,112 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page, userEvent } from 'vitest/browser';
+import JourneyItemRow from './JourneyItemRow.svelte';
+
+const docItem = (overrides: Partial<{ note: string }> = {}) => ({
+ id: 'item-1',
+ position: 0,
+ document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const },
+ ...overrides
+});
+
+const interludeItem = (note = 'Reise nach Wien') => ({
+ id: 'item-2',
+ position: 1,
+ note
+});
+
+const defaultProps = (overrides = {}) => ({
+ index: 0,
+ total: 3,
+ onMoveUp: vi.fn(),
+ onMoveDown: vi.fn(),
+ onRemove: vi.fn(),
+ onNotePatch: vi.fn().mockResolvedValue(undefined),
+ ...overrides
+});
+
+afterEach(() => cleanup());
+
+describe('JourneyItemRow — note textarea', () => {
+ it('opens note textarea on "Notiz hinzufügen" click', async () => {
+ render(JourneyItemRow, { item: docItem(), ...defaultProps() });
+
+ await userEvent.click(page.getByText('Notiz hinzufügen'));
+
+ await expect
+ .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
+ .toBeInTheDocument();
+ });
+
+ it('calls onNotePatch on textarea blur with non-empty value', async () => {
+ const onNotePatch = vi.fn().mockResolvedValue(undefined);
+ render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
+
+ await userEvent.click(page.getByText('Notiz hinzufügen'));
+ const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
+ await userEvent.fill(textarea, 'Eine neue Notiz');
+ await textarea.element().dispatchEvent(new FocusEvent('blur'));
+
+ expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
+ });
+});
+
+describe('JourneyItemRow — interlude rules', () => {
+ it('does not show "Notiz entfernen" for interlude items', async () => {
+ render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
+
+ // Note section should be visible (interlude always shows note)
+ await expect
+ .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
+ .toBeInTheDocument();
+ // But "Notiz entfernen" must be absent
+ await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument();
+ });
+
+ it('blocks saving empty text on interlude note blur', async () => {
+ const onNotePatch = vi.fn().mockResolvedValue(undefined);
+ render(JourneyItemRow, {
+ item: interludeItem('original text'),
+ ...defaultProps({ onNotePatch })
+ });
+
+ const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
+ await userEvent.clear(textarea);
+ await textarea.element().dispatchEvent(new FocusEvent('blur'));
+
+ expect(onNotePatch).not.toHaveBeenCalled();
+ });
+});
+
+describe('JourneyItemRow — remove confirm', () => {
+ it('shows inline confirm when removing a document item that has a note', async () => {
+ render(JourneyItemRow, {
+ item: docItem({ note: 'Wichtige Notiz' }),
+ ...defaultProps()
+ });
+
+ // Click remove (x button)
+ await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' }));
+
+ await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument();
+ await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument();
+ });
+
+ it('confirm cancel restores remove button without calling onRemove', async () => {
+ const onRemove = vi.fn();
+ render(JourneyItemRow, {
+ item: docItem({ note: 'Notiz' }),
+ ...defaultProps({ onRemove })
+ });
+
+ await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' }));
+ await userEvent.click(page.getByRole('button', { name: 'Abbrechen' }));
+
+ expect(onRemove).not.toHaveBeenCalled();
+ // The remove button should be back
+ await expect
+ .element(page.getByRole('button', { name: 'Wirklich entfernen?' }))
+ .toBeInTheDocument();
+ });
+});