feat(lesereisen): frontend — type badge on list, Journey reader on detail, type selector on new #752
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:apifirst 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+itemsfields) must be merged before this branch is opened. As of 2026-06-08 #750 is NOT merged —Geschichte.javahas notype/itemsfields. ConfirmGeschichte.javaincludestypeanditemsbefore starting. Do not pick this issue up until #750 closes.PRECONDITION (#750 contract): #750 reuses
Geschichte.bodyas the Journey intro — there is NO separate intro column. Everybody-as-intro reference in this issue depends on this. If #750 introduced a distinct intro field, retarget everyg.body?.trim()intro reference here. (Confirm during API regen.)Non-Goals
Tasks
1. Regenerate API types
Commit the regenerated
src/lib/generated/api.tsas the first commit of the branch.2.
GeschichteListRow.svelte— extract + add type badgeThe current list on
/geschichtenrenders rows inline in+page.sveltewith no dedicated component. Extract the row markup into a newsrc/lib/geschichte/GeschichteListRow.sveltecomponent, 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). Usetext-xs(12px minimum — nottext-[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 needitems,persons,documents, orstatus. Narrowing the prop type makes the component's contract explicit. (See Review Insights — prop type assignability resolved decision.)Note: The existing
GeschichtenCard.svelteis the person-detail sidebar card — do NOT modify it for this badge.3.
/geschichten/[id]detail page — Journey readerConditional rendering on
type:bodyas a plaintext intro paragraph if present (non-empty, non-whitespace — useg.body?.trim()guard), then iterateitemsin position order:/documents/[id]document === null): typographic aside block between lettersNew components (FOUR —
StoryReaderis required, see Review Insights):Use
$derivedin+page.svelte:Then:
{#if isJourney}<JourneyReader {geschichte} />{:else}<StoryReader {geschichte} />{/if}+page.sveltebecomes a thin orchestrator:isJourneyderived + the if/else + shared header/BackButton. Thesanitized = $derived(safeHtml(g.body)), the body<div>, persons section, documents section, author actions andhandleDeleteALL move intoStoryReader.svelte— see Review Insights.StoryReader MUST preserve the body
<div>class string and the why-not-prose comment VERBATIM ([id]/+page.sveltelines 62-75):font-serif text-lg leading-relaxed text-ink [&_h2]:... [&_ol]:list-decimal [&_ul]:list-disc .... Do NOT substituteprose/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-discmodifiers silently removes list markers from existing stories — a regression no current test catches.4.
/geschichten/new— type selectorExtend
+page.server.tsto readurl.searchParams.get('type')and validate it:Return
selectedTypeto the page. The?typeparse must be INDEPENDENT of the existingpersonId/documentIdpre-population (no coupling — they are independent params). The Svelte component branches:selectedType→ showTypeSelector.svelte(new component atsrc/routes/geschichten/new/TypeSelector.svelte)selectedType === 'STORY'→ show existingGeschichteEditorselectedType === 'JOURNEY'→ show placeholder ("Journey-Editor folgt in #753") + a return-to-selection control (see Review Insights — exit path)"Weiter" navigates to
/geschichten/new?type=STORYor/geschichten/new?type=JOURNEY.5. i18n keys
Add translation keys in
messages/{de,en,es}.jsonfor all new visible strings:journey_badge_list) AND "LESEREISE" (detail badge —journey_badge_detail) — two distinct keys, do not collapse themjourney_placeholder_back)aria-label="Brief vom {date} öffnen") — use Paraglide parameterized message:m.journey_item_open_aria({ date: string })journey_item_open_aria_undated, no{date}param) — see Review Insights (undated-letter decision)Acceptance criteria
GeschichteListRowshows a "REISE" badge for JOURNEY type geschichten (badge usestext-xs, nottext-[10px]; badge is a plain<span>, never nested interactive)<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.tstests keep their assertions unmodified; the factory gainstype: 'STORY'+items: [], and they must keep passing once+page.sveltedelegates toStoryReader.type === 'STORY'withitems: [], 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 beisJourney && items.length === 0, neveritems.length === 0alone.type === undefined(accidental factory default) withitems: [], the Story body renders and NO empty-state appears (defends the accidental-default path)type === undefinedwith a NON-empty body anditems: [], 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")document === nullANDnoteblank/whitespace), the reader omits the item entirely (defends dangling deleted-document items — EARS omit-rule)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)?typeparam; selector shows correct editor/placeholder after "Weiter" (precondition: reachable only by BLOG_WRITE users — server redirects others to/geschichten)tabindex="0"(the otherstabindex="-1") on first render so it is Tab-reachable and Arrow-nav can start (see Review Insights — roving-tabindex initial holder)aria-disabled="true"(not thedisabledHTML attribute) until a type is selected; a visiblearia-live="polite"hint explains why/geschichten/[id]with items" fixture passes (the SOLE regression net for the #750 lazy-init 500; promoted from Test Plan to a hard criterion)frontend/src/lib/geschichte/README.mdupdated with all 4 new components +GeschichteListRow+utils.ts, AND the staleGeschichtenCard"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'stext-[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 filedocs/specs/lesereisen-reader-spec.htmlstill showstext-[10px]AND stockbg-orange-50 text-orange-700/*-orange-NNNclasses — the issue body takes precedence. Ignore everytext-[10px]and every*-orange-NNNTailwind 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 === 0guard 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 === 0guard. 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 === nullAND a blank/whitespacenote(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 andjourney_item_open_aria_undated()(no param) for undated; the component branches ondocumentDatepresence. 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 Storydocumentsbranch already renders the date only{#if d.documentDate}(line 113) — mirror that conditional.JourneyItemCard.svelte.spec.tsgains 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 carriesdocumentDate(see Risks). (Raised by Elicit; Option B — single conditional key — rejected.)Interlude / annotation blank-note guard: Mirror the intro
body?.trim()guard on interludenoteand annotationnote. An interlude withnote === ''or' 'must not render an empty orange-bordered block. Same for a blank annotation note.Journey intro
bodyconditional: Render the intro paragraph only whenbodyis a non-empty, non-whitespace string. Useg.body?.trim()— handles bothnull/undefinedand 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. Usefont-serif italic text-base text-ink-2 leading-relaxed. Reservetext-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 byitalic+text-ink-2. This overrides the design spec'stext-smintro. (Raised by Leonie Voss; design-spectext-smrejected for read-path content.)?typeURL param validation: Validate in+page.server.tsto'STORY' | 'JOURNEY' | nullbefore 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 existingpersonId/documentIdpre-population behind a type check (independent params). Forward note to #753: the create/update DTO must whitelisttypeserver-side and never trust a client-suppliedtypeverbatim (mass-assignment guard) — this issue's?typevalidation 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.cssdefines 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-orangehex exposed via@themewould render the SAME orange in light and dark mode —#FEF0E6becomes 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-dangeris 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#B46820on#FEF0E6≈ 4.6:1 — AA-pass at 12px bold). Dark values: hand-pick a muted dark tint (e.g.~#3A2A1Afill) 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 Tailwindtext-orange-700/bg-orange-50are 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.handleSubmitscope in/geschichten/new: Decided — movehandleSubmitinside the{#if selectedType === 'STORY'}branch. It is a live clientcsrfFetchclosure (not ause:enhanceaction), so move the THREE pieces together: thesubmitting/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.)GeschichteListRowprop type assignability (resolved): KeepPick<Geschichte, ...>and have the list+page.server.tsmap/return the fullGeschichteshape (Option B). Rationale: a clean, explicit component contract decoupled from the list endpoint's shape is worth the trivial allocation cost. Clarification (Round 5): thePickis a contract nicety at the component boundary ONLY — it is NOT an over-fetch mitigation.geschichten/+page.server.ts(line 36) already returns the rawlistResult.data ?? [](fullGeschichte[]), so whatever #750 puts in the entity ships to the page regardless of the component prop. The over-fetch lever (itemsarray on every list row) lives 100% in #750'slist()projection — no frontend change can fix it. The component's prop type staysPick<Geschichte, ...>either way. (Raised by Felix Brandt + Markus Keller; Option A — coupling the component to a list DTO — rejected.) Security bonus: the row never receivesdocument/personIDs, so they can't be accidentally exposed.itemsin the list payload (DECIDED — Option A, coordinate with #750):GET /api/geschichtenreturns the fullGeschichteentity; after #750 each list row would ship its entireitemsarray (plus EAGERpersons/documents) though the list only needstype. Decision: #750'slist()should NOT eager-loaditems(or should return a slim projection); reserve fullitemsloading forgetById(). 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) constrainsstatus: 'PUBLISHED', and it ALREADY eager-loadspersons/documentsthe list never renders. So #750 addingitemsCOMPOUNDS 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 forlist()" recommendation. If #750 already shipsitemsinlist(), 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.svelteis a required 4th new component (the issue's{#if isJourney}split implies it but did not name it). The existing top-levelsanitized = $derived(safeHtml(g.body))in[id]/+page.sveltefires for EVERY Geschichte; leaving it at top level means DOMPurify runs over JOURNEY plaintext pointlessly and the split is a half-extraction. Move thesanitizedderived, the body<div>(with its verbatim class string + why-not-prose comment), persons section, documents section, author actions andhandleDeleteALL intoStoryReader.svelte.+page.sveltebecomes a thin orchestrator. (Felix Brandt, Markus Keller, Leonie Voss.){@html}ownership after extraction (grep gate):[id]/+page.svelteline 74 has{@html sanitized}guarded byeslint-disable-next-line svelte/no-at-html-tags. WhenStoryReaderis extracted, that disable comment AND thesafeHtml()import MUST travel together toStoryReader.svelteand 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 copiedeslint-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 EXACTLYStoryReader.svelte. (Nora Steiner.)GeschichteListRow.sveltemust be a new component (extract from+page.sveltelist loop) — placed insrc/lib/geschichte/. The existingGeschichtenCard.svelte(person-sidebar) must not be modified.TypeSelector.sveltebelongs insrc/routes/geschichten/new/TypeSelector.svelte(single-use route UI). Caveat: do NOT citePersonTypeSelector.svelteas a route-collocation precedent — it lives in$lib/person/and is a live-select. Our TypeSelector is a two-step select → enable Weiter →gotoflow, net-new, needs its own tests. Reuse only theradioGroupNavaction and therole="radio"/aria-checked/roving-tabindexbutton skeleton; do not clone the component.radioGroupNavhandles ONLY ArrowLeft/ArrowRight (horizontal axis) — NOT Space/Enter selection, NOT ArrowUp/ArrowDown. It also mutatesaria-checkedviasetAttributedirectly on the DOM nodes (radioGroupNav.tslines 21-23). CONVERGENCE RULE: TypeSelector selection state MUST live in a$staterune driven by BOTH theonclickAND theradioGroupNavcallback; never depend on the action'ssetAttribute('aria-checked')surviving a render. If the callback does NOT call the state setter (thinking the action already setaria-checked), Svelte's reactivearia-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 thePersonTypeSelectorpattern:use:radioGroupNav={(v) => { if (TYPES.includes(v)) select(v); }}, each button carrying avalue={type}attribute the action reads. Space/Enter activation comes free from native<button onclick>— wire selection on each card button'sonclick. The TypeSelector test asserts ArrowLeft/Right (NOT Up/Down — thegrid grid-cols-1 sm:grid-cols-2layout is vertical on mobile, but reusingradioGroupNavverbatim 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.)radioGroupNav.handleKeydownearly-returns whendocument.activeElementis not already a[role="radio"]node (current === -1).PersonTypeSelectorworks only because it ALWAYS has a defaultselected('PERSON'), so one card carriestabindex={0}and is Tab-reachable. Our two-step TypeSelector starts with NOTHING selected — iftabindex={selected === type ? 0 : -1}withselected === null, ALL cards aretabindex=-1, NONE is Tab-reachable, none can becomeactiveElement, 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]);thentabindex={type === rovingFocusType ? 0 : -1}. This keepsaria-checkedfalse 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 hastabindex="0", the othertabindex="-1". (Raised by Felix Brandt — not covered by the prior convergence rule.)import GeschichteEditorin the JOURNEY placeholder branch. An{#if}does not stop the staticimportfrom bundling the editor + TipTap for a user who only sees the placeholder. Keep theGeschichteEditorimport physically inside the STORY-branch component (or a<StoryCreate>child) so tree-shaking excludes it from the placeholder path. (Felix Brandt.)+page.server.tsat/geschichten/newmust read and validate?typeserver-side — client-only$statewould be lost on page load/refresh. The existingpersonId/documentIdpre-population must still work whenselectedType === 'STORY'(independent params, no coupling).typemust be a union literal inapi.ts, not barestring(commit-1 self-check addition):isJourneysilently depends ontypebeing a strict enum, but the detail load function doesresult.data!with no shape guard. If #750 serialisestypeas a bareString(no@Schema(enumAsRef)/enum constraint),api.tsemitstype?: string,g.type === 'JOURNEY'still works, but ANY drift (lowercasejourney, trailing space, null) makesisJourneyfalse 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)itemsproperty present on theGeschichteschema AND (b)typeemitted as a union literal ('STORY' | 'JOURNEY'), not barestring. If (b) fails, STOP and fix #750's@Schemaon thetypefield — do not proceed with a stringly-typed discriminant feedingisJourney. (Raised by Markus Keller.){#each geschichte.items as item (item.id)}— keyed iteration required inJourneyReader. Items have stable UUIDs; unkeyed iteration causes silent state corruption on reorder. Branch onitem.document === nullto pickJourneyInterludevsJourneyItemCard(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 asformatDate(g.publishedAt.slice(0, 10), 'long' | 'short').formatPublishedAtdoes 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 apublishedAt(g)wrapper that does the?? null+.slice(0,10)+formatDate(_, style)dance. (Felix Brandt — corrects the "signature already exists" framing.)publishedAtonce per row. The list'spublishedAt(g)closure callsformatDate(g.publishedAt.slice(0,10), 'short')TWICE in the template (line 139: once in{#if}, once in the message arg). InGeschichteListRow, hold the formatted value in ONE$derivedper row so it is computed once, not on every reactive read. (Felix Brandt.)$lib/geschichte/utils.ts—formatAuthorName(author)(clean extraction of the duplicatedauthorName) ANDformatPublishedAt(publishedAt, style)(a thin WRAPPER over the existingformatDate: does the?? null+.slice(0,10)+formatDate(_, style), NOT a re-implementation of date formatting; keep theformatDateimport inside the util). Both are duplicated acrossgeschichten/+page.svelte,[id]/+page.svelte,GeschichtenCard.svelte(list/Card use'short', detail uses'long'— thestylearg is required). Place ingeschichte/domain package, NOT$lib/shared/. Do not extract one and leave its twin.frontend/src/lib/geschichte/README.mdcurrently lists only Editor + Card AND contains a STALE claim (line 20) thatGeschichtenCardis "Used in/geschichten(list), dashboard" — it is NOT (the list renders rows inline;GeschichtenCardis only the person-sidebar card). The doc-gate task MUST: (1) correct theGeschichtenCardrow, (2) addGeschichteListRowas the actual list row, (3) add the 4 reader components +utils.ts, (4) document that the public list isstatus=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.)itemspre-sorted byposition(SQLORDER BY positionpreferred). Front-end sorting is fragile and should be avoided.itemsa lazy@OneToMany,getById()/list()need@Transactional(readOnly = true)(ADR-022) or serialisingitemsthrowsLazyInitializationException→ 500. The frontend[id]/+page.server.tsdoesresult.data!(raw entity), and[id]/page.svelte.test.tsmocks 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)
new/+page.server.tsredirects everyone else to/geschichten. The JOURNEY-placeholder test needs no permission dimension; the dead-handleSubmitconcern is low-stakes (only authors reach it).Security
StoryReaderkeeps{@html safeHtml(g.body)}(DOMPurify) — Story body is TipTap HTML.JourneyReader.svelte,JourneyInterlude.svelte, ANDJourneyItemCard.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.plainExcerpt(g.body, 150)→extractText. In the browserextractTextreturnstextContent(safe). ButextractText.tshas an SSR fallback: whenDOMParseris undefined (which is how the list renders on FIRST PAINT), it falls back to a regex striphtml.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: callplainExcerpt('<img src=x onerror="window.__xss=1">')and assert the result contains noonerror=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.).test.tsproject, not the browser.svelte.spec.tsproject. The regex-fallback branch only fires whenDOMParseris genuinelyundefined: Vitest's Node project has noDOMParser(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.)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 BOTHwindow.__xss === undefinedAND 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.?typevalidation 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=p1must return BOTHselectedType: 'STORY'ANDinitialPersons: [p1](proves no coupling). Add two more adversarial cases:?type=STORY%00JOURNEY(null-byte/encoded) must yieldselectedType: null(strict equality rejects it), and a repeated?type=STORY&type=JOURNEYparam must yield'STORY'(sinceurl.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.)canBlogWritemodel applies unchanged.item.documentis data-shape, not auth. A reader sees an item's title/date from the Journey's server-rendereditemsregardless of per-document access. In this archive every logged-in member hasREAD_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.)noteis 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
role="radiogroup"witharia-labelledbypointing to the visible "Was möchtest du erstellen?" question text. Each card:role="radio",aria-checked, rovingtabindex(0 for the roving-focus holder — see roving-tabindex initial-holder decision — -1 for others). ReuseradioGroupNavfor 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.grid grid-cols-1 gap-4 sm:grid-cols-2— mobile-first, one card wide on mobile (320px), two side-by-side at 640px+.aria-disabled="true"+tabindex="0"(not thedisabledHTML attribute) styledopacity-50 cursor-not-allowed. Pair witharia-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 → onearia-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.)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.)aria-labelcovers AT only; this is the visual cue.) (Leonie Voss.)<a href>(+page.svelteline 135<a ... class="block">). On cardhover:shadow-md, confirm the badge keeps its ownbg-journey-tintbackground andtext-journeycolour and does NOT inherit anya:hovercolour treatment, in BOTH light and dark mode. Nopointer-events-noneneeded (it's a span). Quick visual check both themes. (Leonie Voss.)<li>items: addaria-label="Kuratorennotiz". They live INSIDE the same<ol>as document items. The<ol>parent useslist-none(spec: no numbering); screen readers still announce "list, N items".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).<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/selectedTypefield is invisible to tsc and the tests pass vacuously. (Sara Holt.)geschichten/[id]/page.svelte.test.ts— has 12it()blocks (NOT 10 —grep -c "it(" → 12).baseGeschichteusesRecord<string, unknown>overrides + inline anonymous types (e.g.authoris a hand-written{ firstName?; lastName?; email } | null,persons/documentsinline literals — NOT typed against the schema). After API regen the 12 tests do NOT fail to compile; they render withtype === undefined,isJourneyisfalse, the Story branch renders, and they stay green BY ACCIDENT — masking whether the conditional was wired. Tighten overrides toPartial<components['schemas']['Geschichte']>and addtype: 'STORY' as const+items: []to the base. This will likely surface PRE-EXISTINGauthor-shape type drift (the hand-rolled author may missid/displayNameor have extra-required fields) — budget for fixing that drift in the SAME red commit; runnpm run checkon the file immediately after tightening and expect new errors unrelated totype. 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 explicittype: undefined+ non-empty-body cell as a named test.items: []renders body, NO empty-state" and "type: undefined→ StoryReader, NO empty-state" criteria belong at the+page.svelteORCHESTRATOR level (geschichten/[id]/page.svelte.test.ts), NOT insideJourneyReader.svelte.spec.ts.JourneyReaderonly mounts whenisJourney === true; a STORY never mounts it, so handingJourneyReadera STORY input couples the wrong component to the wrong contract. Move those two cases into the detailpage.svelte.test.ts(where{#if isJourney}is decided); keepJourneyReader.svelte.spec.tsfocused on journey-branch internals only — never hand it a STORY.geschichten/new/page.svelte.test.ts—baseDatais a plain literal; mocks$app/navigation(gotoetc.) at the top. AddingselectedTypewill NOT break compilation; the 3–4 editor tests fail at the ASSERTION level (TypeSelector renders instead of the editor whenselectedTypeis undefined) — a behavioural red, not a compile red. AddselectedType: 'STORY'tobaseDataso those tests render the editor; typebaseDataagainst the load return. Add ONE new test withselectedType: nullassertinggetByRole('radiogroup')→ TypeSelector. Assertgoto(already mocked) is called with the EXACT?type=STORY/?type=JOURNEYURL on "Weiter".geschichten/page.svelte.test.ts(list) — hand-typed literal with notype. The badge{#if g.type === 'JOURNEY'}never fires in existing tests. Test split (Sara Holt — avoid redundant maintenance): the listpage.svelte.test.tsasserts ONE integration-level fact (a JOURNEY row in the list shows the badge — proving the page passestypethrough to the row); the EXHAUSTIVE matrix (STORY/JOURNEY/undefined, span-not-anchor, text-xs) lives inGeschichteListRow.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 checkscard?.textContentcontains'Anna Schmidt'. Tighten it to assertcard?.textContentdoes 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 withtype: '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 fortype: undefined; badge text "REISE"; badge is a<span>; title and meta renderJourneyReader.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 orchestratorpage.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, singlearia-labelon 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, assertwindow.__xss === undefined+ escaped text); blank/whitespace note → not rendered;aria-label="Kuratorennotiz"; section-break glyph/rule cue present; visually distinct from document itemsTypeSelector.svelte.spec.ts— both cards render; Weiteraria-disabled/enabled;aria-checked; keyboard ArrowLeft/Right navigation (NOT Up/Down); ArrowRight updatesaria-checkedAND 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 hastabindex="0"and the othertabindex="-1"(roving-focus initial holder — keyboard-startable)Load function tests (vitest Node,
.test.ts):geschichten/new/+page.server.ts— returnsselectedType: 'JOURNEY'for?type=JOURNEY;nullfor missing param;nullfor invalid param (e.g.?type=ADMIN) — named as a security regression;?type=STORY&personId=p1returns BOTHselectedType: 'STORY'ANDinitialPersons: [p1](no coupling);?type=STORY%00JOURNEY→null; repeated?type=STORY&type=JOURNEY→'STORY'(first-value semantics, documented as intentional)extractText/plainExcerptSSR-fallback XSS: callplainExcerpt('<img src=x onerror=...>')under Node, assert noonerror=substring (covers the first-paint regex-fallback path the browser spec misses). Must live in the Node.test.tsproject — add a comment naming WHY (the browser project'sDOMParserwould mask the regex path → false green).E2E (Playwright):
beforeAllfixture 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.?type— desktop required; mobile nice-to-have (Minor)beforeAllcreates a JOURNEY-type Geschichte via the API (captures ID) +afterAllteardown, 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 mockedpage.svelte.test.tscannot catch it).Commit Order
api.ts— after #750 merged). PR gate: theapi.tsdiff must visibly addtypeanditems. Post-regen self-check before committing: confirm BOTH signals — (a) theGeschichteschema gained anitemsproperty (grep -q "items" ...scoped to the schema) AND (b)typeis emitted as a UNION LITERAL ('STORY' | 'JOURNEY'), not barestring. Theitemsproperty is the MORE RELIABLE "did #750 land" signal: if #750 serialisestypeas a barestring(no enum constraint),api.tscompiles fine withtype?: stringand the JOURNEY grep finds nothing → a false "STALE api.ts" on a correct file. Iftypeis barestring, STOP — fix #750's@Schemaon thetypefield; do not feed a stringly-typed discriminant intoisJourney. Verify on-branch provenance:git log --oneline -- frontend/src/lib/generated/api.ts. (Tobias Wendt + Markus Keller.)--c-journey-*tokens to ALL THREE theme blocks oflayout.css+@theme inlineexposurebaseGeschichte/baseDatafactories toPartial<components['schemas']['Geschichte']>, fix surfacedauthor-shape drift, addtype/selectedType, new failing specs)StoryReader,JourneyReader,JourneyItemCard,JourneyInterlude,GeschichteListRow,TypeSelectorformatAuthorName+formatPublishedAt-wrapper extraction to$lib/geschichte/utils.ts) — separate atomic commitfrontend/src/lib/geschichte/README.md— add 4 components +GeschichteListRow+utils.ts, correct staleGeschichtenCardclaim, document PUBLISHED-only list) — can fold into step 4's commits or a final doc commitDevOps / Process
Geschichte.javahas notype/items— branching today makesgenerate:apiemit anapi.tslacking them, sosvelte-check/vitestfail looking like code bugs. Add an explicit blocked-by #750 relationship; do not start the branch until #750 merges tomain.installbefore everything): (1)git pullmain, (2) confirm #750 is merged (grepGeschichte.javafortype/items), (3)git worktree add ../familienarchiv-lesereisen-752 -b feat/lesereisen-752-frontend, (4)cd ../familienarchiv-lesereisen-752/frontend && npm install(pre-commitnpm run lintANDopenapi-typescriptboth neednode_modules), (5) start the #750-merged backend dev server explicitly with--spring.profiles.active=dev— the OpenAPI/v3/api-docsendpoint 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-checkitems-on-Geschichte ANDtypeunion-literal, (8) first commit: regeneratedapi.ts.generate:apiis local-only (needs a running dev-profile backend); CI consumes the committedapi.ts, so the committed regen is load-bearing.api.ts), the E2EbeforeAlldoes a LIVEPOST /api/geschichtenwithtype: 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/ignoretypeand 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-2Geschichte.javacheck). A stale backend image silently breaks the lazy-init-500 regression net.*.svelte.spec.tsand the new components must be Prettier-clean and ESLint-clean AT COMMIT TIME (pre-commit gatesnpm run lint, not test). The XSS specs use awindow.__xssglobal — ESLint may flagno-undef/no-explicit-anyon(window as any).__xss. Use a typeddeclare globalaugmentation so the pre-commit hook doesn't block the green commit. This bites at commit time, not CI. (Tobias Wendt.)?typevalidation load tests and theplainExcerptSSR-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.*.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 theclient(browser) project — confirm the CIclientproject already runs (the existing*.svelte.spec.tsprove it does) before making the XSS specs merge-gating.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.
documentDateon the item's document object? The undated-letter date + aria-label spec (decided above, two i18n keys) is buildable ONLY ifdocumentDateis 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 adddocumentDateto the item-document summary (preferred — small, enables the spec) BEFORE buildingJourneyItemCard; do NOT build the date/aria branch against a field that may not exist. (Raised by Elicit.)typeserialise as a union literal ('STORY' | 'JOURNEY') or barestring? Confirm at commit-1 self-check (see Commit Order). Barestringis a #750@Schemagap to fix in #750, not to paper over here. (Markus Keller.)getById()/list()carry@Transactional(readOnly = true)for lazyitems? Required to avoidLazyInitializationException→ 500; the E2E GET-with-items fixture is the only net for this. (Markus Keller.)UI spec committed to main:
docs/specs/lesereisen-reader-spec.htmlCovers three screens:
/geschichten/new(zwei Karten: Geschichte / Lesereise; Weiter-Button disabled bis Auswahl)/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.
marcel referenced this issue2026-06-07 19:39:21 +02:00