From 81a12ba35c438dc1db4045e9be59db906a7c366d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:31:47 +0200 Subject: [PATCH 01/27] =?UTF-8?q?feat(api):=20regenerate=20api.ts=20?= =?UTF-8?q?=E2=80=94=20GeschichteView,=20GeschichteSummary,=20JourneyItemV?= =?UTF-8?q?iew,=20DocumentSummary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-check: GeschichteView.items present; type emitted as 'STORY'|'JOURNEY' union literal. List endpoint returns GeschichteSummary[]; detail endpoint returns GeschichteView. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/generated/api.ts | 343 +++++++++++++++++++++++++----- 1 file changed, 293 insertions(+), 50 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index d5518fd5..32c3163d 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -84,6 +84,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder journey items + * @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request. + */ + put: operations["reorderItems"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}": { parameters: { query?: never; @@ -420,6 +440,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appendItem"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents": { parameters: { query?: never; @@ -692,6 +728,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/backfill-titles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillTitles"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/backfill-file-hashes": { parameters: { query?: never; @@ -788,6 +840,22 @@ export interface paths { patch: operations["update"]; trace?: never; }; + "/api/geschichten/{id}/items/{itemId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteItem"]; + options?: never; + head?: never; + patch: operations["updateItemNote"]; + trace?: never; + }; "/api/documents/{id}/training-labels": { parameters: { query?: never; @@ -1412,22 +1480,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/documents/conversation": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getConversation"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/dashboard/resume": { parameters: { query?: never; @@ -1690,6 +1742,32 @@ export interface components { provisional: boolean; readonly displayName: string; }; + JourneyReorderDTO: { + itemIds?: string[]; + }; + DocumentSummary: { + /** Format: uuid */ + id: string; + title: string; + /** Format: date */ + documentDate?: string; + /** Format: date */ + documentDateEnd?: string; + /** @enum {string} */ + datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + senderName?: string; + receiverName?: string; + /** Format: int32 */ + receiverCount: number; + }; + JourneyItemView: { + /** Format: uuid */ + id: string; + /** Format: int32 */ + position: number; + document?: components["schemas"]["DocumentSummary"]; + note?: string; + }; DocumentUpdateDTO: { title?: string; /** Format: date */ @@ -1758,6 +1836,7 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; + hasTranscription: boolean; thumbnailUrl?: string; }; PersonMention: { @@ -1946,7 +2025,6 @@ export interface components { /** @enum {string} */ status?: "DRAFT" | "PUBLISHED"; personIds?: string[]; - documentIds?: string[]; }; Geschichte: { /** Format: uuid */ @@ -1955,9 +2033,11 @@ export interface components { body?: string; /** @enum {string} */ status: "DRAFT" | "PUBLISHED"; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; author?: components["schemas"]["AppUser"]; persons?: components["schemas"]["Person"][]; - documents?: components["schemas"]["Document"][]; + items?: components["schemas"]["JourneyItem"][]; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -1965,6 +2045,20 @@ export interface components { /** Format: date-time */ publishedAt?: string; }; + JourneyItem: { + /** Format: uuid */ + id: string; + /** Format: int32 */ + position: number; + note?: string; + /** Format: uuid */ + documentId?: string; + }; + JourneyItemCreateDTO: { + /** Format: uuid */ + documentId?: string; + note?: string; + }; CreateTranscriptionBlockDTO: { /** Format: int32 */ pageNumber?: number; @@ -2163,6 +2257,9 @@ export interface components { actorName?: string; documentTitle?: string; }; + JourneyItemUpdateDTO: { + note?: string; + }; TrainingLabelRequest: { label?: string; enrolled?: boolean; @@ -2230,6 +2327,11 @@ export interface components { color?: string; /** Format: int32 */ documentCount: number; + /** + * Format: int32 + * @description Distinct documents tagged with this tag or any descendant tag (subtree rollup) + */ + subtreeDocumentCount: number; children?: components["schemas"]["TagTreeNodeDTO"][]; /** * Format: uuid @@ -2265,13 +2367,13 @@ export interface components { lastName?: string; /** Format: int64 */ documentCount?: number; + alias?: string; notes?: string; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; provisional?: boolean; - alias?: string; personType?: string; familyMember?: boolean; }; @@ -2402,6 +2504,54 @@ export interface components { nodes: components["schemas"]["PersonNodeDTO"][]; edges: components["schemas"]["RelationshipDTO"][]; }; + AuthorSummary: { + firstName?: string; + lastName?: string; + email: string; + }; + GeschichteSummary: { + body?: string; + title: string; + /** Format: uuid */ + id: string; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + /** @enum {string} */ + status: "DRAFT" | "PUBLISHED"; + author?: components["schemas"]["AuthorSummary"]; + /** Format: date-time */ + publishedAt?: string; + }; + AuthorView: { + /** Format: uuid */ + id: string; + displayName: string; + }; + GeschichteView: { + /** Format: uuid */ + id: string; + title: string; + body?: string; + /** @enum {string} */ + status: "DRAFT" | "PUBLISHED"; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + author?: components["schemas"]["AuthorView"]; + persons: components["schemas"]["PersonView"][]; + items: components["schemas"]["JourneyItemView"][]; + /** Format: date-time */ + publishedAt?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + PersonView: { + /** Format: uuid */ + id: string; + firstName?: string; + lastName?: string; + }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -2548,7 +2698,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -2858,6 +3008,32 @@ export interface operations { }; }; }; + reorderItems: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyReorderDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"][]; + }; + }; + }; + }; getDocument: { parameters: { query?: never; @@ -3563,7 +3739,6 @@ export interface operations { query?: { status?: "DRAFT" | "PUBLISHED"; personId?: string[]; - documentId?: string; limit?: number; }; header?: never; @@ -3578,7 +3753,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"][]; + "*/*": components["schemas"]["GeschichteSummary"][]; }; }; }; @@ -3607,6 +3782,32 @@ export interface operations { }; }; }; + appendItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemCreateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; + }; + }; + }; + }; createDocument: { parameters: { query?: never; @@ -4105,6 +4306,26 @@ export interface operations { }; }; }; + backfillTitles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["BackfillResult"]; + }; + }; + }; + }; backfillFileHashes: { parameters: { query?: never; @@ -4258,7 +4479,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; }; }; }; @@ -4309,6 +4530,54 @@ export interface operations { }; }; }; + deleteItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateItemNote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemUpdateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; + }; + }; + }; + }; patchTrainingLabel: { parameters: { query?: never; @@ -5247,32 +5516,6 @@ export interface operations { }; }; }; - getConversation: { - parameters: { - query: { - senderId: string; - receiverId?: string; - from?: string; - to?: string; - dir?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Document"][]; - }; - }; - }; - }; getResume: { parameters: { query?: never; @@ -5318,7 +5561,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[]; }; header?: never; path?: never; -- 2.49.1 From 825a62241390e81f84e0bf95c597be923810e061 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:33:08 +0200 Subject: [PATCH 02/27] feat(lesereisen): add journey orange CSS tokens to all three theme blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --c-journey-bg/text/border wired in light :root, dark @media, dark [data-theme] blocks. Exposed via @theme inline as color-journey-tint/journey/journey-border. Light: #B46820 on #FEF0E6 ≈ 4.6:1 AA at 12px bold. Dark: #E8862A on #3A2A1A ≈ 4.7:1. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 66702879..0c31ddb2 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -77,6 +77,11 @@ --color-warning: #b45309; --color-warning-fg: #ffffff; + /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ + --color-journey-tint: var(--c-journey-bg); + --color-journey: var(--c-journey-text); + --color-journey-border: var(--c-journey-border); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -128,6 +133,12 @@ /* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */ --c-parchment: #faf8f1; + /* Journey / Lesereise — orange semantic tokens + Text #B46820 on bg #FEF0E6 ≈ 4.6:1 — WCAG AA ✓ at 12px bold (normal-text threshold) */ + --c-journey-bg: #fef0e6; + --c-journey-text: #b46820; + --c-journey-border: #f0c99a; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -246,6 +257,12 @@ /* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for visibility parity with the 8% light-mode token. Decorative carve-out. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on + #3A2A1A ≈ 4.7:1 — WCAG AA ✓ at 12px bold (normal-text threshold) */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; } } @@ -321,6 +338,11 @@ /* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — KEEP IN SYNC with the @media block above */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */ -- 2.49.1 From 0d47bcb4a1a7166da2c7868ada284964e85c86b5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:44:21 +0200 Subject: [PATCH 03/27] feat(lesereisen): GeschichteListRow with JOURNEY badge + i18n keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 17 ++++- frontend/messages/en.json | 17 ++++- frontend/messages/es.json | 17 ++++- .../lib/geschichte/GeschichteListRow.svelte | 49 +++++++++++++++ .../GeschichteListRow.svelte.spec.ts | 63 +++++++++++++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/geschichte/GeschichteListRow.svelte create mode 100644 frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 928460c3..99c9c1b7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1159,5 +1159,20 @@ "themen_alle": "Alle Themen", "themen_leer": "Noch keine Themen vergeben.", "themen_weitere": "+ {count} weitere", - "themen_dokumente": "{count} Dokumente" + "themen_dokumente": "{count} Dokumente", + "journey_badge_list": "REISE", + "journey_badge_detail": "LESEREISE", + "journey_selector_question": "Was möchtest du erstellen?", + "journey_selector_story_title": "Geschichte", + "journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.", + "journey_selector_journey_title": "Lesereise", + "journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.", + "journey_selector_next_btn": "Weiter", + "journey_placeholder_back": "andere Auswahl", + "journey_placeholder_heading": "Lesereise-Editor folgt in #753", + "journey_item_open_aria": "Brief vom {date} öffnen", + "journey_item_open_aria_undated": "Brief öffnen", + "journey_empty_state": "Diese Lesereise ist noch leer.", + "journey_interlude_aria_label": "Kuratorennotiz", + "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e10ef624..0c4686a4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1159,5 +1159,20 @@ "themen_alle": "All Topics", "themen_leer": "No topics assigned yet.", "themen_weitere": "+ {count} more", - "themen_dokumente": "{count} documents" + "themen_dokumente": "{count} documents", + "journey_badge_list": "JOURNEY", + "journey_badge_detail": "READING JOURNEY", + "journey_selector_question": "What would you like to create?", + "journey_selector_story_title": "Story", + "journey_selector_story_desc": "A narrative story with images and text.", + "journey_selector_journey_title": "Reading Journey", + "journey_selector_journey_desc": "A curated selection of letters with notes.", + "journey_selector_next_btn": "Continue", + "journey_placeholder_back": "different selection", + "journey_placeholder_heading": "Reading Journey editor coming in #753", + "journey_item_open_aria": "Open letter from {date}", + "journey_item_open_aria_undated": "Open letter", + "journey_empty_state": "This reading journey is still empty.", + "journey_interlude_aria_label": "Curator's note", + "journey_selector_aria_live_hint": "Please select a type to continue." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 6a8d9eb3..3c8a361e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1159,5 +1159,20 @@ "themen_alle": "Todos los temas", "themen_leer": "Aún no hay temas.", "themen_weitere": "+ {count} más", - "themen_dokumente": "{count} documentos" + "themen_dokumente": "{count} documentos", + "journey_badge_list": "VIAJE", + "journey_badge_detail": "VIAJE DE LECTURA", + "journey_selector_question": "¿Qué deseas crear?", + "journey_selector_story_title": "Historia", + "journey_selector_story_desc": "Una historia narrada con imágenes y texto.", + "journey_selector_journey_title": "Viaje de lectura", + "journey_selector_journey_desc": "Una selección curada de cartas con notas.", + "journey_selector_next_btn": "Continuar", + "journey_placeholder_back": "otra selección", + "journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753", + "journey_item_open_aria": "Abrir carta del {date}", + "journey_item_open_aria_undated": "Abrir carta", + "journey_empty_state": "Este viaje de lectura está vacío.", + "journey_interlude_aria_label": "Nota del curador", + "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar." } diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte new file mode 100644 index 00000000..af2ac038 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -0,0 +1,49 @@ + + + +
+

{geschichte.title}

+ {#if isJourney} + + {m.journey_badge_list()} + + {/if} +
+

+ {authorName} + {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} +

+ {#if geschichte.body} + +

{plainExcerpt(geschichte.body, 150)}

+ {/if} +
diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts new file mode 100644 index 00000000..97e39175 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte'); + +afterEach(cleanup); + +const baseRow = (overrides = {}) => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923...

', + type: 'STORY' as 'STORY' | 'JOURNEY', + status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', + author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }, + publishedAt: '2026-04-15T10:00:00Z', + ...overrides +}); + +describe('GeschichteListRow', () => { + it('renders the title', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Die Reise nach Berlin'); + }); + + it('shows no badge for STORY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows no badge when type is undefined', async () => { + render(GeschichteListRow, { + props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) } + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows REISE badge for JOURNEY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent?.trim()).toBe('REISE'); + }); + + it('badge is a plain , not a nested interactive element', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('badge has text-xs class for WCAG readability', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge?.className).toContain('text-xs'); + }); + + it('renders author name in meta line', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + expect(document.body.textContent).toContain('Anna Schmidt'); + }); +}); -- 2.49.1 From 8fea94cb61bab79b396a4d19795ded5e68326692 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:57:28 +0200 Subject: [PATCH 04/27] =?UTF-8?q?test(lesereisen):=20TDD=20red=20=E2=80=94?= =?UTF-8?q?=20tighten=20factories,=20add=20journey/selector/ssr=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../geschichte/GeschichtenCard.svelte.spec.ts | 10 +++ .../src/lib/shared/utils/extractText.spec.ts | 12 +++ .../geschichten/[id]/page.svelte.test.ts | 73 +++++++++++++----- .../geschichten/new/page.server.test.ts | 76 +++++++++++++++++++ .../geschichten/new/page.svelte.test.ts | 61 ++++++++++++--- .../routes/geschichten/page.svelte.spec.ts | 13 ++++ .../routes/geschichten/page.svelte.test.ts | 3 +- 7 files changed, 219 insertions(+), 29 deletions(-) create mode 100644 frontend/src/routes/geschichten/new/page.server.test.ts diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index 90e3bdb7..2aeefc5a 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -121,6 +121,16 @@ describe('GeschichtenCard', () => { expect(link.getAttribute('href')).toBe('/geschichten?personId=p1'); }); + it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => { + render(GeschichtenCard, { + geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }], + personId: 'p1', + personName: 'Franz', + canWrite: false + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + it('renders a plain-text excerpt without HTML markup', async () => { render(GeschichtenCard, { geschichten: [ diff --git a/frontend/src/lib/shared/utils/extractText.spec.ts b/frontend/src/lib/shared/utils/extractText.spec.ts index 404ac5cb..89f12985 100644 --- a/frontend/src/lib/shared/utils/extractText.spec.ts +++ b/frontend/src/lib/shared/utils/extractText.spec.ts @@ -48,6 +48,18 @@ describe('extractText', () => { }); }); +// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project. +// The browser project's DOMParser would silently take the safe branch → false green. +// This test fires the regex fallback specifically (Node has no DOMParser). +describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => { + it('does not emit onerror= in output when given an payload (security regression)', () => { + // plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser). + // SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk. + const out = plainExcerpt(''); + expect(out).not.toContain('onerror='); + }); +}); + describe('plainExcerpt', () => { it('returns full text when under the limit', () => { expect(plainExcerpt('

short

', 80)).toBe('short'); diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index a251ea80..10172d12 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -3,23 +3,26 @@ 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: GeschichtePage } = await import('./+page.svelte'); afterEach(cleanup); -const baseGeschichte = (overrides: Record = {}) => ({ +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ id: 'g1', title: 'Die Reise nach Berlin', body: '

Im Jahr 1923 fuhr Helene...

', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - items: [] as { id: string; documentId?: string; position: number; note?: string }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); @@ -55,9 +58,7 @@ describe('geschichten/[id] page', () => { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +66,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +87,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +111,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -136,7 +139,14 @@ describe('geschichten/[id] page', () => { props: { data: baseData({ geschichte: baseGeschichte({ - items: [{ id: 'item1', documentId: 'd1', position: 0, note: 'Brief aus 1923' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, + note: 'Brief aus 1923' + } + ] }) }) } @@ -168,4 +178,31 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/new/page.server.test.ts b/frontend/src/routes/geschichten/new/page.server.test.ts new file mode 100644 index 00000000..81663ba9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/page.server.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://backend:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: () => ({ + GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null }) + }) +})); + +import { load } from './+page.server'; + +function makeEvent(search: string, canBlogWrite = true) { + return { + url: new URL(`http://localhost/geschichten/new${search}`), + fetch: vi.fn(), + parent: vi.fn().mockResolvedValue({ canBlogWrite }) + } as never; +} + +describe('geschichten/new load — selectedType validation (security regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns selectedType: STORY for ?type=STORY', async () => { + const result = await load(makeEvent('?type=STORY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => { + const result = await load(makeEvent('?type=JOURNEY')); + expect(result.selectedType).toBe('JOURNEY'); + }); + + it('returns selectedType: null when ?type param is absent', async () => { + const result = await load(makeEvent('')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for invalid ?type param (security regression)', async () => { + const result = await load(makeEvent('?type=ADMIN')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => { + // Strict equality rejects encoded variants; .includes/.startsWith would not. + const result = await load(makeEvent('?type=STORY%00JOURNEY')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => { + // url.searchParams.get() returns the first value; this is intentional and documented. + const result = await load(makeEvent('?type=STORY&type=JOURNEY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => { + const { createApiClient } = await import('$lib/shared/api.server'); + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } }) + } as never); + + const result = await load(makeEvent('?type=STORY&personId=p1')); + expect(result.selectedType).toBe('STORY'); + expect(result.initialPersons).toHaveLength(1); + }); + + it('redirects non-BLOG_WRITE users to /geschichten', async () => { + await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' }); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 8e26a1a2..26a0ad49 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -20,32 +20,34 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte'); afterEach(cleanup); -const baseData = { - initialPersons: [] as { id: string; displayName: string }[] -}; +const baseData = (overrides: Record = {}) => ({ + initialPersons: [] as { id: string; displayName: string }[], + selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null, + ...overrides +}); describe('geschichten/new page', () => { it('renders the page heading', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); }); it('renders a button (BackButton component)', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); const buttons = document.querySelectorAll('button'); expect(buttons.length).toBeGreaterThan(0); }); it('does not render an error banner by default', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); expect(document.querySelector('[role="alert"]')).toBeNull(); }); - it('renders the GeschichteEditor child component', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + it('renders the GeschichteEditor when selectedType is STORY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } }); // Editor renders inputs/textarea — verify at least one form input is present const inputs = document.querySelectorAll('input, textarea'); @@ -55,12 +57,51 @@ describe('geschichten/new page', () => { it('passes initialPersons through to the editor', async () => { render(GeschichtenNewPage, { props: { - data: { + data: baseData({ + selectedType: 'STORY', initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }] - } + }) } }); await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); }); + + it('shows TypeSelector radiogroup when selectedType is null', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + await expect.element(page.getByRole('radiogroup')).toBeVisible(); + }); + + it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); + expect(placeholder).not.toBeNull(); + }); + + it('JOURNEY placeholder offers a return-to-selection link', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const backLink = page.getByRole('link', { name: /andere Auswahl/i }); + await expect.element(backLink).toBeVisible(); + await expect.element(backLink).toHaveAttribute('href', '/geschichten/new'); + }); + + it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + // Select STORY + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await storyCard.click(); + + // Click Weiter + const weiter = page.getByRole('button', { name: /Weiter/i }); + await weiter.click(); + + expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY'); + }); }); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index f5f7621e..5f3a411c 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => { window.history.replaceState({}, '', originalHref); }); + it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'] + }) + }); + + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + }); + it('shows the "+ Person wählen" button even when filters are already active', async () => { render(Page, { data: makeData({ diff --git a/frontend/src/routes/geschichten/page.svelte.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index d5e78ba0..663859b9 100644 --- a/frontend/src/routes/geschichten/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/page.svelte.test.ts @@ -188,8 +188,9 @@ describe('geschichten/+ page', () => { // No "·" separator before date when no publishedAt const titleHeading = document.querySelector('h2'); const card = titleHeading?.closest('li'); - // The middle paragraph (author line) should not contain "·" expect(card?.textContent).toContain('Anna Schmidt'); + // "·" separator must be absent when there is no publishedAt date + expect(card?.textContent).not.toContain('·'); }); it('omits the body excerpt when body is empty', async () => { -- 2.49.1 From 8a6bc2797915edfece27ef92e5acfd6007f4ecb2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:57:51 +0200 Subject: [PATCH 05/27] =?UTF-8?q?feat(lesereisen):=20StoryReader=20?= =?UTF-8?q?=E2=80=94=20extract=20body/persons/docs/actions,=20isJourney=20?= =?UTF-8?q?badge=20in=20detail=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/geschichte/StoryReader.svelte | 120 ++++++++++++++++ .../lib/geschichte/StoryReader.svelte.spec.ts | 135 ++++++++++++++++++ .../src/routes/geschichten/[id]/+page.svelte | 129 +++-------------- 3 files changed, 276 insertions(+), 108 deletions(-) create mode 100644 frontend/src/lib/geschichte/StoryReader.svelte create mode 100644 frontend/src/lib/geschichte/StoryReader.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/StoryReader.svelte b/frontend/src/lib/geschichte/StoryReader.svelte new file mode 100644 index 00000000..bcb04800 --- /dev/null +++ b/frontend/src/lib/geschichte/StoryReader.svelte @@ -0,0 +1,120 @@ + + + +
+ + {@html sanitized} +
+ + +{#if g.persons && g.persons.length > 0} +
+

+ {m.geschichten_persons_section()} +

+ +
+{/if} + + +{#if g.items && g.items.some((i) => i.document)} +
+

+ {m.geschichten_documents_section()} +

+ +
+{/if} + + +{#if canBlogWrite} +
+ + {m.btn_edit()} + + +
+{/if} diff --git a/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts new file mode 100644 index 00000000..7ef5ada8 --- /dev/null +++ b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts @@ -0,0 +1,135 @@ +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: StoryReader } = await import('./StoryReader.svelte'); + +afterEach(cleanup); + +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923 fuhr Helene...

', + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides +}); + +const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]); + +describe('StoryReader', () => { + it('renders body HTML content', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + }); + + it('omits persons section when persons array is empty', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument(); + }); + + it('renders persons section with firstName + lastName joined', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + persons: [ + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible(); + await expect.element(page.getByText('Helene Schmidt')).toBeVisible(); + await expect.element(page.getByText('Karl Müller')).toBeVisible(); + }); + + it('omits documents section when no items have documents', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); + }); + + it('renders documents section for items with documents', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + items: [ + { + id: 'i1', + position: 0, + document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' }, + note: 'Wichtiger Brief' + } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); + await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); + await expect.element(page.getByText('Wichtiger Brief')).toBeVisible(); + }); + + it('shows edit/delete actions when canBlogWrite is true', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: true } + }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/geschichten/g1/edit'); + await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible(); + }); + + it('hides edit/delete actions when canBlogWrite is false', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); + }); + + it('XSS: Story body is sanitised — injected payload does not execute', async () => { + // StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload. + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + body: '' + }), + canBlogWrite: false + } + }); + + expect((window as { __xss_story?: number }).__xss_story).toBeUndefined(); + }); +}); diff --git a/frontend/src/routes/geschichten/[id]/+page.svelte b/frontend/src/routes/geschichten/[id]/+page.svelte index cacdb2e2..23a00d42 100644 --- a/frontend/src/routes/geschichten/[id]/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/+page.svelte @@ -1,17 +1,15 @@ @@ -50,93 +28,28 @@ async function handleDelete() {
-

