diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte
new file mode 100644
index 00000000..1bd8822c
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
new file mode 100644
index 00000000..1210e545
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
+
+afterEach(cleanup);
+
+declare global {
+ interface Window {
+ __xss_interlude?: number;
+ }
+}
+
+describe('JourneyInterlude', () => {
+ it('renders the note text as plaintext', async () => {
+ render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
+
+ await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
+ });
+
+ it('has aria-label Kuratorennotiz', async () => {
+ render(JourneyInterlude, { props: { note: 'Notiz' } });
+
+ const el = document.querySelector('[aria-label="Kuratorennotiz"]');
+ expect(el).not.toBeNull();
+ });
+
+ it('renders the section-break glyph ❦', async () => {
+ render(JourneyInterlude, { props: { note: 'Notiz' } });
+
+ expect(document.body.textContent).toContain('❦');
+ });
+
+ it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
+ // Interlude uses Svelte text interpolation ({note}), NOT {@html}.
+ render(JourneyInterlude, {
+ props: { note: '
' }
+ });
+
+ expect(window.__xss_interlude).toBeUndefined();
+ expect(document.body.textContent).toContain('
0);
+
+
+
+ {doc.title}
+ {#if formattedDate}
+ {formattedDate}
+ {/if}
+
+
+{#if hasNote}
+
+
+ ✎
+ {item.note}
+
+{/if}
diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
new file mode 100644
index 00000000..dcdb6324
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import type { components } from '$lib/generated/api';
+
+const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
+
+afterEach(cleanup);
+
+declare global {
+ interface Window {
+ __xss_note?: number;
+ }
+}
+
+type JourneyItemView = components['schemas']['JourneyItemView'];
+
+const baseItem = (overrides: Partial = {}): JourneyItemView => ({
+ id: 'item1',
+ position: 0,
+ document: {
+ id: 'd1',
+ title: 'Brief an Helene',
+ documentDate: '1923-05-15',
+ datePrecision: 'FULL'
+ },
+ ...overrides
+});
+
+describe('JourneyItemCard', () => {
+ it('renders the document title', async () => {
+ render(JourneyItemCard, { props: { item: baseItem() } });
+
+ await expect.element(page.getByText('Brief an Helene')).toBeVisible();
+ });
+
+ it('renders the document date when documentDate is present', async () => {
+ render(JourneyItemCard, { props: { item: baseItem() } });
+
+ await expect.element(page.getByText(/1923/)).toBeVisible();
+ });
+
+ it('whole card is a single element', async () => {
+ render(JourneyItemCard, { props: { item: baseItem() } });
+
+ const link = document.querySelector('a');
+ expect(link).not.toBeNull();
+ expect(link?.href).toContain('/documents/d1');
+ });
+
+ it('link has dated aria-label when documentDate is present', async () => {
+ render(JourneyItemCard, { props: { item: baseItem() } });
+
+ const link = document.querySelector('a');
+ expect(link?.getAttribute('aria-label')).toContain('Brief');
+ expect(link?.getAttribute('aria-label')).toContain('1923');
+ });
+
+ it('link has undated aria-label when documentDate is absent', async () => {
+ render(JourneyItemCard, {
+ props: {
+ item: baseItem({
+ document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
+ })
+ }
+ });
+
+ const link = document.querySelector('a');
+ expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
+ });
+
+ it('omits date text when documentDate is absent', async () => {
+ render(JourneyItemCard, {
+ props: {
+ item: baseItem({
+ document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
+ })
+ }
+ });
+
+ await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
+ });
+
+ it('renders ✎ glyph and note text when note is present', async () => {
+ render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
+
+ expect(document.body.textContent).toContain('✎');
+ await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
+ });
+
+ it('omits annotation block when note is blank or whitespace', async () => {
+ render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
+
+ expect(document.body.textContent).not.toContain('✎');
+ });
+
+ it('omits annotation block when note is absent', async () => {
+ render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
+
+ expect(document.body.textContent).not.toContain('✎');
+ });
+
+ it('link meets 44px touch-target (min-h-[44px] class)', async () => {
+ render(JourneyItemCard, { props: { item: baseItem() } });
+
+ const link = document.querySelector('a');
+ expect(link?.className).toContain('min-h-[44px]');
+ });
+
+ it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
+ // Note uses Svelte text interpolation ({note}), NOT {@html}.
+ render(JourneyItemCard, {
+ props: {
+ item: baseItem({
+ note: '
'
+ })
+ }
+ });
+
+ expect(window.__xss_note).toBeUndefined();
+ expect(document.body.textContent).toContain('
+ item.document != null || (item.note != null && item.note.trim().length > 0)
+ )
+);
+
+const confirm = getConfirmService();
+
+async function handleDelete() {
+ const ok = await confirm.confirm({
+ title: m.geschichte_delete_confirm_title(),
+ body: m.geschichte_delete_confirm_body(),
+ confirmLabel: m.btn_delete(),
+ cancelLabel: m.btn_cancel(),
+ destructive: true
+ });
+ if (!ok) return;
+ const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
+ if (res.ok) {
+ goto('/geschichten');
+ }
+}
+
+
+{#if introText}
+
+ {introText}
+{/if}
+
+{#if validItems.length === 0}
+
+ {m.journey_empty_state()}
+
+{:else}
+
+ {#each validItems as item (item.id)}
+ -
+ {#if item.document != null}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+{/if}
+
+
+{#if canBlogWrite}
+
+{/if}
diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
new file mode 100644
index 00000000..d3398c63
--- /dev/null
+++ b/frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts
@@ -0,0 +1,166 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
+import type { components } from '$lib/generated/api';
+
+const { default: JourneyReader } = await import('./JourneyReader.svelte');
+
+afterEach(cleanup);
+
+declare global {
+ interface Window {
+ __xss_journey?: number;
+ }
+}
+
+type GeschichteView = components['schemas']['GeschichteView'];
+type JourneyItemView = components['schemas']['JourneyItemView'];
+
+const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({
+ id: 'g1',
+ title: 'Lesereise Berlin',
+ body: null as unknown as undefined,
+ type: 'JOURNEY',
+ status: 'PUBLISHED',
+ persons: [],
+ items: [],
+ createdAt: '2026-01-01T00:00:00Z',
+ updatedAt: '2026-01-01T00:00:00Z',
+ ...overrides
+});
+
+const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
+ id,
+ position,
+ document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' },
+ note
+});
+
+const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
+ id,
+ position,
+ document: undefined,
+ note
+});
+
+const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
+
+describe('JourneyReader', () => {
+ it('renders intro paragraph when body is non-empty', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
+ canBlogWrite: false
+ }
+ });
+
+ await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
+ });
+
+ it('omits intro paragraph when body is null', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false }
+ });
+
+ // Only empty state should render
+ await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
+ });
+
+ it('omits intro paragraph when body is only whitespace', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
+ });
+
+ expect(document.body.textContent?.trim().replace(/\s+/g, ' ')).not.toContain(' ');
+ await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
+ });
+
+ it('renders empty-state message when items array is empty', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
+ });
+
+ await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
+ });
+
+ it('renders both intro and empty-state when body is set but items is empty', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({
+ body: 'Eine Einleitung.',
+ items: []
+ }),
+ canBlogWrite: false
+ }
+ });
+
+ await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
+ await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
+ });
+
+ it('renders document items (JourneyItemCard)', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }),
+ canBlogWrite: false
+ }
+ });
+
+ await expect.element(page.getByText('Brief an Helene')).toBeVisible();
+ });
+
+ it('renders interlude items (JourneyInterlude)', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }),
+ canBlogWrite: false
+ }
+ });
+
+ await expect.element(page.getByText('Eine Pause.')).toBeVisible();
+ expect(document.body.textContent).toContain('❦');
+ });
+
+ it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({
+ items: [
+ { id: 'dangling', position: 0, document: undefined, note: ' ' },
+ docItem('item2', 'Echter Brief', 1)
+ ]
+ }),
+ canBlogWrite: false
+ }
+ });
+
+ await expect.element(page.getByText('Echter Brief')).toBeVisible();
+ // Empty-state must NOT render when valid items exist
+ await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
+ });
+
+ it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
+ // JourneyReader uses Svelte text interpolation, NOT {@html}.
+ render(JourneyReader, {
+ context: ctx(),
+ props: {
+ geschichte: baseGeschichte({
+ body: '
'
+ }),
+ canBlogWrite: false
+ }
+ });
+
+ expect(window.__xss_journey).toBeUndefined();
+ expect(document.body.textContent).toContain('