feat(lesereisen): frontend — type badge on list, Journey reader on detail, type selector on new #752

Open
opened 2026-06-06 16:07:21 +02:00 by marcel · 1 comment
Owner

Goal

Surface the Journey type in the existing Geschichten UI: badge on the list card, a reader view on the detail page, and a type selector when creating a new Geschichte.

Background

Builds on the backend API changes (items array, type field). Run npm run generate:api first to pick up the new TypeScript types. Design spec: docs/superpowers/specs/2026-06-06-lesereisen-design.md.

Prerequisite (HARD BLOCKER): Issue #750 (backend type + items fields) must be merged before this branch is opened. As of 2026-06-08 #750 is NOT mergedGeschichte.java has no type/items fields. Confirm Geschichte.java includes type and items before starting. Do not pick this issue up until #750 closes.

PRECONDITION (#750 contract): #750 reuses Geschichte.body as the Journey intro — there is NO separate intro column. Every body-as-intro reference in this issue depends on this. If #750 introduced a distinct intro field, retarget every g.body?.trim() intro reference here. (Confirm during API regen.)

Non-Goals

  • Journey item editor (adding/reordering items) — see issue #753.

Tasks

1. Regenerate API types

cd frontend && npm run generate:api

Commit the regenerated src/lib/generated/api.ts as the first commit of the branch.

2. GeschichteListRow.svelte — extract + add type badge

The current list on /geschichten renders rows inline in +page.svelte with no dedicated component. Extract the row markup into a new src/lib/geschichte/GeschichteListRow.svelte component, then add the badge there.

Badge: small orange pill showing "REISE" when type === 'JOURNEY'. Stories show no badge (current behaviour). The badge element MUST be a plain <span> — never a nested <a>/<button> (the whole row is already wrapped in one card <a href>; interactive-inside-interactive is invalid HTML + AT confusion). Use text-xs (12px minimum — not text-[10px]) for readability on 60+ audience screens and WCAG SC 1.4.4 compliance. On mobile, badge appears inline next to the title (flex items-center gap-1.5), not in a separate meta column.

Component prop type: type GeschichteRow = Pick<Geschichte, 'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'> — the row doesn't need items, persons, documents, or status. Narrowing the prop type makes the component's contract explicit. (See Review Insights — prop type assignability resolved decision.)

Note: The existing GeschichtenCard.svelte is the person-detail sidebar card — do NOT modify it for this badge.

3. /geschichten/[id] detail page — Journey reader

Conditional rendering on type:

  • STORY: unchanged (rich-text body + document link list as today)
  • JOURNEY: render body as a plaintext intro paragraph if present (non-empty, non-whitespace — use g.body?.trim() guard), then iterate items in position order:
    • Document item: letter title, date, link to /documents/[id]
    • Interlude item (document === null): typographic aside block between letters

New components (FOUR — StoryReader is required, see Review Insights):

src/lib/geschichte/
  StoryReader.svelte          — Story branch (body + persons + documents + delete) extracted from +page.svelte
  JourneyReader.svelte        — renders intro + items list (ol)
  JourneyItemCard.svelte      — one document item with optional annotation
  JourneyInterlude.svelte     — text-only interlude block

Use $derived in +page.svelte:

const isJourney = $derived(g.type === 'JOURNEY');

Then: {#if isJourney}<JourneyReader {geschichte} />{:else}<StoryReader {geschichte} />{/if}

+page.svelte becomes a thin orchestrator: isJourney derived + the if/else + shared header/BackButton. The sanitized = $derived(safeHtml(g.body)), the body <div>, persons section, documents section, author actions and handleDelete ALL move into StoryReader.svelte — see Review Insights.

StoryReader MUST preserve the body <div> class string and the why-not-prose comment VERBATIM ([id]/+page.svelte lines 62-75): font-serif text-lg leading-relaxed text-ink [&_h2]:... [&_ol]:list-decimal [&_ul]:list-disc .... Do NOT substitute prose/max-w-prose (it clamps to ~65ch inside an already-narrow page — unreadable for the senior author; the existing comment documents why). Dropping the [&_ol]:list-decimal/[&_ul]:list-disc modifiers silently removes list markers from existing stories — a regression no current test catches.

4. /geschichten/new — type selector

Extend +page.server.ts to read url.searchParams.get('type') and validate it:

const rawType = url.searchParams.get('type');
const selectedType: 'STORY' | 'JOURNEY' | null =
  rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null;

Return selectedType to the page. The ?type parse must be INDEPENDENT of the existing personId/documentId pre-population (no coupling — they are independent params). The Svelte component branches:

  • No selectedType → show TypeSelector.svelte (new component at src/routes/geschichten/new/TypeSelector.svelte)
  • selectedType === 'STORY' → show existing GeschichteEditor
  • selectedType === 'JOURNEY' → show placeholder ("Journey-Editor folgt in #753") + a return-to-selection control (see Review Insights — exit path)

"Weiter" navigates to /geschichten/new?type=STORY or /geschichten/new?type=JOURNEY.

5. i18n keys

Add translation keys in messages/{de,en,es}.json for all new visible strings:

  • "REISE" (list badge — journey_badge_list) AND "LESEREISE" (detail badge — journey_badge_detail) — two distinct keys, do not collapse them
  • Type selector question, card titles, descriptions
  • "Weiter" button label
  • "andere Auswahl" return-to-selection link on the JOURNEY placeholder (journey_placeholder_back)
  • "Brief öffnen" link text and aria-label pattern (aria-label="Brief vom {date} öffnen") — use Paraglide parameterized message: m.journey_item_open_aria({ date: string })
  • "Brief öffnen" undated fallback aria-label (journey_item_open_aria_undated, no {date} param) — see Review Insights (undated-letter decision)
  • "Diese Lesereise ist noch leer" (empty state — type-neutral, see Review Insights)
  • "Kuratorennotiz" (aria-label for interlude items)
  • "Bitte wähle einen Typ aus, um fortzufahren" (Weiter aria-live hint)

Acceptance criteria

  • GeschichteListRow shows a "REISE" badge for JOURNEY type geschichten (badge uses text-xs, not text-[10px]; badge is a plain <span>, never nested interactive)
  • Story detail page renders the rich-text <div> with {@html safeHtml(body)}, the persons section, and the documents section exactly as before; no REISE/LESEREISE badge appears; no items list appears. Measurable contract: the existing 12 [id]/page.svelte.test.ts tests keep their assertions unmodified; the factory gains type: 'STORY' + items: [], and they must keep passing once +page.svelte delegates to StoryReader.
  • Given type === 'STORY' with items: [], the detail page renders the rich-text body and never the Journey items list or the empty-state message (guards against empty-state leaking into STORY rendering — highest-risk conditional bug). The guard must be isJourney && items.length === 0, never items.length === 0 alone.
  • Given type === undefined (accidental factory default) with items: [], the Story body renders and NO empty-state appears (defends the accidental-default path)
  • Given type === undefined with a NON-empty body and items: [], the Story body renders, NO items list, NO empty-state (the literal accidental-factory-default cell the 12 existing detail tests hit — make it explicit so it stops passing "by accident")
  • Journey detail page renders intro (only when body is a non-empty, non-whitespace string)
  • Given a Journey with body containing only whitespace, no intro paragraph is rendered above the items list
  • Journey detail page renders ordered items (documents and interlude notes)
  • Interlude items (null document) render as a typographic aside visually distinct from letter entries
  • Journey detail with zero items and empty body renders an empty-state message ("Diese Lesereise ist noch leer")
  • Journey detail with non-empty body AND zero items renders BOTH the intro paragraph AND the empty-state message
  • Given a Journey item that is neither a letter nor a note (document === null AND note blank/whitespace), the reader omits the item entirely (defends dangling deleted-document items — EARS omit-rule)
  • Given an interlude or annotation with a blank/whitespace-only note, the block is not rendered (mirror the body whitespace guard)
  • Given a Journey letter item with no documentDate, the reader renders the item with title and link, omits the date, and uses the plain aria-label "Brief öffnen" (no date fragment) — undated archive letters are common in the 1899–1950 corpus (EARS positive rule; see Review Insights)
  • New page shows type selector when no ?type param; selector shows correct editor/placeholder after "Weiter" (precondition: reachable only by BLOG_WRITE users — server redirects others to /geschichten)
  • Given user selects JOURNEY and clicks Weiter, a placeholder UI is displayed (targeting a stable role/testid region) that does not allow submission AND offers a return-to-selection control ("andere Auswahl")
  • TypeSelector is keyboard-startable with NO initial selection: the first card holds tabindex="0" (the others tabindex="-1") on first render so it is Tab-reachable and Arrow-nav can start (see Review Insights — roving-tabindex initial holder)
  • "Weiter" uses aria-disabled="true" (not the disabled HTML attribute) until a type is selected; a visible aria-live="polite" hint explains why
  • E2E "create JOURNEY via API → GET /geschichten/[id] with items" fixture passes (the SOLE regression net for the #750 lazy-init 500; promoted from Test Plan to a hard criterion)
  • Journey badge text contrast ≥ 4.5:1 (normal-text threshold; 12px bold ≠ large text) on the journey tint in BOTH light and dark mode, tool-verified
  • TypeScript types regenerated and no type errors in changed files
  • i18n keys added for all new visible strings in de/en/es (reviewer greps each new key in all three locale files)
  • frontend/src/lib/geschichte/README.md updated with all 4 new components + GeschichteListRow + utils.ts, AND the stale GeschichtenCard "list" claim corrected (merge gate)

Review Insights

Resolved Decisions

"Weiter → Journey" navigation target (this issue's scope): Show a placeholder screen ("Journey-Editor folgt in #753") — keeps this issue self-contained. The placeholder needs a test. Full Journey editor is issue #753. (Options B/404 and C/scope-expand rejected.)

JOURNEY placeholder exit path (DECIDED — Option B): While selectedType === 'JOURNEY', render the #753 placeholder ALONE plus an explicit "andere Auswahl" link back to /geschichten/new (no ?type). Rationale: the placeholder must not be a dead-end for the only users who reach it (BLOG_WRITE authors); Option B decouples the placeholder's layout from the TypeSelector (cleaner than Option A's coupled render) and does not rely on browser-back (Option C's dead-end). One extra link, one i18n key (journey_placeholder_back). The placeholder test asserts both the placeholder region and the presence of the back-to-selection link. (Raised by Elicit; Options A and C rejected.)

Spec supersession — JOURNEY → editor. The design spec (HTML/MD, LLM guide line 693) instructs JOURNEY → goto('/geschichten/new?type=JOURNEY') → "show the Journey editor (from #753)". The issue supersedes this: in this issue's scope JOURNEY → placeholder only. An implementer reading the spec must build the placeholder, not the editor.

"REISE" badge font size: Use text-xs (12px) — overrides the spec's text-[10px]. The 60+ audience and WCAG SC 1.4.4 require a 12px minimum. The badge meaning is fully conveyed at 12px. (Leonie Voss recommendation adopted.) Note: the HTML spec file docs/specs/lesereisen-reader-spec.html still shows text-[10px] AND stock bg-orange-50 text-orange-700 / *-orange-NNN classes — the issue body takes precedence. Ignore every text-[10px] and every *-orange-NNN Tailwind class in the HTML spec; they are superseded.

Empty-state copy (DECIDED — Option A, type-neutral): Use "Diese Lesereise ist noch leer" (NOT "...enthält noch keine Briefe"). Rationale: #753's item editor lets authors add an interlude before attaching letters, so interlude-only is a NORMAL transient authoring/draft-preview state, not degenerate. The "no letters yet" copy would overclaim for an author previewing their own in-progress journey. The neutral copy is true for both the zero-everything case and the interlude-only case. Cost: one i18n key in 3 locales. Keeps the simple isJourney && items.length === 0 guard unchanged. (Raised by Elicit; Option B — keep "...keine Briefe" — rejected.)

JOURNEY detail with zero items: Render the empty-state message ("Diese Lesereise ist noch leer") in the items area. No redirect, no 404 — readers should see a graceful empty state regardless of write permission. (Option B adopted; options C/D rejected.)

Empty-state semantics for interlude-only journeys (DECIDED — Option A guard, type-neutral copy): Keep the isJourney && items.length === 0 guard. A Journey with one interlude and zero letters (items.length === 1) shows its interlude and NO empty state. With the type-neutral copy above, there is no longer a copy overclaim. Do not introduce a "no document items" guard (KISS). (Raised by Elicit; Option B — count document items only — rejected.)

Deleted-document / dangling-item handling (DECIDED — Option A, EARS omit-rule): If a Journey item has document === null AND a blank/whitespace note (neither interlude text nor a letter), then the reader shall omit the item entirely (render nothing for that position). This prevents a dangling deleted-document item being mistaken for an interlude and rendering a blank orange card. First confirm with #750 whether items cascade-delete on Document delete — if they do, this edge cannot occur and the rule is a cheap safety net only. Option B (a "Brief nicht mehr verfügbar" placeholder + third item-type branch + new copy) is rejected for this issue's scope and deferred to a follow-up if it occurs in practice. (Raised by Elicit.)

Undated-letter date + aria-label (DECIDED — Option A, two i18n keys): Many 1899–1950 archive letters are undated, so this WILL fire. EARS positive rule: If a Journey letter item has no documentDate, then the reader shall render the item with title and link, omit the date slot, and use the plain aria-label "Brief öffnen". Use TWO i18n keys — journey_item_open_aria({ date }) for dated letters and journey_item_open_aria_undated() (no param) for undated; the component branches on documentDate presence. Rationale: Paraglide cannot do conditional interpolation cleanly, so a single key with a baked-in optional fragment pushes string assembly into the component (a smell). Two keys cost one extra key × 3 locales and a dead-simple branch. The existing Story documents branch already renders the date only {#if d.documentDate} (line 113) — mirror that conditional. JourneyItemCard.svelte.spec.ts gains an undated case: link present, no date text, aria-label is the plain form. #750 contract dependency: this is buildable only if the item's document object carries documentDate (see Risks). (Raised by Elicit; Option B — single conditional key — rejected.)

Interlude / annotation blank-note guard: Mirror the intro body?.trim() guard on interlude note and annotation note. An interlude with note === '' or ' ' must not render an empty orange-bordered block. Same for a blank annotation note.

Journey intro body conditional: Render the intro paragraph only when body is a non-empty, non-whitespace string. Use g.body?.trim() — handles both null/undefined and whitespace-only strings. Derive a named flag, not inline: const introText = $derived(g.body?.trim() ? g.body : null); then {#if introText}. Keep the whitespace logic out of the template.

Journey intro font size (DECIDED — text-base, 16px): The intro is read-path content seen by EVERY family member, including the 60+ phone-in-daylight audience the brand rules protect with a 16px body minimum — NOT chrome. Use font-serif italic text-base text-ink-2 leading-relaxed. Reserve text-sm (14px) strictly for true metadata (dates, author line). Rationale: 14px italic serif on a phone in daylight is the exact failure mode the brand persona guards against; the slight loss of visual separation from the body is acceptable, and the distinct register is already carried by italic + text-ink-2. This overrides the design spec's text-sm intro. (Raised by Leonie Voss; design-spec text-sm rejected for read-path content.)

?type URL param validation: Validate in +page.server.ts to 'STORY' | 'JOURNEY' | null before use — prevents unexpected strings from reaching the API. No open-redirect risk: goto() targets are relative paths the component constructs, never the raw param. (Security requirement — treat validation tests as security regression tests.) The parse must NOT gate the existing personId/documentId pre-population behind a type check (independent params). Forward note to #753: the create/update DTO must whitelist type server-side and never trust a client-supplied type verbatim (mass-assignment guard) — this issue's ?type validation does NOT protect the eventual write path.

Orange token strategy (REVISED — dark-mode wiring is mandatory, Option A): Use semantic tokens with full three-block dark-mode remapping. layout.css defines colours in THREE blocks: light :root (~line 86), @media (prefers-color-scheme: dark) :root:not([data-theme='light']) (~line 177), and :root[data-theme='dark'] (~line 254). A fixed raw --palette-orange hex exposed via @theme would render the SAME orange in light and dark mode — #FEF0E6 becomes a glaring near-white pill on dark surfaces, failing the calm-dark-mode intent and contrast. Action: define --c-journey-bg, --c-journey-text, --c-journey-border (mirroring how --c-danger is wired) in ALL THREE blocks, then expose via @theme inline { --color-journey-tint: var(--c-journey-bg); --color-journey: var(--c-journey-text); --color-journey-border: var(--c-journey-border); }. Light values: bg #FEF0E6, text #B46820, border #F0C99A (badge #B46820 on #FEF0E6 ≈ 4.6:1 — AA-pass at 12px bold). Dark values: hand-pick a muted dark tint (e.g. ~#3A2A1A fill) with #E8862A-ish text verified ≥4.5:1 with a contrast tool — do NOT eyeball, do NOT ship light-only. The badge text is 12px bold uppercase — this is NOT WCAG large text (large = ≥18.66px bold), so it must clear the 4.5:1 NORMAL-text threshold in BOTH themes, not 3:1. The interlude block (bg-journey-tint border-l-journey-border) and the annotation block reuse these tokens. Stock Tailwind text-orange-700 / bg-orange-50 are forbidden — they bypass the semantic contract and break dark mode. (Raised by Markus Keller + Leonie Voss — light-only / Option B rejected; the read path is Critical and readers are the dark-mode audience.) This must be the second commit of the branch (after API regen), before any component work.

handleSubmit scope in /geschichten/new: Decided — move handleSubmit inside the {#if selectedType === 'STORY'} branch. It is a live client csrfFetch closure (not a use:enhance action), so move the THREE pieces together: the submitting/errorMessage $state, the handler, and the error-banner markup. The JOURNEY placeholder branch must NOT carry the error-alert markup. Structural removal eliminates the dead-code-with-a-live-fetch smell rather than relying on a comment. (Nora Steiner / Felix Brandt.)

GeschichteListRow prop type assignability (resolved): Keep Pick<Geschichte, ...> and have the list +page.server.ts map/return the full Geschichte shape (Option B). Rationale: a clean, explicit component contract decoupled from the list endpoint's shape is worth the trivial allocation cost. Clarification (Round 5): the Pick is a contract nicety at the component boundary ONLY — it is NOT an over-fetch mitigation. geschichten/+page.server.ts (line 36) already returns the raw listResult.data ?? [] (full Geschichte[]), so whatever #750 puts in the entity ships to the page regardless of the component prop. The over-fetch lever (items array on every list row) lives 100% in #750's list() projection — no frontend change can fix it. The component's prop type stays Pick<Geschichte, ...> either way. (Raised by Felix Brandt + Markus Keller; Option A — coupling the component to a list DTO — rejected.) Security bonus: the row never receives document/person IDs, so they can't be accidentally exposed.

items in the list payload (DECIDED — Option A, coordinate with #750): GET /api/geschichten returns the full Geschichte entity; after #750 each list row would ship its entire items array (plus EAGER persons/documents) though the list only needs type. Decision: #750's list() should NOT eager-load items (or should return a slim projection); reserve full items loading for getById(). Rationale: a 50-journey list × ~20 items each serialises ~1000 nested document summaries the list never renders — correctness over accident. This is a #750 task surfaced here. Round-6 framing refinement: the list +page.server.ts (line 18) constrains status: 'PUBLISHED', and it ALREADY eager-loads persons/documents the list never renders. So #750 adding items COMPOUNDS a pre-existing over-fetch, it does not create it. Frame the tech-debt note as: "list projection already ships persons+documents the list never renders; #750 must not add items to that pile" — a concrete bound that strengthens the "slim projection for list()" recommendation. If #750 already ships items in list(), accept it for now but log a tracked tech-debt item. (Raised by Markus Keller; Option B — accept full-entity over-fetch silently — rejected.)

Public list is PUBLISHED-only — empty-state/draft paths are detail-route-only: The list endpoint constrains status: 'PUBLISHED', so a DRAFT journey never appears in the list and the REISE badge is only ever seen for published journeys. Consequently the empty-state and interlude-only draft-preview paths are reachable ONLY via the detail page (an author opening their own draft by direct URL or via the dashboard), never the list. Wire the empty-state E2E through the detail route, not by expecting a draft journey in the list (which would be flaky/empty). Document this in the geschichte README so a future contributor does not wire an empty-state E2E through the list. (Raised by Markus Keller.)

Architecture Guidance

  • StoryReader.svelte is a required 4th new component (the issue's {#if isJourney} split implies it but did not name it). The existing top-level sanitized = $derived(safeHtml(g.body)) in [id]/+page.svelte fires for EVERY Geschichte; leaving it at top level means DOMPurify runs over JOURNEY plaintext pointlessly and the split is a half-extraction. Move the sanitized derived, the body <div> (with its verbatim class string + why-not-prose comment), persons section, documents section, author actions and handleDelete ALL into StoryReader.svelte. +page.svelte becomes a thin orchestrator. (Felix Brandt, Markus Keller, Leonie Voss.)
  • {@html} ownership after extraction (grep gate): [id]/+page.svelte line 74 has {@html sanitized} guarded by eslint-disable-next-line svelte/no-at-html-tags. When StoryReader is extracted, that disable comment AND the safeHtml() import MUST travel together to StoryReader.svelte and must NOT be left dangling in +page.svelte (a dangling disable with no {@html} below is itself an ESLint error / smell). The new Journey components must NOT inherit a copied eslint-disable. After the change, exactly ONE file owns the {@html} + disable. Reviewer gate: grep -rl "no-at-html-tags" src/lib/geschichte src/routes/geschichten ⇒ returns EXACTLY StoryReader.svelte. (Nora Steiner.)
  • GeschichteListRow.svelte must be a new component (extract from +page.svelte list loop) — placed in src/lib/geschichte/. The existing GeschichtenCard.svelte (person-sidebar) must not be modified.
  • TypeSelector.svelte belongs in src/routes/geschichten/new/TypeSelector.svelte (single-use route UI). Caveat: do NOT cite PersonTypeSelector.svelte as a route-collocation precedent — it lives in $lib/person/ and is a live-select. Our TypeSelector is a two-step select → enable Weiter → goto flow, net-new, needs its own tests. Reuse only the radioGroupNav action and the role="radio"/aria-checked/roving-tabindex button skeleton; do not clone the component.
  • radioGroupNav handles ONLY ArrowLeft/ArrowRight (horizontal axis) — NOT Space/Enter selection, NOT ArrowUp/ArrowDown. It also mutates aria-checked via setAttribute directly on the DOM nodes (radioGroupNav.ts lines 21-23). CONVERGENCE RULE: TypeSelector selection state MUST live in a $state rune driven by BOTH the onclick AND the radioGroupNav callback; never depend on the action's setAttribute('aria-checked') surviving a render. If the callback does NOT call the state setter (thinking the action already set aria-checked), Svelte's reactive aria-checked={selected === type} binding overwrites the action's manual attribute on the next flush and selection appears not to stick on arrow-key nav. The fix is exactly the PersonTypeSelector pattern: use:radioGroupNav={(v) => { if (TYPES.includes(v)) select(v); }}, each button carrying a value={type} attribute the action reads. Space/Enter activation comes free from native <button onclick> — wire selection on each card button's onclick. The TypeSelector test asserts ArrowLeft/Right (NOT Up/Down — the grid grid-cols-1 sm:grid-cols-2 layout is vertical on mobile, but reusing radioGroupNav verbatim gives Left/Right only; ARIA APG permits either axis — document it so it's not filed as a bug), AND that selection state survives a subsequent prop-driven re-render. (Felix Brandt.)
  • Roving-tabindex initial holder when NOTHING is selected (DECIDED — Felix Brandt blind-spot): radioGroupNav.handleKeydown early-returns when document.activeElement is not already a [role="radio"] node (current === -1). PersonTypeSelector works only because it ALWAYS has a default selected ('PERSON'), so one card carries tabindex={0} and is Tab-reachable. Our two-step TypeSelector starts with NOTHING selected — if tabindex={selected === type ? 0 : -1} with selected === null, ALL cards are tabindex=-1, NONE is Tab-reachable, none can become activeElement, and ArrowLeft/Right do nothing → keyboard dead-spot. Fix: derive a roving-focus holder that falls back to the first card when nothing is selected. const rovingFocusType = $derived(selected ?? TYPES[0]); then tabindex={type === rovingFocusType ? 0 : -1}. This keeps aria-checked false for all (nothing selected) while making the first card focusable and Arrow-nav-startable. Extract the expression to the named $derived (no logic in template). Add a TypeSelector test: with no selection, first card has tabindex="0", the other tabindex="-1". (Raised by Felix Brandt — not covered by the prior convergence rule.)
  • Do NOT statically import GeschichteEditor in the JOURNEY placeholder branch. An {#if} does not stop the static import from bundling the editor + TipTap for a user who only sees the placeholder. Keep the GeschichteEditor import physically inside the STORY-branch component (or a <StoryCreate> child) so tree-shaking excludes it from the placeholder path. (Felix Brandt.)
  • +page.server.ts at /geschichten/new must read and validate ?type server-side — client-only $state would be lost on page load/refresh. The existing personId/documentId pre-population must still work when selectedType === 'STORY' (independent params, no coupling).
  • type must be a union literal in api.ts, not bare string (commit-1 self-check addition): isJourney silently depends on type being a strict enum, but the detail load function does result.data! with no shape guard. If #750 serialises type as a bare String (no @Schema(enumAsRef)/enum constraint), api.ts emits type?: string, g.type === 'JOURNEY' still works, but ANY drift (lowercase journey, trailing space, null) makes isJourney false and the page silently renders the Story branch over Journey data — a wrong-render, not a crash, that no mocked test catches. Pin the commit-1 self-check to BOTH (a) items property present on the Geschichte schema AND (b) type emitted as a union literal ('STORY' | 'JOURNEY'), not bare string. If (b) fails, STOP and fix #750's @Schema on the type field — do not proceed with a stringly-typed discriminant feeding isJourney. (Raised by Markus Keller.)
  • {#each geschichte.items as item (item.id)} — keyed iteration required in JourneyReader. Items have stable UUIDs; unkeyed iteration causes silent state corruption on reorder. Branch on item.document === null to pick JourneyInterlude vs JourneyItemCard (inline discriminant). Combine with the omit-rule above so a null-document + blank-note item is skipped entirely.
  • formatDate(iso, style) is the EXISTING shared helper ($lib/shared/utils/date.ts), called as formatDate(g.publishedAt.slice(0, 10), 'long' | 'short'). formatPublishedAt does NOT yet exist — do not treat it as if it does. The duplicated logic across the three files is two local closures: authorName(g) and a publishedAt(g) wrapper that does the ?? null + .slice(0,10) + formatDate(_, style) dance. (Felix Brandt — corrects the "signature already exists" framing.)
  • Compute publishedAt once per row. The list's publishedAt(g) closure calls formatDate(g.publishedAt.slice(0,10), 'short') TWICE in the template (line 139: once in {#if}, once in the message arg). In GeschichteListRow, hold the formatted value in ONE $derived per row so it is computed once, not on every reactive read. (Felix Brandt.)
  • Cleanup commit (separate atomic commit, after feature tests are green): extract the two closures to $lib/geschichte/utils.tsformatAuthorName(author) (clean extraction of the duplicated authorName) AND formatPublishedAt(publishedAt, style) (a thin WRAPPER over the existing formatDate: does the ?? null + .slice(0,10) + formatDate(_, style), NOT a re-implementation of date formatting; keep the formatDate import inside the util). Both are duplicated across geschichten/+page.svelte, [id]/+page.svelte, GeschichtenCard.svelte (list/Card use 'short', detail uses 'long' — the style arg is required). Place in geschichte/ domain package, NOT $lib/shared/. Do not extract one and leave its twin.
  • Doc gate: frontend/src/lib/geschichte/README.md currently lists only Editor + Card AND contains a STALE claim (line 20) that GeschichtenCard is "Used in /geschichten (list), dashboard" — it is NOT (the list renders rows inline; GeschichtenCard is only the person-sidebar card). The doc-gate task MUST: (1) correct the GeschichtenCard row, (2) add GeschichteListRow as the actual list row, (3) add the 4 reader components + utils.ts, (4) document that the public list is status=PUBLISHED-only so empty-state/draft-preview paths are detail-route-only. Otherwise the README claims two components own the list. Treat a missing/incorrect README update as a merge blocker. No C4/PUML updates triggered. (Markus Keller.)
  • Confirm with #750 that the backend API returns items pre-sorted by position (SQL ORDER BY position preferred). Front-end sorting is fragile and should be avoided.
  • #750 lazy-init coordination: if #750 makes items a lazy @OneToMany, getById()/list() need @Transactional(readOnly = true) (ADR-022) or serialising items throws LazyInitializationException → 500. The frontend [id]/+page.server.ts does result.data! (raw entity), and [id]/page.svelte.test.ts mocks the data — so it CANNOT catch this. The E2E "create JOURNEY via API → GET-with-items" fixture is the SOLE regression net for this 500-class bug → it is now a hard acceptance criterion, not a nice-to-have. (Markus Keller.)

Audience / Reachability (Requirements)

  • The create path (TypeSelector + JOURNEY placeholder) is reachable only by BLOG_WRITE usersnew/+page.server.ts redirects everyone else to /geschichten. The JOURNEY-placeholder test needs no permission dimension; the dead-handleSubmit concern is low-stakes (only authors reach it).
  • The read path (JourneyReader detail page) is reachable by every logged-in family member (spec LR-2). Read and create paths have different audiences → JourneyReader mobile layout is Critical; TypeSelector mobile layout is Minor.

Security

  • StoryReader keeps {@html safeHtml(g.body)} (DOMPurify) — Story body is TipTap HTML. JourneyReader.svelte, JourneyInterlude.svelte, AND JourneyItemCard.svelte (annotation block): use {g.body} / {note} / {item.note} (Svelte text interpolation, auto-escapes). Never {@html} for Journey plaintext fields — XSS risk (CWE-79). Add comment <!-- plaintext — do NOT use {@html} here --> in all three Journey components.
  • List/card excerpt path is XSS-safe for Journey plaintext, but for a SUBTLE reason that can break. The list calls plainExcerpt(g.body, 150)extractText. In the browser extractText returns textContent (safe). But extractText.ts has an SSR fallback: when DOMParser is undefined (which is how the list renders on FIRST PAINT), it falls back to a regex strip html.replace(/<[^>]*>/g, '') — explicitly NOT a sanitiser per its own docstring. For plaintext Journey bodies this is still safe (no tags; any literal <img onerror> the user typed is regex-stripped to empty, and SvelteKit escapes the SSR output anyway). But the browser-project XSS spec never exercises the SSR regex-fallback path. Add a Node-project assertion: call plainExcerpt('<img src=x onerror="window.__xss=1">') and assert the result contains no onerror= substring. Add the comment <!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} --> at the list/card excerpt site. Confirm the implementer does NOT "improve" the list to show a richer journey preview re-introducing {@html}. (Nora Steiner.)
  • The SSR-fallback XSS test MUST live in the Node .test.ts project, not the browser .svelte.spec.ts project. The regex-fallback branch only fires when DOMParser is genuinely undefined: Vitest's Node project has no DOMParser (fallback fires), but the browser project DOES (would silently take the safe DOMParser branch → false green). Pin the test to the Node tier and add a one-line comment naming WHY it must not move to the browser project. (Nora Steiner.)
  • Add THREE adversarial XSS regression tests (TDD red phase), one per plaintext sink — body (JourneyReader.svelte.spec.ts), annotation note (JourneyItemCard.svelte.spec.ts), interlude note (JourneyInterlude.svelte.spec.ts). Each passes <img src=x onerror="window.__xss=1"> and asserts BOTH window.__xss === undefined AND the literal payload appears as escaped text. Browser project (--project=client, requires real DOM — jsdom {@html} injection is unreliable). PLUS the fourth (SSR) assertion above. "renders as text" alone is insufficient; make them all adversarial.
  • The ?type validation tests are security-grade — name them explicitly (e.g. 'returns selectedType: null for invalid ?type param (security regression)') and treat as permanent. Add a combined-params test: ?type=STORY&personId=p1 must return BOTH selectedType: 'STORY' AND initialPersons: [p1] (proves no coupling). Add two more adversarial cases: ?type=STORY%00JOURNEY (null-byte/encoded) must yield selectedType: null (strict equality rejects it), and a repeated ?type=STORY&type=JOURNEY param must yield 'STORY' (since url.searchParams.get() returns only the FIRST value). These pin that the guard is strict equality, not .startsWith/.includes, and document the repeated-param semantics as intentional. (Nora Steiner.)
  • No new write endpoints — read-only for Journey data. Existing canBlogWrite model applies unchanged.
  • Trust boundary on item.document is data-shape, not auth. A reader sees an item's title/date from the Journey's server-rendered items regardless of per-document access. In this archive every logged-in member has READ_ALL (no doc-level ACLs), so this is NOT an IDOR — but document the assumption so a future per-document-permission feature doesn't silently leak titles through Journey items. No action now. (Nora Steiner.)
  • Content-trust assumption (document, no code action): interlude/annotation note is author-authored free text rendered (text-interpolated, XSS-safe) to EVERY logged-in reader. A BLOG_WRITE author can surface arbitrary curator prose to all readers via these notes. Acceptable in a family archive with a tiny trusted author set — flag as a known assumption, not an accident. (Nora Steiner.)

Accessibility

  • Type selector: wrap both cards in role="radiogroup" with aria-labelledby pointing to the visible "Was möchtest du erstellen?" question text. Each card: role="radio", aria-checked, roving tabindex (0 for the roving-focus holder — see roving-tabindex initial-holder decision — -1 for others). Reuse radioGroupNav for Arrow-key navigation (Left/Right only — see Architecture Guidance); native <button onclick> for Space/Enter selection. Add a test that the radiogroup is correctly named.
  • TypeSelector layout: grid grid-cols-1 gap-4 sm:grid-cols-2 — mobile-first, one card wide on mobile (320px), two side-by-side at 640px+.
  • "Weiter" disabled state: use aria-disabled="true" + tabindex="0" (not the disabled HTML attribute) styled opacity-50 cursor-not-allowed. Pair with aria-live="polite" instructional text ("Bitte wähle einen Typ aus, um fortzufahren").
  • JourneyItemCard: make the WHOLE card a single wrapping <a> covering title + meta + "öffnen" affordance — not a small link at the bottom while the title is dead space. One link → one aria-label="Brief vom {date} öffnen" (or the plain "Brief öffnen" for undated letters — see undated-letter decision), eliminates the duplicate-aria-label problem. flex items-center min-h-[44px], focus-visible:ring-2 focus-visible:ring-focus-ring. (Leonie Voss.)
  • Annotation is a hanging note coupled to its JourneyItemCard, NOT a free-standing sibling <li> (DECIDED — Leonie Voss). Two italic-aside blocks (interlude orange, annotation mint) inside the same <ol list-none> are same-shape and distinguished only by colour — indistinguishable for colour-blind users. POSITION is the strongest non-colour cue: render the annotation visually NESTED inside / butted against its letter card (a hanging note under the letter, ✎ anchored to the card), while the interlude is a full-width BLOCK between letters with a centered section-break glyph. If both render as flat siblings at the same indentation, the glyph alone won't deliver the distinction. (Leonie Voss — supersedes the flat-sibling reading.)
  • Colour-as-sole-distinction redundant-cue assignment: give the interlude (between-letters pause) a section-break glyph/rule whose SHAPE encodes position (e.g. a centered "❦" or "* * *"); give the annotation (note on a specific letter) the "✎" glyph anchored to its letter card. Do NOT put the only icon on the annotation and leave the interlude distinguished by colour alone. (aria-label covers AT only; this is the visual cue.) (Leonie Voss.)
  • Badge hover bleed (verify): the badge sits inside the card-wide <a href> (+page.svelte line 135 <a ... class="block">). On card hover:shadow-md, confirm the badge keeps its own bg-journey-tint background and text-journey colour and does NOT inherit any a:hover colour treatment, in BOTH light and dark mode. No pointer-events-none needed (it's a span). Quick visual check both themes. (Leonie Voss.)
  • Interlude <li> items: add aria-label="Kuratorennotiz". They live INSIDE the same <ol> as document items. The <ol> parent uses list-none (spec: no numbering); screen readers still announce "list, N items".
  • Journey intro typography: font-serif italic text-base text-ink-2 leading-relaxed (16px — see intro font-size decision) — must NOT bleed from Story body classes (font-serif text-lg leading-relaxed text-ink).
  • Mobile inline badge: wrap the <h2> title and badge in a <div class="flex items-center gap-1.5"> — the flex wrapper is the <div>, the <h2> stays the heading element (preserve heading semantics). The badge is a plain <span> (never nested interactive inside the card <a>).

Test Plan (TDD — write failing tests first)

Factory typing is MANDATORY, not polish — without it there is NO red phase. The existing factories are hand-rolled object literals NOT typed against the generated schema, so a missing type/selectedType field is invisible to tsc and the tests pass vacuously. (Sara Holt.)

  • geschichten/[id]/page.svelte.test.ts — has 12 it() blocks (NOT 10 — grep -c "it(" → 12). baseGeschichte uses Record<string, unknown> overrides + inline anonymous types (e.g. author is a hand-written { firstName?; lastName?; email } | null, persons/documents inline literals — NOT typed against the schema). After API regen the 12 tests do NOT fail to compile; they render with type === undefined, isJourney is false, the Story branch renders, and they stay green BY ACCIDENT — masking whether the conditional was wired. Tighten overrides to Partial<components['schemas']['Geschichte']> and add type: 'STORY' as const + items: [] to the base. This will likely surface PRE-EXISTING author-shape type drift (the hand-rolled author may miss id/displayName or have extra-required fields) — budget for fixing that drift in the SAME red commit; run npm run check on the file immediately after tightening and expect new errors unrelated to type. Two of the 12 (persons/documents tests) need their props to flow through <StoryReader {geschichte} /> after extraction — keep their ASSERTIONS unmodified; they catch a dropped prop. Run the existing 12 green before touching the component. Add the explicit type: undefined + non-empty-body cell as a named test.
  • Orchestrator-level placement of the STORY-not-empty-state cases (Sara Holt): the "STORY with items: [] renders body, NO empty-state" and "type: undefined → StoryReader, NO empty-state" criteria belong at the +page.svelte ORCHESTRATOR level (geschichten/[id]/page.svelte.test.ts), NOT inside JourneyReader.svelte.spec.ts. JourneyReader only mounts when isJourney === true; a STORY never mounts it, so handing JourneyReader a STORY input couples the wrong component to the wrong contract. Move those two cases into the detail page.svelte.test.ts (where {#if isJourney} is decided); keep JourneyReader.svelte.spec.ts focused on journey-branch internals only — never hand it a STORY.
  • geschichten/new/page.svelte.test.tsbaseData is a plain literal; mocks $app/navigation (goto etc.) at the top. Adding selectedType will NOT break compilation; the 3–4 editor tests fail at the ASSERTION level (TypeSelector renders instead of the editor when selectedType is undefined) — a behavioural red, not a compile red. Add selectedType: 'STORY' to baseData so those tests render the editor; type baseData against the load return. Add ONE new test with selectedType: null asserting getByRole('radiogroup') → TypeSelector. Assert goto (already mocked) is called with the EXACT ?type=STORY / ?type=JOURNEY URL on "Weiter".
  • geschichten/page.svelte.test.ts (list) — hand-typed literal with no type. The badge {#if g.type === 'JOURNEY'} never fires in existing tests. Test split (Sara Holt — avoid redundant maintenance): the list page.svelte.test.ts asserts ONE integration-level fact (a JOURNEY row in the list shows the badge — proving the page passes type through to the row); the EXHAUSTIVE matrix (STORY/JOURNEY/undefined, span-not-anchor, text-xs) lives in GeschichteListRow.svelte.spec.ts. Do NOT duplicate all three cases in both files. While in this file, fix the pre-existing weak "omits date" test (line ~189): its comment claims it checks the · separator is absent, but the assertion only checks card?.textContent contains 'Anna Schmidt'. Tighten it to assert card?.textContent does NOT contain · — otherwise it passes vacuously for both STORY and JOURNEY rows. (Sara Holt.)
  • GeschichtenCard.svelte.spec.ts (person sidebar) — add a regression guard: render with type: 'JOURNEY' to confirm no badge bleeds through (one test, one line).

New component specs (vitest-browser-svelte, *.svelte.spec.ts):

  • GeschichteListRow.svelte.spec.ts — badge renders for JOURNEY; no badge for STORY; no badge for type: undefined; badge text "REISE"; badge is a <span>; title and meta render
  • JourneyReader.svelte.spec.ts — intro shown/hidden by body content; body: ' ' → no intro; XSS regression (browser project); items in position order; document vs interlude items; annotation shown/hidden; null-document + blank-note item omitted entirely; empty-items state renders message ("Diese Lesereise ist noch leer"); Journey-with-intro-and-empty-items shows BOTH intro AND empty state (separate test). (NOTE: the "STORY + items:[] → renders body" and "type: undefined + non-empty body" cases moved to the orchestrator page.svelte.test.ts — see Sara Holt's relocation above; do NOT place them here.)
  • JourneyItemCard.svelte.spec.ts — title, date, sender→receiver, whole-card <a> href, single aria-label on link, annotation block, blank-note annotation not rendered, XSS-on-note regression, min-h-[44px], "✎" cue present; undated letter → link present, NO date text, aria-label is the plain "Brief öffnen" form (no date fragment)
  • JourneyInterlude.svelte.spec.ts — renders note as plaintext (XSS-on-note regression: inject payload, assert window.__xss === undefined + escaped text); blank/whitespace note → not rendered; aria-label="Kuratorennotiz"; section-break glyph/rule cue present; visually distinct from document items
  • TypeSelector.svelte.spec.ts — both cards render; Weiter aria-disabled/enabled; aria-checked; keyboard ArrowLeft/Right navigation (NOT Up/Down); ArrowRight updates aria-checked AND selection state persists after a re-render; Space/Enter selects (native button); radiogroup correctly named (aria-labelledby); instructional text visible when no type selected; with NO selection, the first card has tabindex="0" and the other tabindex="-1" (roving-focus initial holder — keyboard-startable)

Load function tests (vitest Node, .test.ts):

  • geschichten/new/+page.server.ts — returns selectedType: 'JOURNEY' for ?type=JOURNEY; null for missing param; null for invalid param (e.g. ?type=ADMIN) — named as a security regression; ?type=STORY&personId=p1 returns BOTH selectedType: 'STORY' AND initialPersons: [p1] (no coupling); ?type=STORY%00JOURNEYnull; repeated ?type=STORY&type=JOURNEY'STORY' (first-value semantics, documented as intentional)
  • extractText/plainExcerpt SSR-fallback XSS: call plainExcerpt('<img src=x onerror=...>') under Node, assert no onerror= substring (covers the first-paint regex-fallback path the browser spec misses). Must live in the Node .test.ts project — add a comment naming WHY (the browser project's DOMParser would mask the regex path → false green).

E2E (Playwright):

  • List: REISE badge visible next to a Journey title
  • Detail (Journey): LESEREISE badge, ordered items, interlude blocks, empty-state — mobile layout required (Critical read path); reuse the beforeAll fixture navigated at both 320px and desktop. Wire the empty-state E2E through the DETAIL route (direct URL/dashboard), NOT the list — the list is PUBLISHED-only so a draft journey never appears there.
  • Detail (Story): visually unchanged (regression guard)
  • New page: selector appears, Weiter disabled → enabled → navigates with ?typedesktop required; mobile nice-to-have (Minor)
  • E2E fixture (HARD acceptance criterion): beforeAll creates a JOURNEY-type Geschichte via the API (captures ID) + afterAll teardown, then GETs /geschichten/[id] with items. The E2E user MUST have BLOG_WRITE (the new-page redirect blocks non-writers); confirm before relying on the fixture. This GET-with-items flow is the SOLE guard against the #750 lazy-init 500 (the mocked page.svelte.test.ts cannot catch it).

Commit Order

  1. API regen (api.ts — after #750 merged). PR gate: the api.ts diff must visibly add type and items. Post-regen self-check before committing: confirm BOTH signals — (a) the Geschichte schema gained an items property (grep -q "items" ... scoped to the schema) AND (b) type is emitted as a UNION LITERAL ('STORY' | 'JOURNEY'), not bare string. The items property is the MORE RELIABLE "did #750 land" signal: if #750 serialises type as a bare string (no enum constraint), api.ts compiles fine with type?: string and the JOURNEY grep finds nothing → a false "STALE api.ts" on a correct file. If type is bare string, STOP — fix #750's @Schema on the type field; do not feed a stringly-typed discriminant into isJourney. Verify on-branch provenance: git log --oneline -- frontend/src/lib/generated/api.ts. (Tobias Wendt + Markus Keller.)
  2. Add orange --c-journey-* tokens to ALL THREE theme blocks of layout.css + @theme inline exposure
  3. Update failing tests (TDD red — tighten baseGeschichte/baseData factories to Partial<components['schemas']['Geschichte']>, fix surfaced author-shape drift, add type/selectedType, new failing specs)
  4. Implement components (TDD green) — StoryReader, JourneyReader, JourneyItemCard, JourneyInterlude, GeschichteListRow, TypeSelector
  5. Cleanup (formatAuthorName + formatPublishedAt-wrapper extraction to $lib/geschichte/utils.ts) — separate atomic commit
  6. README update (frontend/src/lib/geschichte/README.md — add 4 components + GeschichteListRow + utils.ts, correct stale GeschichtenCard claim, document PUBLISHED-only list) — can fold into step 4's commits or a final doc commit

DevOps / Process

  • #750 is a HARD BLOCKER, not dev-ergonomics. As of 2026-06-08 Geschichte.java has no type/items — branching today makes generate:api emit an api.ts lacking them, so svelte-check/vitest fail looking like code bugs. Add an explicit blocked-by #750 relationship; do not start the branch until #750 merges to main.
  • Worktree sequence (order matters — install before everything): (1) git pull main, (2) confirm #750 is merged (grep Geschichte.java for type/items), (3) git worktree add ../familienarchiv-lesereisen-752 -b feat/lesereisen-752-frontend, (4) cd ../familienarchiv-lesereisen-752/frontend && npm install (pre-commit npm run lint AND openapi-typescript both need node_modules), (5) start the #750-merged backend dev server explicitly with --spring.profiles.active=dev — the OpenAPI /v3/api-docs endpoint is dev-profile-only; a default-profile or stale JAR silently emits no spec / the pre-#750 schema, (6) npm run generate:api, (7) self-check items-on-Geschichte AND type union-literal, (8) first commit: regenerated api.ts.
  • generate:api is local-only (needs a running dev-profile backend); CI consumes the committed api.ts, so the committed regen is load-bearing.
  • CI E2E backend provenance (Tobias Wendt): unlike the unit/component tiers (which consume the static api.ts), the E2E beforeAll does a LIVE POST /api/geschichten with type: JOURNEY. If CI's backend image/JAR is built from a commit BEFORE #750 merged (stale layer cache, or the E2E job pins a backend tag), the POST will 400/ignore type and the GET-with-items assertion fails confusingly. Confirm the CI E2E job builds the backend from the same merge-base that includes #750 (mirror the local worktree step-2 Geschichte.java check). A stale backend image silently breaks the lazy-init-500 regression net.
  • Pre-commit lint gate on net-new files: the ~5 new *.svelte.spec.ts and the new components must be Prettier-clean and ESLint-clean AT COMMIT TIME (pre-commit gates npm run lint, not test). The XSS specs use a window.__xss global — ESLint may flag no-undef/no-explicit-any on (window as any).__xss. Use a typed declare global augmentation so the pre-commit hook doesn't block the green commit. This bites at commit time, not CI. (Tobias Wendt.)
  • Tag the fast Node-tier security tests as front-line merge gates (Tobias Wendt): the ?type validation load tests and the plainExcerpt SSR-fallback XSS test are pure-Node (<10s tier) and run first/cheapest — make THEM the front-line tripwires so a security regression fails in the first 10s, not after the ~30s browser tier. Keep the browser XSS specs gating too, but the Node security tests are the front line.
  • New browser-mode specs (~5 *.svelte.spec.ts) are the slow CI tier (+~15–30s to the client project) — run only the changed spec file locally (standing rule: never the full suite locally); let CI run the sweep. The XSS regression specs must run in the client (browser) project — confirm the CI client project already runs (the existing *.svelte.spec.ts prove it does) before making the XSS specs merge-gating.
  • No Compose, CI, Renovate, or infrastructure changes needed — pure frontend feature against an unchanged service topology. The only infra-adjacent artifact is the committed api.ts.

Open #750 Contract Questions (escalate BEFORE building the affected component)

These are hard cross-issue dependencies surfaced in review. Resolve with #750 before building the dependent UI; the frontend decisions above are correct ONLY if these hold.

  • Does the #750 item payload include documentDate on the item's document object? The undated-letter date + aria-label spec (decided above, two i18n keys) is buildable ONLY if documentDate is present on the item's document summary. If #750 ships only { id, title } per item document, the date cannot render and the entire "Brief vom {date}" spec is moot → escalate to #750 to add documentDate to the item-document summary (preferred — small, enables the spec) BEFORE building JourneyItemCard; do NOT build the date/aria branch against a field that may not exist. (Raised by Elicit.)
  • Does type serialise as a union literal ('STORY' | 'JOURNEY') or bare string? Confirm at commit-1 self-check (see Commit Order). Bare string is a #750 @Schema gap to fix in #750, not to paper over here. (Markus Keller.)
  • Do Journey items cascade-delete when a Document is deleted? If yes, the dangling-item omit-rule is a cheap safety net only; if no, it is load-bearing. Confirm to size the rule. (Elicit.)
  • Does getById()/list() carry @Transactional(readOnly = true) for lazy items? Required to avoid LazyInitializationException → 500; the E2E GET-with-items fixture is the only net for this. (Markus Keller.)
## Goal Surface the Journey type in the existing Geschichten UI: badge on the list card, a reader view on the detail page, and a type selector when creating a new Geschichte. ## Background Builds on the backend API changes (items array, type field). Run `npm run generate:api` first to pick up the new TypeScript types. Design spec: `docs/superpowers/specs/2026-06-06-lesereisen-design.md`. **Prerequisite (HARD BLOCKER):** Issue #750 (backend `type` + `items` fields) must be merged before this branch is opened. As of 2026-06-08 #750 is **NOT merged** — `Geschichte.java` has no `type`/`items` fields. Confirm `Geschichte.java` includes `type` and `items` before starting. Do not pick this issue up until #750 closes. **PRECONDITION (#750 contract):** #750 reuses `Geschichte.body` as the Journey intro — there is NO separate intro column. Every `body`-as-intro reference in this issue depends on this. If #750 introduced a distinct intro field, retarget every `g.body?.trim()` intro reference here. _(Confirm during API regen.)_ ## Non-Goals - Journey item editor (adding/reordering items) — see issue #753. ## Tasks ### 1. Regenerate API types ```bash cd frontend && npm run generate:api ``` Commit the regenerated `src/lib/generated/api.ts` as the first commit of the branch. ### 2. `GeschichteListRow.svelte` — extract + add type badge The current list on `/geschichten` renders rows inline in `+page.svelte` with no dedicated component. Extract the row markup into a new `src/lib/geschichte/GeschichteListRow.svelte` component, then add the badge there. Badge: small orange pill showing "REISE" when `type === 'JOURNEY'`. Stories show no badge (current behaviour). **The badge element MUST be a plain `<span>` — never a nested `<a>`/`<button>` (the whole row is already wrapped in one card `<a href>`; interactive-inside-interactive is invalid HTML + AT confusion).** Use `text-xs` (12px minimum — not `text-[10px]`) for readability on 60+ audience screens and WCAG SC 1.4.4 compliance. On mobile, badge appears inline next to the title (`flex items-center gap-1.5`), not in a separate meta column. **Component prop type:** `type GeschichteRow = Pick<Geschichte, 'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'>` — the row doesn't need `items`, `persons`, `documents`, or `status`. Narrowing the prop type makes the component's contract explicit. (See Review Insights — prop type assignability resolved decision.) Note: The existing `GeschichtenCard.svelte` is the *person-detail sidebar card* — do NOT modify it for this badge. ### 3. `/geschichten/[id]` detail page — Journey reader Conditional rendering on `type`: - **STORY**: unchanged (rich-text body + document link list as today) - **JOURNEY**: render `body` as a plaintext intro paragraph if present (non-empty, non-whitespace — use `g.body?.trim()` guard), then iterate `items` in position order: - Document item: letter title, date, link to `/documents/[id]` - Interlude item (`document === null`): typographic aside block between letters **New components (FOUR — `StoryReader` is required, see Review Insights):** ``` src/lib/geschichte/ StoryReader.svelte — Story branch (body + persons + documents + delete) extracted from +page.svelte JourneyReader.svelte — renders intro + items list (ol) JourneyItemCard.svelte — one document item with optional annotation JourneyInterlude.svelte — text-only interlude block ``` Use `$derived` in `+page.svelte`: ```svelte const isJourney = $derived(g.type === 'JOURNEY'); ``` Then: `{#if isJourney}<JourneyReader {geschichte} />{:else}<StoryReader {geschichte} />{/if}` `+page.svelte` becomes a thin orchestrator: `isJourney` derived + the if/else + shared header/BackButton. The `sanitized = $derived(safeHtml(g.body))`, the body `<div>`, persons section, documents section, author actions and `handleDelete` ALL move into `StoryReader.svelte` — see Review Insights. **StoryReader MUST preserve the body `<div>` class string and the why-not-prose comment VERBATIM** (`[id]/+page.svelte` lines 62-75): `font-serif text-lg leading-relaxed text-ink [&_h2]:... [&_ol]:list-decimal [&_ul]:list-disc ...`. Do NOT substitute `prose`/`max-w-prose` (it clamps to ~65ch inside an already-narrow page — unreadable for the senior author; the existing comment documents why). Dropping the `[&_ol]:list-decimal`/`[&_ul]:list-disc` modifiers silently removes list markers from existing stories — a regression no current test catches. ### 4. `/geschichten/new` — type selector Extend `+page.server.ts` to read `url.searchParams.get('type')` and validate it: ```typescript const rawType = url.searchParams.get('type'); const selectedType: 'STORY' | 'JOURNEY' | null = rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; ``` Return `selectedType` to the page. The `?type` parse must be INDEPENDENT of the existing `personId`/`documentId` pre-population (no coupling — they are independent params). The Svelte component branches: - No `selectedType` → show `TypeSelector.svelte` (new component at `src/routes/geschichten/new/TypeSelector.svelte`) - `selectedType === 'STORY'` → show existing `GeschichteEditor` - `selectedType === 'JOURNEY'` → show placeholder ("Journey-Editor folgt in #753") + a return-to-selection control (see Review Insights — exit path) "Weiter" navigates to `/geschichten/new?type=STORY` or `/geschichten/new?type=JOURNEY`. ### 5. i18n keys Add translation keys in `messages/{de,en,es}.json` for all new visible strings: - "REISE" (list badge — `journey_badge_list`) AND "LESEREISE" (detail badge — `journey_badge_detail`) — **two distinct keys, do not collapse them** - Type selector question, card titles, descriptions - "Weiter" button label - "andere Auswahl" return-to-selection link on the JOURNEY placeholder (`journey_placeholder_back`) - "Brief öffnen" link text and aria-label pattern (`aria-label="Brief vom {date} öffnen"`) — use Paraglide parameterized message: `m.journey_item_open_aria({ date: string })` - "Brief öffnen" undated fallback aria-label (`journey_item_open_aria_undated`, no `{date}` param) — see Review Insights (undated-letter decision) - "Diese Lesereise ist noch leer" (empty state — type-neutral, see Review Insights) - "Kuratorennotiz" (aria-label for interlude items) - "Bitte wähle einen Typ aus, um fortzufahren" (Weiter aria-live hint) ## Acceptance criteria - [ ] `GeschichteListRow` shows a "REISE" badge for JOURNEY type geschichten (badge uses `text-xs`, not `text-[10px]`; badge is a plain `<span>`, never nested interactive) - [ ] Story detail page renders the rich-text `<div>` with `{@html safeHtml(body)}`, the persons section, and the documents section exactly as before; no REISE/LESEREISE badge appears; no items list appears. **Measurable contract: the existing 12 `[id]/page.svelte.test.ts` tests keep their assertions unmodified; the factory gains `type: 'STORY'` + `items: []`, and they must keep passing once `+page.svelte` delegates to `StoryReader`.** - [ ] **Given `type === 'STORY'` with `items: []`, the detail page renders the rich-text body and never the Journey items list or the empty-state message** (guards against empty-state leaking into STORY rendering — highest-risk conditional bug). The guard must be `isJourney && items.length === 0`, never `items.length === 0` alone. - [ ] **Given `type === undefined` (accidental factory default) with `items: []`, the Story body renders and NO empty-state appears** (defends the accidental-default path) - [ ] **Given `type === undefined` with a NON-empty body and `items: []`, the Story body renders, NO items list, NO empty-state** (the literal accidental-factory-default cell the 12 existing detail tests hit — make it explicit so it stops passing "by accident") - [ ] Journey detail page renders intro (only when body is a non-empty, non-whitespace string) - [ ] **Given a Journey with body containing only whitespace, no intro paragraph is rendered above the items list** - [ ] Journey detail page renders ordered items (documents and interlude notes) - [ ] Interlude items (null document) render as a typographic aside visually distinct from letter entries - [ ] Journey detail with zero items and empty body renders an empty-state message ("Diese Lesereise ist noch leer") - [ ] Journey detail with non-empty body AND zero items renders BOTH the intro paragraph AND the empty-state message - [ ] **Given a Journey item that is neither a letter nor a note (`document === null` AND `note` blank/whitespace), the reader omits the item entirely** (defends dangling deleted-document items — EARS omit-rule) - [ ] **Given an interlude or annotation with a blank/whitespace-only note, the block is not rendered** (mirror the body whitespace guard) - [ ] **Given a Journey letter item with no `documentDate`, the reader renders the item with title and link, omits the date, and uses the plain aria-label `"Brief öffnen"` (no date fragment)** — undated archive letters are common in the 1899–1950 corpus (EARS positive rule; see Review Insights) - [ ] New page shows type selector when no `?type` param; selector shows correct editor/placeholder after "Weiter" (precondition: reachable only by BLOG_WRITE users — server redirects others to `/geschichten`) - [ ] **Given user selects JOURNEY and clicks Weiter, a placeholder UI is displayed (targeting a stable role/testid region) that does not allow submission AND offers a return-to-selection control ("andere Auswahl")** - [ ] **TypeSelector is keyboard-startable with NO initial selection: the first card holds `tabindex="0"` (the others `tabindex="-1"`) on first render so it is Tab-reachable and Arrow-nav can start** (see Review Insights — roving-tabindex initial holder) - [ ] "Weiter" uses `aria-disabled="true"` (not the `disabled` HTML attribute) until a type is selected; a visible `aria-live="polite"` hint explains why - [ ] **E2E "create JOURNEY via API → GET `/geschichten/[id]` with items" fixture passes (the SOLE regression net for the #750 lazy-init 500; promoted from Test Plan to a hard criterion)** - [ ] Journey badge text contrast ≥ 4.5:1 (normal-text threshold; 12px bold ≠ large text) on the journey tint in BOTH light and dark mode, tool-verified - [ ] TypeScript types regenerated and no type errors in changed files - [ ] i18n keys added for all new visible strings in de/en/es (reviewer greps each new key in all three locale files) - [ ] `frontend/src/lib/geschichte/README.md` updated with all 4 new components + `GeschichteListRow` + `utils.ts`, AND the stale `GeschichtenCard` "list" claim corrected (merge gate) ## Review Insights ### Resolved Decisions **"Weiter → Journey" navigation target (this issue's scope):** Show a placeholder screen ("Journey-Editor folgt in #753") — keeps this issue self-contained. The placeholder needs a test. Full Journey editor is issue #753. _(Options B/404 and C/scope-expand rejected.)_ **JOURNEY placeholder exit path (DECIDED — Option B):** While `selectedType === 'JOURNEY'`, render the #753 placeholder ALONE plus an explicit "andere Auswahl" link back to `/geschichten/new` (no `?type`). Rationale: the placeholder must not be a dead-end for the only users who reach it (BLOG_WRITE authors); Option B decouples the placeholder's layout from the TypeSelector (cleaner than Option A's coupled render) and does not rely on browser-back (Option C's dead-end). One extra link, one i18n key (`journey_placeholder_back`). The placeholder test asserts both the placeholder region and the presence of the back-to-selection link. _(Raised by Elicit; Options A and C rejected.)_ **Spec supersession — JOURNEY → editor.** The design spec (HTML/MD, LLM guide line 693) instructs JOURNEY → `goto('/geschichten/new?type=JOURNEY')` → "show the Journey editor (from #753)". The issue **supersedes** this: in this issue's scope JOURNEY → placeholder only. An implementer reading the spec must build the placeholder, not the editor. **"REISE" badge font size:** Use `text-xs` (12px) — overrides the spec's `text-[10px]`. The 60+ audience and WCAG SC 1.4.4 require a 12px minimum. The badge meaning is fully conveyed at 12px. _(Leonie Voss recommendation adopted.)_ **Note: the HTML spec file `docs/specs/lesereisen-reader-spec.html` still shows `text-[10px]` AND stock `bg-orange-50 text-orange-700` / `*-orange-NNN` classes — the issue body takes precedence. Ignore every `text-[10px]` and every `*-orange-NNN` Tailwind class in the HTML spec; they are superseded.** **Empty-state copy (DECIDED — Option A, type-neutral):** Use **"Diese Lesereise ist noch leer"** (NOT "...enthält noch keine Briefe"). Rationale: #753's item editor lets authors add an interlude *before* attaching letters, so interlude-only is a NORMAL transient authoring/draft-preview state, not degenerate. The "no letters yet" copy would overclaim for an author previewing their own in-progress journey. The neutral copy is true for both the zero-everything case and the interlude-only case. Cost: one i18n key in 3 locales. Keeps the simple `isJourney && items.length === 0` guard unchanged. _(Raised by Elicit; Option B — keep "...keine Briefe" — rejected.)_ **JOURNEY detail with zero items:** Render the empty-state message ("Diese Lesereise ist noch leer") in the items area. No redirect, no 404 — readers should see a graceful empty state regardless of write permission. _(Option B adopted; options C/D rejected.)_ **Empty-state semantics for interlude-only journeys (DECIDED — Option A guard, type-neutral copy):** Keep the `isJourney && items.length === 0` guard. A Journey with one interlude and zero letters (`items.length === 1`) shows its interlude and NO empty state. With the type-neutral copy above, there is no longer a copy overclaim. Do not introduce a "no document items" guard (KISS). _(Raised by Elicit; Option B — count document items only — rejected.)_ **Deleted-document / dangling-item handling (DECIDED — Option A, EARS omit-rule):** _If a Journey item has `document === null` AND a blank/whitespace `note` (neither interlude text nor a letter), then the reader shall omit the item entirely (render nothing for that position)._ This prevents a dangling deleted-document item being mistaken for an interlude and rendering a blank orange card. **First confirm with #750 whether items cascade-delete on Document delete — if they do, this edge cannot occur and the rule is a cheap safety net only.** Option B (a "Brief nicht mehr verfügbar" placeholder + third item-type branch + new copy) is rejected for this issue's scope and deferred to a follow-up if it occurs in practice. _(Raised by Elicit.)_ **Undated-letter date + aria-label (DECIDED — Option A, two i18n keys):** Many 1899–1950 archive letters are undated, so this WILL fire. EARS positive rule: _If a Journey letter item has no `documentDate`, then the reader shall render the item with title and link, omit the date slot, and use the plain aria-label `"Brief öffnen"`._ Use TWO i18n keys — `journey_item_open_aria({ date })` for dated letters and `journey_item_open_aria_undated()` (no param) for undated; the component branches on `documentDate` presence. Rationale: Paraglide cannot do conditional interpolation cleanly, so a single key with a baked-in optional fragment pushes string assembly into the component (a smell). Two keys cost one extra key × 3 locales and a dead-simple branch. The existing Story `documents` branch already renders the date only `{#if d.documentDate}` (line 113) — mirror that conditional. `JourneyItemCard.svelte.spec.ts` gains an undated case: link present, no date text, aria-label is the plain form. **#750 contract dependency:** this is buildable only if the item's document object carries `documentDate` (see Risks). _(Raised by Elicit; Option B — single conditional key — rejected.)_ **Interlude / annotation blank-note guard:** Mirror the intro `body?.trim()` guard on interlude `note` and annotation `note`. An interlude with `note === ''` or `' '` must not render an empty orange-bordered block. Same for a blank annotation note. **Journey intro `body` conditional:** Render the intro paragraph only when `body` is a non-empty, non-whitespace string. Use `g.body?.trim()` — handles both `null`/`undefined` and whitespace-only strings. Derive a named flag, not inline: `const introText = $derived(g.body?.trim() ? g.body : null);` then `{#if introText}`. Keep the whitespace logic out of the template. **Journey intro font size (DECIDED — `text-base`, 16px):** The intro is read-path content seen by EVERY family member, including the 60+ phone-in-daylight audience the brand rules protect with a 16px body minimum — NOT chrome. Use `font-serif italic text-base text-ink-2 leading-relaxed`. Reserve `text-sm` (14px) strictly for true metadata (dates, author line). Rationale: 14px italic serif on a phone in daylight is the exact failure mode the brand persona guards against; the slight loss of visual separation from the body is acceptable, and the distinct register is already carried by `italic` + `text-ink-2`. This overrides the design spec's `text-sm` intro. _(Raised by Leonie Voss; design-spec `text-sm` rejected for read-path content.)_ **`?type` URL param validation:** Validate in `+page.server.ts` to `'STORY' | 'JOURNEY' | null` before use — prevents unexpected strings from reaching the API. No open-redirect risk: `goto()` targets are relative paths the component constructs, never the raw param. _(Security requirement — treat validation tests as security regression tests.)_ The parse must NOT gate the existing `personId`/`documentId` pre-population behind a type check (independent params). **Forward note to #753:** the create/update DTO must whitelist `type` server-side and never trust a client-supplied `type` verbatim (mass-assignment guard) — this issue's `?type` validation does NOT protect the eventual write path. **Orange token strategy (REVISED — dark-mode wiring is mandatory, Option A):** Use semantic tokens with full three-block dark-mode remapping. `layout.css` defines colours in **THREE** blocks: light `:root` (~line 86), `@media (prefers-color-scheme: dark) :root:not([data-theme='light'])` (~line 177), and `:root[data-theme='dark']` (~line 254). A fixed raw `--palette-orange` hex exposed via `@theme` would render the SAME orange in light and dark mode — `#FEF0E6` becomes a glaring near-white pill on dark surfaces, failing the calm-dark-mode intent and contrast. **Action: define `--c-journey-bg`, `--c-journey-text`, `--c-journey-border` (mirroring how `--c-danger` is wired) in ALL THREE blocks, then expose via `@theme inline { --color-journey-tint: var(--c-journey-bg); --color-journey: var(--c-journey-text); --color-journey-border: var(--c-journey-border); }`.** Light values: bg `#FEF0E6`, text `#B46820`, border `#F0C99A` (badge `#B46820` on `#FEF0E6` ≈ 4.6:1 — AA-pass at 12px bold). Dark values: hand-pick a muted dark tint (e.g. `~#3A2A1A` fill) with `#E8862A`-ish text verified ≥4.5:1 with a contrast tool — do NOT eyeball, do NOT ship light-only. **The badge text is 12px bold uppercase — this is NOT WCAG large text (large = ≥18.66px bold), so it must clear the 4.5:1 NORMAL-text threshold in BOTH themes, not 3:1.** The interlude block (`bg-journey-tint border-l-journey-border`) and the annotation block reuse these tokens. Stock Tailwind `text-orange-700` / `bg-orange-50` are forbidden — they bypass the semantic contract and break dark mode. _(Raised by Markus Keller + Leonie Voss — light-only / Option B rejected; the read path is Critical and readers are the dark-mode audience.)_ **This must be the second commit of the branch** (after API regen), before any component work. **`handleSubmit` scope in `/geschichten/new`:** **Decided — move `handleSubmit` inside the `{#if selectedType === 'STORY'}` branch.** It is a live client `csrfFetch` closure (not a `use:enhance` action), so move the THREE pieces together: the `submitting`/`errorMessage` `$state`, the handler, and the error-banner markup. The JOURNEY placeholder branch must NOT carry the error-alert markup. Structural removal eliminates the dead-code-with-a-live-fetch smell rather than relying on a comment. _(Nora Steiner / Felix Brandt.)_ **`GeschichteListRow` prop type assignability (resolved):** **Keep `Pick<Geschichte, ...>` and have the list `+page.server.ts` map/return the full `Geschichte` shape (Option B).** Rationale: a clean, explicit component contract decoupled from the list endpoint's shape is worth the trivial allocation cost. **Clarification (Round 5):** the `Pick` is a contract nicety at the component boundary ONLY — it is NOT an over-fetch mitigation. `geschichten/+page.server.ts` (line 36) already returns the raw `listResult.data ?? []` (full `Geschichte[]`), so whatever #750 puts in the entity ships to the page regardless of the component prop. The over-fetch lever (`items` array on every list row) lives 100% in #750's `list()` projection — no frontend change can fix it. The component's prop type stays `Pick<Geschichte, ...>` either way. _(Raised by Felix Brandt + Markus Keller; Option A — coupling the component to a list DTO — rejected.)_ Security bonus: the row never receives `document`/`person` IDs, so they can't be accidentally exposed. **`items` in the list payload (DECIDED — Option A, coordinate with #750):** `GET /api/geschichten` returns the full `Geschichte` entity; after #750 each list row would ship its entire `items` array (plus EAGER `persons`/`documents`) though the list only needs `type`. **Decision: #750's `list()` should NOT eager-load `items` (or should return a slim projection); reserve full `items` loading for `getById()`.** Rationale: a 50-journey list × ~20 items each serialises ~1000 nested document summaries the list never renders — correctness over accident. **This is a #750 task surfaced here.** Round-6 framing refinement: the list `+page.server.ts` (line 18) constrains `status: 'PUBLISHED'`, and it ALREADY eager-loads `persons`/`documents` the list never renders. So #750 adding `items` COMPOUNDS a pre-existing over-fetch, it does not create it. Frame the tech-debt note as: "list projection already ships persons+documents the list never renders; #750 must not add items to that pile" — a concrete bound that strengthens the "slim projection for `list()`" recommendation. If #750 already ships `items` in `list()`, accept it for now but log a tracked tech-debt item. _(Raised by Markus Keller; Option B — accept full-entity over-fetch silently — rejected.)_ **Public list is PUBLISHED-only — empty-state/draft paths are detail-route-only:** The list endpoint constrains `status: 'PUBLISHED'`, so a DRAFT journey never appears in the list and the REISE badge is only ever seen for published journeys. Consequently the empty-state and interlude-only draft-preview paths are reachable ONLY via the **detail** page (an author opening their own draft by direct URL or via the dashboard), never the list. **Wire the empty-state E2E through the detail route**, not by expecting a draft journey in the list (which would be flaky/empty). Document this in the geschichte README so a future contributor does not wire an empty-state E2E through the list. _(Raised by Markus Keller.)_ ### Architecture Guidance - **`StoryReader.svelte` is a required 4th new component** (the issue's `{#if isJourney}` split implies it but did not name it). The existing top-level `sanitized = $derived(safeHtml(g.body))` in `[id]/+page.svelte` fires for EVERY Geschichte; leaving it at top level means DOMPurify runs over JOURNEY plaintext pointlessly and the split is a half-extraction. Move the `sanitized` derived, the body `<div>` (with its verbatim class string + why-not-prose comment), persons section, documents section, author actions and `handleDelete` ALL into `StoryReader.svelte`. `+page.svelte` becomes a thin orchestrator. _(Felix Brandt, Markus Keller, Leonie Voss.)_ - **`{@html}` ownership after extraction (grep gate):** `[id]/+page.svelte` line 74 has `{@html sanitized}` guarded by `eslint-disable-next-line svelte/no-at-html-tags`. When `StoryReader` is extracted, that disable comment AND the `safeHtml()` import MUST travel together to `StoryReader.svelte` and must NOT be left dangling in `+page.svelte` (a dangling disable with no `{@html}` below is itself an ESLint error / smell). The new Journey components must NOT inherit a copied `eslint-disable`. After the change, exactly ONE file owns the `{@html}` + disable. Reviewer gate: `grep -rl "no-at-html-tags" src/lib/geschichte src/routes/geschichten` ⇒ returns EXACTLY `StoryReader.svelte`. _(Nora Steiner.)_ - `GeschichteListRow.svelte` must be a new component (extract from `+page.svelte` list loop) — placed in `src/lib/geschichte/`. The existing `GeschichtenCard.svelte` (person-sidebar) must not be modified. - `TypeSelector.svelte` belongs in `src/routes/geschichten/new/TypeSelector.svelte` (single-use route UI). **Caveat:** do NOT cite `PersonTypeSelector.svelte` as a route-collocation precedent — it lives in `$lib/person/` and is a *live-select*. Our TypeSelector is a **two-step** select → enable Weiter → `goto` flow, net-new, needs its own tests. Reuse only the `radioGroupNav` action and the `role="radio"`/`aria-checked`/roving-`tabindex` button skeleton; do not clone the component. - **`radioGroupNav` handles ONLY ArrowLeft/ArrowRight (horizontal axis) — NOT Space/Enter selection, NOT ArrowUp/ArrowDown.** It also mutates `aria-checked` via `setAttribute` directly on the DOM nodes (`radioGroupNav.ts` lines 21-23). **CONVERGENCE RULE: TypeSelector selection state MUST live in a `$state` rune driven by BOTH the `onclick` AND the `radioGroupNav` callback; never depend on the action's `setAttribute('aria-checked')` surviving a render.** If the callback does NOT call the state setter (thinking the action already set `aria-checked`), Svelte's reactive `aria-checked={selected === type}` binding overwrites the action's manual attribute on the next flush and selection appears not to stick on arrow-key nav. The fix is exactly the `PersonTypeSelector` pattern: `use:radioGroupNav={(v) => { if (TYPES.includes(v)) select(v); }}`, each button carrying a `value={type}` attribute the action reads. **Space/Enter activation comes free from native `<button onclick>`** — wire selection on each card button's `onclick`. The TypeSelector test asserts ArrowLeft/Right (NOT Up/Down — the `grid grid-cols-1 sm:grid-cols-2` layout is vertical on mobile, but reusing `radioGroupNav` verbatim gives Left/Right only; ARIA APG permits either axis — document it so it's not filed as a bug), AND that selection state survives a subsequent prop-driven re-render. _(Felix Brandt.)_ - **Roving-tabindex initial holder when NOTHING is selected (DECIDED — Felix Brandt blind-spot):** `radioGroupNav.handleKeydown` early-returns when `document.activeElement` is not already a `[role="radio"]` node (`current === -1`). `PersonTypeSelector` works only because it ALWAYS has a default `selected` (`'PERSON'`), so one card carries `tabindex={0}` and is Tab-reachable. Our two-step TypeSelector starts with NOTHING selected — if `tabindex={selected === type ? 0 : -1}` with `selected === null`, ALL cards are `tabindex=-1`, NONE is Tab-reachable, none can become `activeElement`, and ArrowLeft/Right do nothing → keyboard dead-spot. **Fix: derive a roving-focus holder that falls back to the first card when nothing is selected.** `const rovingFocusType = $derived(selected ?? TYPES[0]);` then `tabindex={type === rovingFocusType ? 0 : -1}`. This keeps `aria-checked` false for all (nothing *selected*) while making the first card *focusable* and Arrow-nav-startable. Extract the expression to the named `$derived` (no logic in template). Add a TypeSelector test: with no selection, first card has `tabindex="0"`, the other `tabindex="-1"`. _(Raised by Felix Brandt — not covered by the prior convergence rule.)_ - **Do NOT statically `import GeschichteEditor` in the JOURNEY placeholder branch.** An `{#if}` does not stop the static `import` from bundling the editor + TipTap for a user who only sees the placeholder. Keep the `GeschichteEditor` import physically inside the STORY-branch component (or a `<StoryCreate>` child) so tree-shaking excludes it from the placeholder path. _(Felix Brandt.)_ - `+page.server.ts` at `/geschichten/new` must read and validate `?type` server-side — client-only `$state` would be lost on page load/refresh. The existing `personId`/`documentId` pre-population must still work when `selectedType === 'STORY'` (independent params, no coupling). - **`type` must be a union literal in `api.ts`, not bare `string` (commit-1 self-check addition):** `isJourney` silently depends on `type` being a strict enum, but the detail load function does `result.data!` with no shape guard. If #750 serialises `type` as a bare `String` (no `@Schema(enumAsRef)`/enum constraint), `api.ts` emits `type?: string`, `g.type === 'JOURNEY'` still works, but ANY drift (lowercase `journey`, trailing space, null) makes `isJourney` false and the page silently renders the Story branch over Journey data — a wrong-render, not a crash, that no mocked test catches. **Pin the commit-1 self-check to BOTH (a) `items` property present on the `Geschichte` schema AND (b) `type` emitted as a union literal (`'STORY' | 'JOURNEY'`), not bare `string`.** If (b) fails, STOP and fix #750's `@Schema` on the `type` field — do not proceed with a stringly-typed discriminant feeding `isJourney`. _(Raised by Markus Keller.)_ - `{#each geschichte.items as item (item.id)}` — keyed iteration required in `JourneyReader`. Items have stable UUIDs; unkeyed iteration causes silent state corruption on reorder. Branch on `item.document === null` to pick `JourneyInterlude` vs `JourneyItemCard` (inline discriminant). Combine with the omit-rule above so a null-document + blank-note item is skipped entirely. - **`formatDate(iso, style)` is the EXISTING shared helper** (`$lib/shared/utils/date.ts`), called as `formatDate(g.publishedAt.slice(0, 10), 'long' | 'short')`. `formatPublishedAt` does NOT yet exist — do not treat it as if it does. The duplicated logic across the three files is two *local closures*: `authorName(g)` and a `publishedAt(g)` wrapper that does the `?? null` + `.slice(0,10)` + `formatDate(_, style)` dance. _(Felix Brandt — corrects the "signature already exists" framing.)_ - **Compute `publishedAt` once per row.** The list's `publishedAt(g)` closure calls `formatDate(g.publishedAt.slice(0,10), 'short')` TWICE in the template (line 139: once in `{#if}`, once in the message arg). In `GeschichteListRow`, hold the formatted value in ONE `$derived` per row so it is computed once, not on every reactive read. _(Felix Brandt.)_ - **Cleanup commit (separate atomic commit, after feature tests are green):** extract the two **closures** to `$lib/geschichte/utils.ts` — `formatAuthorName(author)` (clean extraction of the duplicated `authorName`) AND `formatPublishedAt(publishedAt, style)` (a thin WRAPPER over the existing `formatDate`: does the `?? null` + `.slice(0,10)` + `formatDate(_, style)`, NOT a re-implementation of date formatting; keep the `formatDate` import inside the util). Both are duplicated across `geschichten/+page.svelte`, `[id]/+page.svelte`, `GeschichtenCard.svelte` (list/Card use `'short'`, detail uses `'long'` — the `style` arg is required). Place in `geschichte/` domain package, NOT `$lib/shared/`. Do not extract one and leave its twin. - **Doc gate:** `frontend/src/lib/geschichte/README.md` currently lists only Editor + Card AND contains a STALE claim (line 20) that `GeschichtenCard` is "Used in `/geschichten` (list), dashboard" — it is NOT (the list renders rows inline; `GeschichtenCard` is only the person-sidebar card). The doc-gate task MUST: (1) **correct** the `GeschichtenCard` row, (2) **add** `GeschichteListRow` as the actual list row, (3) **add** the 4 reader components + `utils.ts`, (4) **document** that the public list is `status=PUBLISHED`-only so empty-state/draft-preview paths are detail-route-only. Otherwise the README claims two components own the list. Treat a missing/incorrect README update as a merge blocker. No C4/PUML updates triggered. _(Markus Keller.)_ - Confirm with #750 that the backend API returns `items` pre-sorted by `position` (SQL `ORDER BY position` preferred). Front-end sorting is fragile and should be avoided. - **#750 lazy-init coordination:** if #750 makes `items` a lazy `@OneToMany`, `getById()`/`list()` need `@Transactional(readOnly = true)` (ADR-022) or serialising `items` throws `LazyInitializationException` → 500. The frontend `[id]/+page.server.ts` does `result.data!` (raw entity), and `[id]/page.svelte.test.ts` mocks the data — so it CANNOT catch this. The E2E "create JOURNEY via API → GET-with-items" fixture is the SOLE regression net for this 500-class bug → it is now a hard acceptance criterion, not a nice-to-have. _(Markus Keller.)_ ### Audience / Reachability (Requirements) - The **create path** (TypeSelector + JOURNEY placeholder) is reachable **only by BLOG_WRITE users** — `new/+page.server.ts` redirects everyone else to `/geschichten`. The JOURNEY-placeholder test needs no permission dimension; the dead-`handleSubmit` concern is low-stakes (only authors reach it). - The **read path** (JourneyReader detail page) is reachable by **every logged-in family member** (spec LR-2). Read and create paths have different audiences → JourneyReader mobile layout is Critical; TypeSelector mobile layout is Minor. ### Security - `StoryReader` keeps `{@html safeHtml(g.body)}` (DOMPurify) — Story body is TipTap HTML. `JourneyReader.svelte`, `JourneyInterlude.svelte`, AND `JourneyItemCard.svelte` (annotation block): use `{g.body}` / `{note}` / `{item.note}` (Svelte text interpolation, auto-escapes). **Never `{@html}`** for Journey plaintext fields — XSS risk (CWE-79). Add comment `<!-- plaintext — do NOT use {@html} here -->` in **all three** Journey components. - **List/card excerpt path is XSS-safe for Journey plaintext, but for a SUBTLE reason that can break.** The list calls `plainExcerpt(g.body, 150)` → `extractText`. In the browser `extractText` returns `textContent` (safe). But `extractText.ts` has an **SSR fallback**: when `DOMParser` is undefined (which is how the list renders on FIRST PAINT), it falls back to a regex strip `html.replace(/<[^>]*>/g, '')` — explicitly NOT a sanitiser per its own docstring. For plaintext Journey bodies this is still safe (no tags; any literal `<img onerror>` the user typed is regex-stripped to empty, and SvelteKit escapes the SSR output anyway). **But the browser-project XSS spec never exercises the SSR regex-fallback path.** Add a Node-project assertion: call `plainExcerpt('<img src=x onerror="window.__xss=1">')` and assert the result contains no `onerror=` substring. Add the comment `<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->` at the list/card excerpt site. Confirm the implementer does NOT "improve" the list to show a richer journey preview re-introducing `{@html}`. _(Nora Steiner.)_ - **The SSR-fallback XSS test MUST live in the Node `.test.ts` project, not the browser `.svelte.spec.ts` project.** The regex-fallback branch only fires when `DOMParser` is genuinely `undefined`: Vitest's Node project has no `DOMParser` (fallback fires), but the browser project DOES (would silently take the safe DOMParser branch → false green). Pin the test to the Node tier and add a one-line comment naming WHY it must not move to the browser project. _(Nora Steiner.)_ - **Add THREE adversarial XSS regression tests (TDD red phase), one per plaintext sink** — body (`JourneyReader.svelte.spec.ts`), annotation note (`JourneyItemCard.svelte.spec.ts`), interlude note (`JourneyInterlude.svelte.spec.ts`). Each passes `<img src=x onerror="window.__xss=1">` and asserts BOTH `window.__xss === undefined` AND the literal payload appears as escaped text. Browser project (`--project=client`, requires real DOM — jsdom `{@html}` injection is unreliable). PLUS the fourth (SSR) assertion above. "renders as text" alone is insufficient; make them all adversarial. - The `?type` validation tests are security-grade — name them explicitly (e.g. `'returns selectedType: null for invalid ?type param (security regression)'`) and treat as permanent. Add a combined-params test: `?type=STORY&personId=p1` must return BOTH `selectedType: 'STORY'` AND `initialPersons: [p1]` (proves no coupling). **Add two more adversarial cases:** `?type=STORY%00JOURNEY` (null-byte/encoded) must yield `selectedType: null` (strict equality rejects it), and a repeated `?type=STORY&type=JOURNEY` param must yield `'STORY'` (since `url.searchParams.get()` returns only the FIRST value). These pin that the guard is strict equality, not `.startsWith`/`.includes`, and document the repeated-param semantics as intentional. _(Nora Steiner.)_ - No new write endpoints — read-only for Journey data. Existing `canBlogWrite` model applies unchanged. - **Trust boundary on `item.document` is data-shape, not auth.** A reader sees an item's title/date from the Journey's server-rendered `items` regardless of per-document access. In this archive every logged-in member has `READ_ALL` (no doc-level ACLs), so this is NOT an IDOR — but document the assumption so a future per-document-permission feature doesn't silently leak titles through Journey items. No action now. _(Nora Steiner.)_ - **Content-trust assumption (document, no code action):** interlude/annotation `note` is author-authored free text rendered (text-interpolated, XSS-safe) to EVERY logged-in reader. A BLOG_WRITE author can surface arbitrary curator prose to all readers via these notes. Acceptable in a family archive with a tiny trusted author set — flag as a known assumption, not an accident. _(Nora Steiner.)_ ### Accessibility - Type selector: wrap both cards in `role="radiogroup"` with `aria-labelledby` pointing to the visible "Was möchtest du erstellen?" question text. Each card: `role="radio"`, `aria-checked`, roving `tabindex` (0 for the roving-focus holder — see roving-tabindex initial-holder decision — -1 for others). Reuse `radioGroupNav` for Arrow-key navigation (Left/Right only — see Architecture Guidance); native `<button onclick>` for Space/Enter selection. Add a test that the radiogroup is correctly named. - TypeSelector layout: `grid grid-cols-1 gap-4 sm:grid-cols-2` — mobile-first, one card wide on mobile (320px), two side-by-side at 640px+. - "Weiter" disabled state: use `aria-disabled="true"` + `tabindex="0"` (not the `disabled` HTML attribute) styled `opacity-50 cursor-not-allowed`. Pair with `aria-live="polite"` instructional text ("Bitte wähle einen Typ aus, um fortzufahren"). - **`JourneyItemCard`: make the WHOLE card a single wrapping `<a>`** covering title + meta + "öffnen" affordance — not a small link at the bottom while the title is dead space. One link → one `aria-label="Brief vom {date} öffnen"` (or the plain `"Brief öffnen"` for undated letters — see undated-letter decision), eliminates the duplicate-aria-label problem. `flex items-center min-h-[44px]`, `focus-visible:ring-2 focus-visible:ring-focus-ring`. _(Leonie Voss.)_ - **Annotation is a hanging note coupled to its `JourneyItemCard`, NOT a free-standing sibling `<li>` (DECIDED — Leonie Voss).** Two italic-aside blocks (interlude orange, annotation mint) inside the same `<ol list-none>` are same-shape and distinguished only by colour — indistinguishable for colour-blind users. POSITION is the strongest non-colour cue: render the annotation visually NESTED inside / butted against its letter card (a hanging note under the letter, ✎ anchored to the card), while the interlude is a full-width BLOCK between letters with a centered section-break glyph. If both render as flat siblings at the same indentation, the glyph alone won't deliver the distinction. _(Leonie Voss — supersedes the flat-sibling reading.)_ - **Colour-as-sole-distinction redundant-cue assignment:** give the **interlude** (between-letters pause) a section-break glyph/rule whose SHAPE encodes position (e.g. a centered "❦" or "* * *"); give the **annotation** (note on a specific letter) the "✎" glyph anchored to its letter card. Do NOT put the only icon on the annotation and leave the interlude distinguished by colour alone. (`aria-label` covers AT only; this is the visual cue.) _(Leonie Voss.)_ - **Badge hover bleed (verify):** the badge sits inside the card-wide `<a href>` (`+page.svelte` line 135 `<a ... class="block">`). On card `hover:shadow-md`, confirm the badge keeps its own `bg-journey-tint` background and `text-journey` colour and does NOT inherit any `a:hover` colour treatment, in BOTH light and dark mode. No `pointer-events-none` needed (it's a span). Quick visual check both themes. _(Leonie Voss.)_ - Interlude `<li>` items: add `aria-label="Kuratorennotiz"`. They live INSIDE the same `<ol>` as document items. The `<ol>` parent uses `list-none` (spec: no numbering); screen readers still announce "list, N items". - Journey intro typography: `font-serif italic text-base text-ink-2 leading-relaxed` (16px — see intro font-size decision) — must NOT bleed from Story body classes (`font-serif text-lg leading-relaxed text-ink`). - Mobile inline badge: wrap the `<h2>` title and badge in a `<div class="flex items-center gap-1.5">` — the flex wrapper is the `<div>`, the `<h2>` stays the heading element (preserve heading semantics). The badge is a plain `<span>` (never nested interactive inside the card `<a>`). ### Test Plan (TDD — write failing tests first) **Factory typing is MANDATORY, not polish — without it there is NO red phase.** The existing factories are hand-rolled object literals NOT typed against the generated schema, so a missing `type`/`selectedType` field is invisible to tsc and the tests pass vacuously. _(Sara Holt.)_ - `geschichten/[id]/page.svelte.test.ts` — has **12** `it()` blocks (NOT 10 — `grep -c "it(" → 12`). `baseGeschichte` uses `Record<string, unknown>` overrides + inline anonymous types (e.g. `author` is a hand-written `{ firstName?; lastName?; email } | null`, `persons`/`documents` inline literals — NOT typed against the schema). After API regen the 12 tests do NOT fail to compile; they render with `type === undefined`, `isJourney` is `false`, the Story branch renders, and they stay green BY ACCIDENT — masking whether the conditional was wired. **Tighten overrides to `Partial<components['schemas']['Geschichte']>`** and add `type: 'STORY' as const` + `items: []` to the base. **This will likely surface PRE-EXISTING `author`-shape type drift** (the hand-rolled author may miss `id`/`displayName` or have extra-required fields) — budget for fixing that drift in the SAME red commit; run `npm run check` on the file immediately after tightening and expect new errors unrelated to `type`. Two of the 12 (persons/documents tests) need their props to flow through `<StoryReader {geschichte} />` after extraction — keep their ASSERTIONS unmodified; they catch a dropped prop. Run the existing 12 green before touching the component. Add the explicit `type: undefined` + non-empty-body cell as a named test. - **Orchestrator-level placement of the STORY-not-empty-state cases (Sara Holt):** the "STORY with `items: []` renders body, NO empty-state" and "`type: undefined` → StoryReader, NO empty-state" criteria belong at the `+page.svelte` ORCHESTRATOR level (`geschichten/[id]/page.svelte.test.ts`), NOT inside `JourneyReader.svelte.spec.ts`. `JourneyReader` only mounts when `isJourney === true`; a STORY never mounts it, so handing `JourneyReader` a STORY input couples the wrong component to the wrong contract. **Move those two cases into the detail `page.svelte.test.ts` (where `{#if isJourney}` is decided); keep `JourneyReader.svelte.spec.ts` focused on journey-branch internals only — never hand it a STORY.** - `geschichten/new/page.svelte.test.ts` — `baseData` is a plain literal; mocks `$app/navigation` (`goto` etc.) at the top. Adding `selectedType` will NOT break compilation; the 3–4 editor tests fail at the ASSERTION level (TypeSelector renders instead of the editor when `selectedType` is undefined) — a behavioural red, not a compile red. Add `selectedType: 'STORY'` to `baseData` so those tests render the editor; type `baseData` against the load return. Add ONE new test with `selectedType: null` asserting `getByRole('radiogroup')` → TypeSelector. **Assert `goto` (already mocked) is called with the EXACT `?type=STORY` / `?type=JOURNEY` URL on "Weiter".** - `geschichten/page.svelte.test.ts` (list) — hand-typed literal with no `type`. The badge `{#if g.type === 'JOURNEY'}` never fires in existing tests. **Test split (Sara Holt — avoid redundant maintenance):** the list `page.svelte.test.ts` asserts ONE integration-level fact (a JOURNEY row in the list shows the badge — proving the page passes `type` through to the row); the EXHAUSTIVE matrix (STORY/JOURNEY/undefined, span-not-anchor, text-xs) lives in `GeschichteListRow.svelte.spec.ts`. Do NOT duplicate all three cases in both files. **While in this file, fix the pre-existing weak "omits date" test (line ~189): its comment claims it checks the `·` separator is absent, but the assertion only checks `card?.textContent` contains `'Anna Schmidt'`. Tighten it to assert `card?.textContent` does NOT contain `·`** — otherwise it passes vacuously for both STORY and JOURNEY rows. _(Sara Holt.)_ - `GeschichtenCard.svelte.spec.ts` (person sidebar) — add a regression guard: render with `type: 'JOURNEY'` to confirm no badge bleeds through (one test, one line). **New component specs (vitest-browser-svelte, `*.svelte.spec.ts`):** - `GeschichteListRow.svelte.spec.ts` — badge renders for JOURNEY; no badge for STORY; no badge for `type: undefined`; badge text "REISE"; badge is a `<span>`; title and meta render - `JourneyReader.svelte.spec.ts` — intro shown/hidden by body content; `body: ' '` → no intro; XSS regression (browser project); items in position order; document vs interlude items; annotation shown/hidden; null-document + blank-note item omitted entirely; empty-items state renders message ("Diese Lesereise ist noch leer"); **Journey-with-intro-and-empty-items shows BOTH intro AND empty state (separate test)**. _(NOTE: the "STORY + `items:[]` → renders body" and "`type: undefined` + non-empty body" cases moved to the orchestrator `page.svelte.test.ts` — see Sara Holt's relocation above; do NOT place them here.)_ - `JourneyItemCard.svelte.spec.ts` — title, date, sender→receiver, whole-card `<a>` href, single `aria-label` on link, annotation block, blank-note annotation not rendered, XSS-on-note regression, `min-h-[44px]`, "✎" cue present; **undated letter → link present, NO date text, aria-label is the plain `"Brief öffnen"` form (no date fragment)** - `JourneyInterlude.svelte.spec.ts` — renders note as plaintext (XSS-on-note regression: inject payload, assert `window.__xss === undefined` + escaped text); blank/whitespace note → not rendered; `aria-label="Kuratorennotiz"`; section-break glyph/rule cue present; visually distinct from document items - `TypeSelector.svelte.spec.ts` — both cards render; Weiter `aria-disabled`/enabled; `aria-checked`; keyboard ArrowLeft/Right navigation (NOT Up/Down); ArrowRight updates `aria-checked` AND selection state persists after a re-render; Space/Enter selects (native button); radiogroup correctly named (`aria-labelledby`); instructional text visible when no type selected; **with NO selection, the first card has `tabindex="0"` and the other `tabindex="-1"` (roving-focus initial holder — keyboard-startable)** **Load function tests (vitest Node, `.test.ts`):** - `geschichten/new/+page.server.ts` — returns `selectedType: 'JOURNEY'` for `?type=JOURNEY`; `null` for missing param; `null` for invalid param (e.g. `?type=ADMIN`) — named as a security regression; `?type=STORY&personId=p1` returns BOTH `selectedType: 'STORY'` AND `initialPersons: [p1]` (no coupling); **`?type=STORY%00JOURNEY` → `null`; repeated `?type=STORY&type=JOURNEY` → `'STORY'` (first-value semantics, documented as intentional)** - `extractText`/`plainExcerpt` SSR-fallback XSS: call `plainExcerpt('<img src=x onerror=...>')` under Node, assert no `onerror=` substring (covers the first-paint regex-fallback path the browser spec misses). **Must live in the Node `.test.ts` project — add a comment naming WHY (the browser project's `DOMParser` would mask the regex path → false green).** **E2E (Playwright):** - List: REISE badge visible next to a Journey title - Detail (Journey): LESEREISE badge, ordered items, interlude blocks, empty-state — **mobile layout required (Critical read path)**; reuse the `beforeAll` fixture navigated at both 320px and desktop. **Wire the empty-state E2E through the DETAIL route (direct URL/dashboard), NOT the list — the list is PUBLISHED-only so a draft journey never appears there.** - Detail (Story): visually unchanged (regression guard) - New page: selector appears, Weiter disabled → enabled → navigates with `?type` — **desktop required; mobile nice-to-have (Minor)** - **E2E fixture (HARD acceptance criterion):** `beforeAll` creates a JOURNEY-type Geschichte via the API (captures ID) + `afterAll` teardown, then GETs `/geschichten/[id]` with items. The E2E user MUST have BLOG_WRITE (the new-page redirect blocks non-writers); confirm before relying on the fixture. This GET-with-items flow is the SOLE guard against the #750 lazy-init 500 (the mocked `page.svelte.test.ts` cannot catch it). ### Commit Order 1. API regen (`api.ts` — after #750 merged). **PR gate:** the `api.ts` diff must visibly add `type` and `items`. Post-regen self-check before committing: confirm BOTH signals — (a) the `Geschichte` schema gained an `items` property (`grep -q "items" ...` scoped to the schema) AND (b) `type` is emitted as a UNION LITERAL (`'STORY' | 'JOURNEY'`), not bare `string`. The `items` property is the MORE RELIABLE "did #750 land" signal: if #750 serialises `type` as a bare `string` (no enum constraint), `api.ts` compiles fine with `type?: string` and the JOURNEY grep finds nothing → a false "STALE api.ts" on a correct file. **If `type` is bare `string`, STOP — fix #750's `@Schema` on the `type` field; do not feed a stringly-typed discriminant into `isJourney`.** Verify on-branch provenance: `git log --oneline -- frontend/src/lib/generated/api.ts`. _(Tobias Wendt + Markus Keller.)_ 2. Add orange `--c-journey-*` tokens to ALL THREE theme blocks of `layout.css` + `@theme inline` exposure 3. Update failing tests (TDD red — tighten `baseGeschichte`/`baseData` factories to `Partial<components['schemas']['Geschichte']>`, fix surfaced `author`-shape drift, add `type`/`selectedType`, new failing specs) 4. Implement components (TDD green) — `StoryReader`, `JourneyReader`, `JourneyItemCard`, `JourneyInterlude`, `GeschichteListRow`, `TypeSelector` 5. Cleanup (`formatAuthorName` + `formatPublishedAt`-wrapper extraction to `$lib/geschichte/utils.ts`) — separate atomic commit 6. README update (`frontend/src/lib/geschichte/README.md` — add 4 components + `GeschichteListRow` + `utils.ts`, correct stale `GeschichtenCard` claim, document PUBLISHED-only list) — can fold into step 4's commits or a final doc commit ### DevOps / Process - **#750 is a HARD BLOCKER, not dev-ergonomics.** As of 2026-06-08 `Geschichte.java` has no `type`/`items` — branching today makes `generate:api` emit an `api.ts` lacking them, so `svelte-check`/`vitest` fail looking like code bugs. Add an explicit blocked-by #750 relationship; do not start the branch until #750 merges to `main`. - **Worktree sequence (order matters — `install` before everything):** (1) `git pull` main, (2) confirm #750 is merged (`grep` `Geschichte.java` for `type`/`items`), (3) `git worktree add ../familienarchiv-lesereisen-752 -b feat/lesereisen-752-frontend`, (4) `cd ../familienarchiv-lesereisen-752/frontend && npm install` (pre-commit `npm run lint` AND `openapi-typescript` both need `node_modules`), (5) start the **#750-merged** backend dev server **explicitly with `--spring.profiles.active=dev`** — the OpenAPI `/v3/api-docs` endpoint is dev-profile-only; a default-profile or stale JAR silently emits no spec / the pre-#750 schema, (6) `npm run generate:api`, (7) self-check `items`-on-Geschichte AND `type` union-literal, (8) first commit: regenerated `api.ts`. - `generate:api` is local-only (needs a running dev-profile backend); CI consumes the committed `api.ts`, so the committed regen is load-bearing. - **CI E2E backend provenance (Tobias Wendt):** unlike the unit/component tiers (which consume the static `api.ts`), the E2E `beforeAll` does a LIVE `POST /api/geschichten` with `type: JOURNEY`. If CI's backend image/JAR is built from a commit BEFORE #750 merged (stale layer cache, or the E2E job pins a backend tag), the POST will 400/ignore `type` and the GET-with-items assertion fails confusingly. **Confirm the CI E2E job builds the backend from the same merge-base that includes #750** (mirror the local worktree step-2 `Geschichte.java` check). A stale backend image silently breaks the lazy-init-500 regression net. - **Pre-commit lint gate on net-new files:** the ~5 new `*.svelte.spec.ts` and the new components must be Prettier-clean and ESLint-clean AT COMMIT TIME (pre-commit gates `npm run lint`, not test). The XSS specs use a `window.__xss` global — ESLint may flag `no-undef`/`no-explicit-any` on `(window as any).__xss`. Use a typed `declare global` augmentation so the pre-commit hook doesn't block the green commit. This bites at commit time, not CI. _(Tobias Wendt.)_ - **Tag the fast Node-tier security tests as front-line merge gates (Tobias Wendt):** the `?type` validation load tests and the `plainExcerpt` SSR-fallback XSS test are pure-Node (<10s tier) and run first/cheapest — make THEM the front-line tripwires so a security regression fails in the first 10s, not after the ~30s browser tier. Keep the browser XSS specs gating too, but the Node security tests are the front line. - New browser-mode specs (~5 `*.svelte.spec.ts`) are the slow CI tier (+~15–30s to the client project) — run only the changed spec file locally (standing rule: never the full suite locally); let CI run the sweep. The XSS regression specs must run in the `client` (browser) project — confirm the CI `client` project already runs (the existing `*.svelte.spec.ts` prove it does) before making the XSS specs merge-gating. - No Compose, CI, Renovate, or infrastructure changes needed — pure frontend feature against an unchanged service topology. The only infra-adjacent artifact is the committed `api.ts`. ### Open #750 Contract Questions (escalate BEFORE building the affected component) These are hard cross-issue dependencies surfaced in review. Resolve with #750 before building the dependent UI; the frontend decisions above are correct ONLY if these hold. - **Does the #750 item payload include `documentDate` on the item's document object?** The undated-letter date + aria-label spec (decided above, two i18n keys) is buildable ONLY if `documentDate` is present on the item's document summary. If #750 ships only `{ id, title }` per item document, the date cannot render and the entire "Brief vom {date}" spec is moot → escalate to #750 to add `documentDate` to the item-document summary (preferred — small, enables the spec) BEFORE building `JourneyItemCard`; do NOT build the date/aria branch against a field that may not exist. _(Raised by Elicit.)_ - **Does `type` serialise as a union literal (`'STORY' | 'JOURNEY'`) or bare `string`?** Confirm at commit-1 self-check (see Commit Order). Bare `string` is a #750 `@Schema` gap to fix in #750, not to paper over here. _(Markus Keller.)_ - **Do Journey items cascade-delete when a Document is deleted?** If yes, the dangling-item omit-rule is a cheap safety net only; if no, it is load-bearing. Confirm to size the rule. _(Elicit.)_ - **Does `getById()`/`list()` carry `@Transactional(readOnly = true)` for lazy `items`?** Required to avoid `LazyInitializationException` → 500; the E2E GET-with-items fixture is the only net for this. _(Markus Keller.)_
marcel added this to the Lesereisen (Reading Journeys) milestone 2026-06-06 16:07:21 +02:00
marcel added the P1-highfeatureui labels 2026-06-06 16:08:07 +02:00
Author
Owner

UI spec committed to main: docs/specs/lesereisen-reader-spec.html

Covers three screens:

  • LR-0 — Typauswahl auf /geschichten/new (zwei Karten: Geschichte / Lesereise; Weiter-Button disabled bis Auswahl)
  • LR-1 — „REISE"-Badge in der Übersichtsliste (orangenes Pill in der Metaspalte; Story-Zeilen unverändert)
  • LR-2 — Journey-Leseansicht auf /geschichten/[id] („LESEREISE"-Badge, optionale Einleitung, Briefkarten ohne Nummerierung, Interlude-Absätze mit orangenem linken Rand, Kuratoren-Annotationen mit Mint-Rand)

Desktop- und Mobile-Mockups + impl-ref-Tabellen für alle drei Screens.

UI spec committed to main: [`docs/specs/lesereisen-reader-spec.html`](https://git.raddatz.cloud/marcel/familienarchiv/src/branch/main/docs/specs/lesereisen-reader-spec.html) Covers three screens: - **LR-0** — Typauswahl auf `/geschichten/new` (zwei Karten: Geschichte / Lesereise; Weiter-Button disabled bis Auswahl) - **LR-1** — „REISE"-Badge in der Übersichtsliste (orangenes Pill in der Metaspalte; Story-Zeilen unverändert) - **LR-2** — Journey-Leseansicht auf `/geschichten/[id]` („LESEREISE"-Badge, optionale Einleitung, Briefkarten ohne Nummerierung, Interlude-Absätze mit orangenem linken Rand, Kuratoren-Annotationen mit Mint-Rand) Desktop- und Mobile-Mockups + impl-ref-Tabellen für alle drei Screens.
Sign in to join this conversation.
No Label P1-high feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#752