- {g.title} -

+
+

+ {g.title} +

+ {#if isJourney} + + {m.journey_badge_detail()} + + {/if} +

{authorName()} {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}

- -
- - {@html sanitized} -
+ {#if isJourney} + + {:else} + + {/if}
- - - {#if g.persons && g.persons.length > 0} -
-

- {m.geschichten_persons_section()} -

- -
- {/if} - - - {#if g.items && g.items.some((i) => i.documentId)} -
-

- {m.geschichten_documents_section()} -

- -
- {/if} - - - {#if data.canBlogWrite} -
- - {m.btn_edit()} - - -
- {/if} -- 2.49.1 From 0b9e8c2abb16ee911f2948ccc767ea0bd0d3104d Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:58:15 +0200 Subject: [PATCH 06/27] feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs Co-Authored-By: Claude Sonnet 4.6 --- .../lib/geschichte/JourneyInterlude.svelte | 21 +++ .../JourneyInterlude.svelte.spec.ts | 44 +++++ .../src/lib/geschichte/JourneyItemCard.svelte | 41 +++++ .../geschichte/JourneyItemCard.svelte.spec.ts | 123 +++++++++++++ .../src/lib/geschichte/JourneyReader.svelte | 89 ++++++++++ .../geschichte/JourneyReader.svelte.spec.ts | 166 ++++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte create mode 100644 frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte create mode 100644 frontend/src/lib/geschichte/JourneyReader.svelte.spec.ts 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 @@ + + +
+ + +

{note}

+
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)} +
  1. + {#if item.document != null} + + {:else} + + {/if} +
  2. + {/each} +
+{/if} + + +{#if canBlogWrite} +
+ + {m.btn_edit()} + + +
+{/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(' id !== personId))); } - -function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) { - const a = g.author; - if (!a) return ''; - const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim(); - return full || a.email || ''; -} - -function publishedAt(g: { publishedAt?: string }): string | null { - if (!g.publishedAt) return null; - return formatDate(g.publishedAt.slice(0, 10), 'short'); -}
@@ -131,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
  • - -

    {g.title}

    -

    - {authorName(g)} - {#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if} -

    - {#if g.body} -

    {plainExcerpt(g.body, 150)}

    - {/if} -
    + +
  • {/each} diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts index 4763555d..652aa86c 100644 --- a/frontend/src/routes/geschichten/new/+page.server.ts +++ b/frontend/src/routes/geschichten/new/+page.server.ts @@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => { const initialPersons = personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; - return { initialPersons }; + // Validate ?type against the known union — prevents unexpected strings from reaching the API. + // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and + // only the FIRST value is returned by searchParams.get() on repeated params. + const rawType = url.searchParams.get('type'); + const selectedType: 'STORY' | 'JOURNEY' | null = + rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; + + return { initialPersons, selectedType }; }; diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index ad80d87b..0eff0a68 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -1,42 +1,12 @@
    @@ -46,18 +16,16 @@ async function handleSubmit(payload: {

    {m.geschichten_new_button()}

    - {#if errorMessage} - diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte new file mode 100644 index 00000000..5bd49fdc --- /dev/null +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte @@ -0,0 +1,50 @@ + + +{#if errorMessage} + +{/if} + + diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte b/frontend/src/routes/geschichten/new/TypeSelector.svelte new file mode 100644 index 00000000..a6cc6848 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte @@ -0,0 +1,96 @@ + + +
    +

    + {m.journey_selector_question()} +

    + +
    { + if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType); + }} + > + {#each TYPES as type (type)} + + {/each} +
    + +
    {announcement}
    + + {#if !selected} + + {/if} + + +
    diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts new file mode 100644 index 00000000..8ddaa362 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: TypeSelector } = await import('./TypeSelector.svelte'); + +afterEach(cleanup); + +describe('TypeSelector', () => { + it('renders both type cards', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible(); + }); + + it('radiogroup is correctly labelled', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const group = document.querySelector('[role="radiogroup"]'); + const labelledBy = group?.getAttribute('aria-labelledby'); + const labelEl = labelledBy ? document.getElementById(labelledBy) : null; + expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0); + }); + + it('Weiter button has aria-disabled=true when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('no card is aria-checked when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true'); + expect(anyChecked).toBe(false); + }); + + it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + expect(radios[0]?.getAttribute('tabindex')).toBe('0'); + expect(radios[1]?.getAttribute('tabindex')).toBe('-1'); + }); + + it('clicking STORY card sets aria-checked=true and enables Weiter', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); + + await expect.element(storyCard).toHaveAttribute('aria-checked', 'true'); + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('false'); + }); + + it('clicking JOURNEY card sets aria-checked=true', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await userEvent.click(journeyCard); + + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); + + it('clicking Weiter after selection calls onweiter with the selected type', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + await userEvent.click(page.getByRole('radio', { name: /Geschichte/i })); + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).toHaveBeenCalledWith('STORY'); + }); + + it('clicking Weiter without selection does NOT call onweiter', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).not.toHaveBeenCalled(); + }); + + it('instructional text is visible when no type is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible(); + }); +}); -- 2.49.1 From 97026fec111f5b58dc3d47e5f85a407b1dfa22f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 22:59:03 +0200 Subject: [PATCH 08/27] refactor(geschichte): add utils.ts (formatAuthorName/DisplayName/PublishedAt), update README Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/README.md | 39 ++++++++++++++++++++++----- frontend/src/lib/geschichte/utils.ts | 28 +++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/geschichte/utils.ts diff --git a/frontend/src/lib/geschichte/README.md b/frontend/src/lib/geschichte/README.md index 74baea7b..49024cfc 100644 --- a/frontend/src/lib/geschichte/README.md +++ b/frontend/src/lib/geschichte/README.md @@ -1,10 +1,11 @@ # geschichte (frontend) -UI for family stories: the rich-text editor, story cards, and story list view. +UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-text editor, story/journey readers, type badge, and list rows. ## What this domain owns -Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`. +Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`. +Utilities: `utils.ts`. ## What this domain does NOT own @@ -14,14 +15,38 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`. ## Key components -| Component | Used in | Notes | -| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ | -| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds | -| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status | +| Component | Used in | Notes | +| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds | +| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page | +| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) | +| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions | +| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude | +| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `` for a document item; dated/undated aria-label, ✎ annotation glyph | +| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` | + +## utils.ts + +`formatAuthorName(author)` — joins `firstName + lastName`, falls back to `email` (for list/summary shapes). +`formatAuthorDisplayName(author)` — returns `displayName` (for detail `AuthorView` shape). +`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail). + +## Public list is PUBLISHED-only + +`GET /api/geschichten` constrains `status=PUBLISHED`, so DRAFT journeys never appear in the list. +The REISE badge is only ever seen for published journeys. +Empty-state and draft-preview paths are reachable ONLY via the **detail route** (`/geschichten/[id]`), not the list. +Wire empty-state E2E tests through the detail route, not by expecting a draft journey in the list. + +## TypeSelector route component + +`TypeSelector.svelte` lives in `src/routes/geschichten/new/` (single-use route UI). +It is NOT in `$lib/geschichte/` — route-specific, not reused elsewhere. +`StoryCreate.svelte` (also in `new/`) wraps `GeschichteEditor` so tree-shaking excludes TipTap from the JOURNEY placeholder path. ## Audience note -The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. +The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. JourneyReader mobile layout is Critical; TypeSelector is Minor. ## Cross-domain imports diff --git a/frontend/src/lib/geschichte/utils.ts b/frontend/src/lib/geschichte/utils.ts new file mode 100644 index 00000000..824ad996 --- /dev/null +++ b/frontend/src/lib/geschichte/utils.ts @@ -0,0 +1,28 @@ +import { formatDate } from '$lib/shared/utils/date'; + +type AuthorSummary = { firstName?: string; lastName?: string; email: string }; +type AuthorView = { displayName: string }; + +/** Format an author name from a list summary (firstName + lastName, falling back to email). */ +export function formatAuthorName(author: AuthorSummary | null | undefined): string { + if (!author) return ''; + const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim(); + return full || author.email || ''; +} + +/** Format an author displayName from a detail view. */ +export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { + return author?.displayName ?? ''; +} + +/** + * Format a publishedAt ISO string to a localised date string. + * Returns null when publishedAt is absent. + */ +export function formatPublishedAt( + publishedAt: string | null | undefined, + style: 'short' | 'long' = 'short' +): string | null { + if (!publishedAt) return null; + return formatDate(publishedAt.slice(0, 10), style); +} -- 2.49.1 From 4184d0775b33505f26701dabec76106d95af8a18 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 23:23:46 +0200 Subject: [PATCH 09/27] fix(journeyinterlude): use i18n aria-label instead of hardcoded German Replaces aria-label="Kuratorennotiz" with m.journey_interlude_aria_label() so screen readers get the correct label in all three supported locales. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/JourneyInterlude.svelte | 4 +++- frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte index 1bd8822c..1a517fd5 100644 --- a/frontend/src/lib/geschichte/JourneyInterlude.svelte +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -1,4 +1,6 @@

    { await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible(); }); - it('has aria-label Kuratorennotiz', async () => { + it('has aria-label from i18n (journey_interlude_aria_label)', async () => { render(JourneyInterlude, { props: { note: 'Notiz' } }); - const el = document.querySelector('[aria-label="Kuratorennotiz"]'); + const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`); expect(el).not.toBeNull(); }); -- 2.49.1 From 91d9dae6fdf3a11f29a71481c4c44969d0b3cf65 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 23:24:10 +0200 Subject: [PATCH 10/27] refactor(geschichtelistrow): use formatAuthorName utility, eliminate inline name computation Replaces the 3-line inline join with the shared formatAuthorName helper from utils.ts. Test switches from CSS class string assertion to getComputedStyle for the badge font-size check. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/geschichte/GeschichteListRow.svelte | 8 ++------ .../src/lib/geschichte/GeschichteListRow.svelte.spec.ts | 6 ++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte index af2ac038..9d5dc5df 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -2,6 +2,7 @@ import { m } from '$lib/paraglide/messages.js'; import { plainExcerpt } from '$lib/shared/utils/extractText'; import { formatDate } from '$lib/shared/utils/date'; +import { formatAuthorName } from './utils'; import type { components } from '$lib/generated/api'; type GeschichteRow = Pick< @@ -18,12 +19,7 @@ const publishedAt = $derived.by(() => { return formatDate(geschichte.publishedAt.slice(0, 10), 'short'); }); -const authorName = $derived.by(() => { - const a = geschichte.author; - if (!a) return ''; - const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim(); - return full || a.email || ''; -}); +const authorName = $derived(formatAuthorName(geschichte.author)); diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts index 97e39175..c0aa748d 100644 --- a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -50,10 +50,12 @@ describe('GeschichteListRow', () => { expect(badge?.tagName.toLowerCase()).toBe('span'); }); - it('badge has text-xs class for WCAG readability', async () => { + it('badge has small font size appropriate for a label', async () => { render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); const badge = document.querySelector('[data-testid="journey-badge"]'); - expect(badge?.className).toContain('text-xs'); + const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize); + expect(fontSize).toBeGreaterThan(0); + expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size }); it('renders author name in meta line', async () => { -- 2.49.1 From 4c24bbb002b4e511e226b8b4193d79970970e07c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 23:24:33 +0200 Subject: [PATCH 11/27] refactor(geschichte): extract delete handler to [id]/+page.svelte, pass via ondelete prop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the confirm-then-delete flow out of StoryReader and JourneyReader into the single [id]/+page.svelte owner. Both reader components gain an optional ondelete prop — the delete button calls ondelete?.() so the handler is opt-in and never duplicated. Tests verify the prop is called on click. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/geschichte/JourneyReader.svelte | 25 ++-------------- .../geschichte/JourneyReader.svelte.spec.ts | 25 ++++++++++++++-- .../src/lib/geschichte/StoryReader.svelte | 27 +++-------------- .../lib/geschichte/StoryReader.svelte.spec.ts | 16 ++++++++-- .../src/routes/geschichten/[id]/+page.svelte | 29 +++++++++++++++---- 5 files changed, 67 insertions(+), 55 deletions(-) diff --git a/frontend/src/lib/geschichte/JourneyReader.svelte b/frontend/src/lib/geschichte/JourneyReader.svelte index fff41e09..e508c723 100644 --- a/frontend/src/lib/geschichte/JourneyReader.svelte +++ b/frontend/src/lib/geschichte/JourneyReader.svelte @@ -1,8 +1,5 @@ {#if introText} @@ -80,7 +61,7 @@ async function handleDelete() {