feat(lesereisen): frontend Journey editor — ordered item list, document picker, interlude notes, reorder #753

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

Goal

Build the curator editing experience for Reading Journeys: an ordered item list with the ability to add letters, add interlude notes, edit notes, remove items, and drag-to-reorder.

Background

Builds on the list/detail frontend issue. The edit page at /geschichten/[id]/edit already handles Story editing via GeschichteEditor. This issue adds the parallel JourneyEditor component. Design spec: docs/superpowers/specs/2026-06-06-lesereisen-design.md.

Prerequisite: This issue depends on a backend issue that implements Geschichte.type (GeschichteType enum), GeschichteItem entity + Flyway migration, and all five item endpoints (POST /items, PATCH /items/{itemId}, DELETE /items/{itemId}, PUT /items/reorder). Frontend branch must not be merged until that backend PR is merged first.

JOURNEY creation seam (REQ-LE-CREATE-001): Today nothing creates a JOURNEY — new/+page.svelte mounts GeschichteEditor unconditionally and POSTs with no type, so this editor would be unreachable. Type selection at creation is owned by a separate small frontend issue (see Review Insights → Resolved Decisions). Since type is immutable after creation (REQ-LE-TYPE-001), it MUST be chosen at /geschichten/new. Link that issue as blocked-by before merge.

Tasks

JourneyEditor component (src/lib/geschichte/JourneyEditor.svelte)

Rendered on the edit page when type === 'JOURNEY'. Contains:

  • Ordered item list — displays current items in position order
  • "Add letter" action — opens the existing document typeahead/picker; on select calls POST /api/geschichten/{id}/items with documentId
  • "Add interlude note" action — inline text input; on submit calls POST /api/geschichten/{id}/items with note
  • Note editing — each document item can have a note added/edited inline; calls PATCH /api/geschichten/{id}/items/{itemId}
  • Remove item — calls DELETE /api/geschichten/{id}/items/{itemId}
  • Drag-to-reorder — reordering calls PUT /api/geschichten/{id}/items/reorder
  • Optional intro field — maps to Geschichte.body; small textarea above the item list

Edit page changes (/geschichten/[id]/edit)

The {#if geschichte.type === 'JOURNEY'} branch goes in edit/+page.svelte — not inside GeschichteEditor. GeschichteEditor gets zero changes except factoring out the shared sidebar into GeschichteSidebar.svelte, which both editors import. Do not make GeschichteEditor type-aware.

Requirements (EARS)

  • REQ-LE-ITEM-001 (item content invariant): Every item shall have a documentId OR a non-empty note (or both). The system shall reject (HTTP 400) an item with neither. This is the formal definition of a valid "interlude" and drives the "block empty interlude add" UI rule.
  • REQ-LE-PUB-001 (last-item removal on published journey): If the curator removes the last item from a PUBLISHED journey, the system shall allow the removal and show an inline warning ("Diese Reise wird ohne Einträge veröffentlicht bleiben") rather than blocking. The reader issue must spec an empty-journey state.
  • REQ-LE-TYPE-001 (type immutability): GeschichteType is set once at creation and is immutable thereafter. There is no STORY↔JOURNEY conversion path (a STORY's TipTap body and a JOURNEY's items are incompatible). No "change type" control shall exist, AND the API must reject a type change on PATCH (not only the UI).
  • REQ-LE-CREATE-001 (type chosen at creation): When creating a new Geschichte, the curator shall select the type (STORY or JOURNEY) before the editor opens; the type cannot be changed afterward. Owned by the dedicated /geschichten/new type-selector issue.
  • REQ-LE-INTRO-001 (plaintext preservation): When the Geschichte type is JOURNEY, the system shall store the intro as plain text, preserving all literal characters including <, >, and &. AC: a JOURNEY intro containing Temperatur < 0, when saved and reloaded, shows the full text including < 0.
  • REQ-LE-INTERLUDE-EDIT-001 (interlude text is editable): A pure interlude's text (note-only item, no documentId) shall be editable inline via the same note-PATCH mechanism used for document-item notes; the same confirm/blur-save model applies. The implementer MUST NOT make interludes immutable.
  • REQ-LE-INTERLUDE-NONEMPTY-001 (interlude cannot be emptied): The system shall block clearing the note of a note-only interlude (it would leave an item with neither documentId nor note, violating REQ-LE-ITEM-001). The "Notiz entfernen" control is ABSENT for note-only interludes; the only way to remove an interlude is "remove item". Editing an interlude to empty is also blocked (REQ-LE-ITEM-001 symmetry at edit time, not only add time).

Acceptance criteria

  • Journey edit page renders JourneyEditor; when type === 'JOURNEY', the edit page shows an ordered item list with "Brief hinzufügen" and "Zwischentext hinzufügen" buttons — no TipTap editor elements are visible (testable via role="toolbar" with aria-label="Formatierung" being absent)
  • When type === 'STORY', the TipTap editor is rendered and no JourneyEditor elements are visible
  • Curator can add a document (letter) to the journey via document picker
  • The document picker marks already-added documents as "Bereits enthalten" with aria-disabled="true" and prevents re-adding them; they appear in search results but clicking them has no effect. The accessible name of a disabled option includes both the title and "bereits enthalten" so screen-reader users hear why it does nothing.
  • Curator can add a pure interlude note (no document); the "Zwischentext hinzufügen" confirm stays disabled until the interlude text is non-empty (REQ-LE-ITEM-001)
  • Curator can edit a pure interlude's text inline; clearing it is blocked (REQ-LE-INTERLUDE-EDIT-001 / -NONEMPTY-001); a note-only interlude has NO "Notiz entfernen" control
  • Curator can add a note to a document item that has no note
  • Curator can edit an existing note on a document item inline
  • Curator can remove a note from a DOCUMENT item; the textarea collapses and note is set to null via PATCH (control present only on document-items, never on note-only interludes)
  • Curator can remove any item; removing an item that has a note shows an inline confirmation ("Wirklich entfernen?" + Cancel/Bestätigen button pair in the row — not window.confirm()); removing one without a note does not
  • Removing the last item from a PUBLISHED journey is allowed and shows an inline warning (REQ-LE-PUB-001)
  • Curator can reorder items; a page reload after reordering shows items in the new order
  • When an item mutation (add/remove/reorder) fails, the list returns to its pre-mutation state and a user-visible error message is shown; DELETE/reorder failure messages include a reload prompt ("bitte Seite neu laden")
  • Optimistic add/remove renders the affected row in a visible "pending" state (dimmed opacity-60 + an aria-live="polite" "wird hinzugefügt…" / "wird entfernt…" status) until the API resolves, so a rollback reads as "the tentative item went away" not "a real item vanished"
  • Optional intro field is editable and saved to body when the curator clicks "Speichern" or "Entwurf speichern" (not on blur); the intro carries NO blur hint and instead shows "Wird mit 'Speichern' gesichert."
  • JOURNEY intro preserves literal <, >, & on save+reload (REQ-LE-INTRO-001)
  • Navigating away after adding/reordering items only (without title/body/person edits) does NOT trigger the unsaved-changes warning, because item mutations are already persisted
  • Navigating away with an UNSAVED title/intro/person edit DOES trigger the unsaved-changes warning (inverse dirty-flag case)
  • All visible labels, button text, and error messages have translations in messages/de.json, messages/en.json, and messages/es.json
  • No TypeScript errors in changed files

Review Insights

Round 6 — Final Resolutions (these supersede any contradicting decision below)

Round 6 re-examined six decisions against the live code and found the spec contradicted reality on two of them. All six are now closed:

  1. Intro storage → RAW-at-rest (SUPERSEDES "escape-as-text in the backend at write"). The editor loads geschichte.body straight into a <textarea> ($state(geschichte?.body ?? '')). Escaped-at-rest therefore shows the curator the literal Temperatur &lt; 0 on reload and double-escapes to &amp;lt; on re-save — failing the editor half of REQ-LE-INTRO-001. Decision: persist the intro raw for JOURNEY. Skip BODY_SANITIZER on the JOURNEY branch (it drops <), store the raw textarea string, and let the reader's existing {@html safeHtml(g.body)} (DOMPurify) escape/sanitize at render — one escaping site, at the reader, not two. The editor textarea is bind:value only, never {@html}. Rationale: this is the only configuration where BOTH AC clauses pass (editor read-back shows raw < 0; reader shows rendered < 0). Cost: the DB column holds un-sanitized text for JOURNEY — acceptable only because every render path is safeHtml/{text} (see mandatory test below). (Felix, Nora, Elicit)

    • REQ-LE-INTRO-001 amended: the editor textarea, on reload, shall display the raw characters the curator typed (< 0), NOT HTML entities. This disambiguates "preserve literal characters" (it means byte-preserved at rest AND raw in the editor, not merely render-preserved).
    • Mandatory security control (load-bearing, not a nicety): test journey_body_render_path_is_sanitized asserting the reader path interpolates through safeHtml/{text}, plus a code comment on the JOURNEY body semantics. Because the DB now holds raw text, this test is the sole stored-XSS defense — any future "journey intro preview" that uses {@html} without safeHtml is the regression to guard against.
    • Component test: GET-loaded textarea shows Temperatur < 0 (raw); the onSubmit payload sends body: 'Temperatur < 0' raw on the wire; the reader renders Temperatur < 0.
  2. Keyboard reorder → reuse the existing move-up / move-down buttons from TranscriptionEditView (SUPERSEDES the Space/Arrow/Space keyboard-drag addition to createBlockDragDrop). createBlockDragDrop has zero keyboard handling today, so keyboard-drag was net-new ~40 lines + a settle animation + live-region choreography. TranscriptionEditView already ships tested, accessible handleMoveUp/handleMoveDown buttons (lines 133–147). Decision: keep createBlockDragDrop's pointer-drag for mouse (still generalize it to createBlockDragDrop<T extends { id: string }> for the pointer path and preserve the data-block-wrapper/data-drag-handle selector contract), and provide keyboard accessibility via move-up/down buttons on each row instead of keyboard-on-the-handle. Rationale: simpler, already tested, no new keyboard state machine, no prefers-reduced-motion settle transition, and arguably clearer for the 60+ audience than a drag affordance. The buttons fire the same optimistic-local-reorder + background-PUT + rollback path as pointer-drag. (Felix)

    • Consequences: the prefers-reduced-motion "settle" requirement is dropped (no keyboard-drag animation). The journey_item_moved aria-live announcer stays — fire it on a button-move too. The E2E reorder step uses the move buttons (deterministic), not Space/Arrow/Space. Each move button needs a min-h-[44px]/min-w-[44px] hit area and a parameterized aria-label (e.g. journey_move_up/journey_move_down with {title}). Disable "up" on the first row and "down" on the last.
  3. Code-split → static-import both editors (do NOT dynamic-import() JourneyEditor). Under SSR + adapter-node, a dynamic import inside the {#if type === 'JOURNEY'} branch is a no-op during SSR and renders nothing until the client chunk resolves — a flash-of-empty-editor on the exact page the 60+/slow-phone audience is working in, requiring a bespoke skeleton to paper over. The asymmetry: STORY already pays for TipTap; only JOURNEY would save by skipping it, and JourneyEditor (item list, no TipTap) is the light one. Decision: static-import both; the cost is JOURNEY users downloading TipTap once (cached thereafter), which is far cheaper than a first-paint loading state and an SSR-vs-client code path to maintain. KISS wins. (Tobias)This supersedes the "Code-split the two editors" decision below.

  4. Mobile save bar → non-sticky while a text field is focused (NOT an intro max-h-[25vh] cap). At 320px with the soft keyboard up, a max-h-[40vh] intro + item list + a sticky bottom-0 save bar overlap, hiding the item list or floating the bar over the keyboard. Decision: on <sm, toggle the save bar's sticky off while an input/textarea inside the editor is focused (a focus/blur listener), so it scrolls away with content during typing and returns sticky on blur. Chosen over capping the intro — a curator focused on a field is typing, not saving, so a non-sticky bar during typing is fine, and a long intro stays comfortable to edit. (Leonie)

    • AC additions: a 320px test that focuses the intro first, then asserts the item list and a save control are both reachable (scroll). Run the dual-theme AxeBuilder scan ALSO at a reflow width (320 CSS px / 400% zoom) asserting the interlude row stacks without the handle+position+accent-border colliding. Tapping the aria-disabled Publish button moves focus into the now-open Status collapsible (not just scrolls it into view).
    • Three save behaviors, three hints: notes save on blur (journey_note_save_hint); intro/title save on Speichern only (journey_intro_save_hint, "Wird mit 'Speichern' gesichert."); interlude add uses an explicit confirm. Each carries its own text-xs text-ink-2 hint, and the interlude-add confirm is a labelled verb button ("Hinzufügen"), not a checkmark icon.
  5. Backend read strategy → a dedicated @Transactional(readOnly = true) getByIdWithItems(id) that delegates to getById(id) first for the DRAFT guard, then runs the items fetch-join (do NOT make getById itself @Transactional in place). getById is non-transactional today (line 63). Adding @Transactional to it would wrap the EAGER persons load and the DRAFT && !blogWrite → 404 guard in a transaction and change the session lifecycle for every STORY read flowing through it — an acceptable-per-ADR-022 but wider blast radius where a STORY regression could hide inside a JOURNEY PR. The separate method keeps the STORY read path byte-for-byte unchanged at the cost of one delegating method. Record the alternative-and-why in the ADR fetch-strategy section. (Markus) — refines the "JOURNEY item read inherits getById's DRAFT guard" decision below toward the second listed option.

  6. Silent item removal on document delete → accepted, documented non-goal (no curator notification). The ON DELETE CASCADE silently shortens a published journey when a letter is deleted elsewhere. For a family archive where document deletion is rare and admin-driven, silent removal is acceptable; a notification/tombstone would creep into the notification + reader-empty-slot domains. Add this explicit non-goal: "When a document is deleted, its journey item(s) are removed silently; the curator is not notified in-app." The cascade is reconstructable post-hoc iff document deletes are already audit-logged — confirm in the PR that the existing audit log captures document deletes; if so, no new observability hook is needed (do NOT add a journey-specific counter). (Elicit, Tobias)

Additional round-6 requirement clarifications:

  • Type-source asymmetry (ADR point 4): create() trusts dto.getType() (no existing entity — the /geschichten/new selector sets it); the create-path frozen-join guard is if (dto.getType() == JOURNEY && documentIds present). update() trusts g.getType() and rejects a differing dto.getType(). These read the type from two different sources — spell both out so a reviewer doesn't write one guard for both.
  • Type-immutability is a THREE-case matrix, not one: PATCH with type omitted (null) → 200; type same as g.type → 200 (idempotent); type differing → 409. Test all three — the single 409 test invites a strict-equality bug that over-rejects every normal title-only PATCH. Allowed because a normal PATCH sends type: null.
  • Republish invariant needs the POSITIVE test too: alongside item_add_on_published_journey_does_not_change_parent_updatedAt, add intro_save_on_published_journey_DOES_update_updatedAt. Two tests pin the real contract (item mutations don't republish; an intro/title Speichern does); one test invites a wrong "freeze updatedAt everywhere" fix.
  • Note XSS test (verbatim plaintext): add note_with_script_tag_is_stored_and_rendered_as_text — a note containing <img src=x onerror=alert(1)> renders as literal text in BOTH editor preview and reader (notes are unsanitized plaintext; the only safety is {text} interpolation).
  • Items persist across PUBLISHED↔DRAFT transitions — status is independent of item content; retracting a journey to DRAFT does not touch its items.
  • 50-item NFR wording: "no page-level / blocking spinner." The required per-row optimistic pending indicator (opacity-60 + "wird hinzugefügt…") is exempt and required — it is NOT the spinner the NFR forbids.

Resolved Decisions

DnD mechanism → reuse the in-house createBlockDragDrop module for POINTER drag + generalize; keyboard accessibility via move-up/down buttons (UPDATED round 6 — keyboard-drag dropped, see Round 6 Resolution #2).
A production PointerEvent reorder module already ships in the transcription editor (useBlockDragDrop.svelte.ts, callback onReorder(ids: string[])). Adding svelte-dnd-action or @dnd-kit would introduce a second DnD system. Decision: reuse createBlockDragDrop for mouse/touch pointer drag. The reuse is NOT a copy-paste — the module is hard-typed to TranscriptionBlockData and couples to DOM via literal selectors [data-block-wrapper] / [data-drag-handle] (lines 27, 37). Explicit tasks: (1) generalize the signature to createBlockDragDrop<T extends { id: string }>(...); (2) keep data-block-wrapper / data-drag-handle as the documented selector contract and have JourneyItemRow emit those exact attributes (different attribute names silently no-op the pointer hit-testing); (3) keyboard accessibility is provided by per-row move-up / move-down buttons copied from TranscriptionEditView (handleMoveUp/handleMoveDown, lines 133–147) — NOT by adding Space/Arrow/Space to the handle (the module has zero keyboard handling today; keyboard-drag was net-new and is dropped); (4) prefers-reduced-motion settle transition DROPPED with keyboard-drag; (5) add a type-level test that transcription still compiles against the generalized signature so the refactor cannot regress the existing caller. A reviewer must reject a copy-paste fork — that recreates the exact second-DnD-system problem this decision prevents. Do NOT add svelte-dnd-action or @dnd-kit.

Type-regression guard runs in the Vitest/tsc --noEmit gate, NOT svelte-check (DECIDED round 5). CI does NOT gate on npm run check (~834 pre-existing svelte-check errors). The "transcription still compiles against the generalized signature" guard MUST therefore be a real Vitest type-test (e.g. expectTypeOf/a compile-asserting spec) or a tsc --noEmit over the changed files — something that actually fails CI. A svelte-check-only guard would let a transcription-caller type regression ship silently.

Reorder API contract → ordered-ID array { itemIds: [...] }, server assigns positions (SUPERSEDES position = index * 10).
Matches the existing transcription { blockIds: [...] } contract. The reorder endpoint accepts { itemIds: [...] } in display order; the server is the only writer of position and assigns 0..n atomically in one transaction. This eliminates client-side gap arithmetic and makes the UNIQUE (geschichte_id, position) constraint trivially satisfiable. Display order on read is derived from position ASC; the client never computes position.

Reorder UI flow → optimistic local reorder + background PUT with rollback, NO refetch (DECIDED round 5, was open).
On onReorder, reorder the local items array immediately (synchronous), then fire the PUT in the background; on failure restore the pre-mutation snapshot and show a visible error. Do NOT refetch + re-sort after the PUT. Rationale: matches the optimistic add/remove model already specced; keeps the keyed-{#each} corruption test deterministic and synchronous (no race against a refetch); and the server's position assignment stays authoritative only on the next full page load — the transient cross-tab order drift this allows is already an accepted non-goal (last-write-wins). The server remains the only writer of position; the client's optimistic array is display-only until reload. Consequence for tests: the keyed-{#each} test reorders the local array synchronously with the PUT merely mocked-to-resolve — its assertion does NOT belong to the "PUT + GET, toHaveBeenCalledTimes(2)" pattern (that count belongs to the rollback test). Reconcile the two test descriptions accordingly.

Document search → reuse the existing useTypeahead hook (SUPERSEDES "create useDocumentSearch").
$lib/shared/hooks/useTypeahead.svelte.ts exists: createTypeahead<T>({ fetchUrl, onSelect, debounceMs }). Decision: (a) refactor DocumentMultiSelect.svelte (still on a raw pre-useTypeahead setTimeout debounce, lines ~36–56) onto useTypeahead, then (b) build DocumentPickerDropdown (single-select) on the same hook. "Reuse" here means reuse the HOOK, not the component — DocumentMultiSelect owns its own selectedDocuments chip state which the picker does NOT want (journey items live server-side).
Corrected fetchUrl (round 5 — the prior snippet was broken): DocumentController.search returns a paginated DocumentSearchResult wrapper { items: [...] }, NOT an array, and createTypeahead<T> expects Promise<T[]>. Use:
(q) => fetch('/api/documents/search?q=' + encodeURIComponent(q) + '&size=10').then(r => r.json()).then((b: { items: DocumentListItem[] }) => b.items)
Without the .then(b => b.items) the dropdown renders nothing.
useTypeahead has a fixed 300ms debounce, always sets isOpen = true on setQuery, and has no in-flight cancellation (last-write-wins; request N can resolve after N+1). This is accepted as-is (KISS — a 10-result archive search does not justify an AbortController); the alreadyAddedIds derivation is $derived over items (not over results), so it stays correct regardless of which response lands last. State this in the PR so a reviewer doesn't flag it as a missing-cancellation bug.

Document picker empty-query behavior → keep the < 1 char guard (Option A, DECIDED round 5, was open). DocumentPickerDropdown guards query.trim().length >= 1 before calling setQuery; the dropdown stays empty until the curator types. Chosen over preloading the 10 most-recent on focus: consistent with the existing DocumentMultiSelect behavior, costs one guard line, and avoids a q=&size=10 "latest 10 docs" surprise in what is labelled a search box. Add a unit test: picker does not call fetch on empty query.

GeschichteItem fetch strategy → lazy + dedicated @Transactional(readOnly=true) fetch-join (DECIDED).
Chosen over a third EAGER collection. The backend implements a geschichteRepository-level LEFT JOIN FETCH g.items i ... ORDER BY i.position query invoked from a @Transactional(readOnly = true) read method. Rationale: a third EAGER collection would cartesian-join with the existing EAGER persons set on every Geschichte GET including STORY reads that never use items, and getById/list are non-transactional today — a lazy @OneToMany serialized outside a transaction throws LazyInitializationException in production (ADR-022). The dedicated transactional fetch-join read pays no cost on STORY reads and avoids the lazy-init trap.

Item-load query shape → fetch ONLY items in the fetch-join; persons stay EAGER via the entity (DECIDED round 5, was open). The LEFT JOIN FETCH g.items statement MUST NOT also JOIN FETCH g.persons. Fetching two collections (persons × items) in one statement produces a Cartesian row blow-up (and would MultipleBagFetchException if either were a List; they are Sets so it merely multiplies rows). Fetch one collection per query: items via the dedicated transactional fetch-join, persons via the entity's existing EAGER load — two SELECTs, no row multiplication, negligible at family-archive cardinality. Because the "documents frozen for JOURNEY" decision means documents is empty for journeys, only persons×items was the risk. Record this in the ADR fetch-strategy section so a future dev doesn't "optimize" into a Cartesian fetch.

JOURNEY item read inherits getById's DRAFT guard — no guard-free read path (DECIDED round 5; read-strategy settled round 6). Items must be exposed only as a FIELD on the existing, already-guarded GET /api/geschichten/{id} (and list). Round 6 picks the second option: a dedicated @Transactional(readOnly = true) getByIdWithItems(id) that calls getById(id) FIRST for the DRAFT && !blogWrite → 404 guard, then runs the items fetch-join — leaving the STORY read path byte-for-byte unchanged (see Round 6 Resolution #5). Do NOT make getById itself @Transactional in place (wider STORY blast radius). Do NOT add a separate GET /api/geschichten/{id}/items endpoint and do NOT add a findByIdWithItems repository call that bypasses the DRAFT check — either would leak a DRAFT journey's items to anonymous readers. The get_journey_returns_items_in_position_order @SpringBootTest asserts against getByIdWithItems's path.

JOURNEY create() AND update() both reject/ignore documentIds for the frozen join table (DECIDED round 5 — create was the missed path). The "documents frozen for JOURNEY" rule is not update-only. create() (the JOURNEY-creation path via the /geschichten/new selector) currently builds documents(resolveDocuments(dto.getDocumentIds())) unconditionally — for a JOURNEY this would populate the frozen geschichten_documents table. Guard BOTH paths: if (type == JOURNEY && documentIds present) ignore-or-reject. This is the FOURTH ADR point (see ADR task).

JOURNEY intro encoding on write → store RAW for JOURNEY, escape only at the reader (UPDATED round 6 — SUPERSEDES "escape-as-text in the backend at write"; see Round 6 Resolution #1).
GeschichteService.sanitize() runs body through BODY_SANITIZER (OWASP HtmlPolicyBuilder), which treats < as markup and silently DROPS everything from a literal < onward. Earlier decision: escape-as-text at write via escapeHtml. That breaks the editor read-back (the textarea would show &lt; and double-escape on re-save). Final decision: for JOURNEY, skip the HTML sanitizer entirely on the journey branch and persist the intro RAW (the unescaped textarea string). The frontend sends body as the RAW textarea string (no <p> wrapping, no TipTap); the backend stores it verbatim; the reader's existing {@html safeHtml(g.body)} (DOMPurify) is the single escaping/sanitizing site. Add unit test journey_intro_with_angle_bracket_is_preserved_as_text (raw round-trip through the service), the component tests for raw textarea read-back + raw-on-the-wire PATCH, and the mandatory journey_body_render_path_is_sanitized reader-sanitization test (load-bearing stored-XSS control now that the column holds raw text).

JOURNEY creation ownership → a new small dedicated frontend issue (DECIDED; REQ-LE-CREATE-001). Create the /geschichten/new type-selector issue and link it as a blocked-by dependency. Without it the editor is unreachable except via a manual DB insert.

noteSaving concurrency model → per-row independent PATCH (DECIDED). Different rows share no state; cross-row serialization adds coupling for no benefit. Each row PATCHes independently with its own itemId; neither row rolls back the other. Race test asserts both PATCH calls fire with distinct itemIds (toHaveBeenCalledTimes(2)), no rollback on either. Keep a per-row noteSaving = $state(false) only to prevent two concurrent PATCH calls from the same row on a fast blur.

Interlude visual treatment → neutral surface + left accent border + "ZWISCHENTEXT" label (DECIDED). Chosen over warm-orange (reads as caution/alarm to the 60+ audience). Colors MUST be semantic tokens with explicit light AND dark values — never raw orange-* or text-blue-600. Add --color-interlude-bg, --color-interlude-border, AND --color-interlude-label to layout.css (light + dark); replace every orange-*/blue-600 literal in the spec's impl-ref with tokens; the note-action link uses text-primary (mint/navy). The "ZWISCHENTEXT" label color must independently pass 4.5:1 in BOTH themes (token it, or pin to text-ink-2 which is token-safe) and include it in the dual-theme axe assertion. Merge blocker.

Item content invariant → block the add until non-empty (REQ-LE-ITEM-001). "Zwischentext hinzufügen" confirm stays disabled (one $derived) until text is non-empty; backend rejects neither (400). Chosen over auto-DELETE-on-empty-blur (would race the just-completed POST). Symmetric rule now applies to interlude EDIT (REQ-LE-INTERLUDE-NONEMPTY-001): editing an interlude to empty is blocked too.

Last item removed from a PUBLISHED journey → allow + inline warning (REQ-LE-PUB-001). Allow removal; show "Diese Reise wird ohne Einträge veröffentlicht bleiben". Reader issue must spec an empty-journey state.

Type immutability enforced at BOTH UI and API (REQ-LE-TYPE-001). No "change type" control. The shared GeschichteUpdateDTO is mass-assignment-risky once type exists. update() must reject any differing type: if (dto.getType() != null && dto.getType() != g.getType()) throw conflict(GESCHICHTE_TYPE_IMMUTABLE). Test the THREE-case matrix (round 6): type omitted/null → 200, type same → 200 (idempotent), type differing → 409 (patch_cannot_change_geschichte_type). The single-409 test alone invites a strict-equality bug that over-rejects normal title-only PATCHes (which send type: null). create() reads the type from dto.getType() (no existing entity); update() trusts g.getType() and rejects a differing dto.getType() — two different type sources, spell both out. This is a FIFTH new ErrorCode beyond the four item codes.

Item DTOs are minimal Java records — NOT a shared/fat DTO (DECIDED round 5). record AddItemRequest(UUID documentId, String note), record UpdateItemRequest(String note), record ReorderRequest(List<UUID> itemIds). They MUST NOT reuse GeschichteUpdateDTO or carry type/status/author/publishedAt — otherwise a PATCH /items/{id} carrying {"note":"x","status":"PUBLISHED"} becomes a mass-assignment vector if any path reads those fields. Add test patch_item_ignores_unexpected_fields (send status/type in the item PATCH body; assert journey unchanged).

Item ownership validation → scoped query, not load-then-compare (DECIDED round 5). Use findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY) (mirrors TranscriptionService.getBlock). A raw findById then if (!item.getGeschichte().getId().equals(journeyId)) is a TOCTOU-ish smell, leaks existence via timing, AND — critically for the republish-abuse invariant — loading the managed parent risks dirtying it. Validate ownership with the scoped boolean/scoped-fetch query so no managed Geschichte is mutated. The reorder loop is scoped per-id the same way.

Reorder must validate a full permutation of the journey's current items (DECIDED round 5). The submitted itemIds array must have the same size and the same id set as the journey's current items, with no duplicates → else 400. The transcription reorder loops positionally and would assign gapped positions on a partial array. Add reorder_rejects_array_missing_an_item → 400 and reorder_rejects_duplicate_item_id → 400 (alongside the existing cross-journey IDOR 403 test).

Editor loading → static-import both editors (UPDATED round 6 — SUPERSEDES the dynamic-import() code-split; see Round 6 Resolution #3). Both GeschichteEditor (TipTap, STORY) and JourneyEditor (item list, JOURNEY) are statically imported on the edit page; the {#if geschichte.type === 'JOURNEY'} branch selects which renders. Dynamic import() was rejected: under SSR + adapter-node it renders nothing until the client chunk resolves (flash-of-empty-editor on a slow phone, needing a bespoke skeleton). The savings are asymmetric — STORY already pays for TipTap; only JOURNEY would save by skipping it. Downloading TipTap once on a JOURNEY edit (cached thereafter) is cheaper than a first-paint loading state plus an SSR/client code path. KISS.

Branching location → edit/+page.svelte, not inside GeschichteEditor. Making GeschichteEditor type-aware via a type prop violates single-responsibility.

GeschichteSidebar.svelte — extract first. Before any Journey logic, extract the Personen + Status sidebar from GeschichteEditor into GeschichteSidebar.svelte; both editors import it. On mobile these sections become collapsibles.

Confirmation dialog for remove-with-note → inline button pair. Remove of an item with a note transforms the remove button into "Wirklich entfernen?" + Cancel/Bestätigen inline in the row. No window.confirm().

Maximum note length → 2000 characters. Flyway CHECK constraint AND maxlength="2000" on note textareas AND a backend service guard (DomainException.badRequest(JOURNEY_NOTE_TOO_LONG)) so the CHECK is a backstop, not the primary error path.

Hint text minimum size → text-xs (12px). The spec's text-[9px] fails WCAG AA. Use text-xs text-ink-2 mt-1. Non-negotiable.

Mobile collapsibles → native <details>/<summary> with a 44px summary hit area. Native <summary> defaults to ~20px (fails WCAG 2.5.5) — add class="min-h-[44px] flex items-center px-4".

Geschichte.documents join table for JOURNEY type — deprecated. For JOURNEY, GeschichteItem is the ordered source of truth. The backend rejects writes to the unordered geschichten_documents ManyToMany for JOURNEY geschichten (in BOTH create and update). For JOURNEY reads, null out / omit documents in the response. The frontend JourneyEditor reads items from the new items field, never from geschichte.documents (which GeschichteEditor uses on line 47). Add a code comment pointing at this decision.

Item-mutation vs. republish semantics (backend product + security decision). list() orders by COALESCE(publishedAt, updatedAt) DESC. Item add/remove/reorder on a PUBLISHED journey must NOT bump updatedAt (republish recommendation: item mutations do not republish / do not re-sort the public list). Also a content-promotion-abuse vector: a BLOG_WRITE attacker could repeatedly add/remove an interlude to keep a journey pinned. The item endpoints save the item, not the parent; a @OneToMany won't dirty the parent unless code calls geschichteRepository.save(parent) — and ownership validation must use the scoped query so no managed parent is loaded+mutated (see decision above). Integration test: parent updatedAt/publishedAt unchanged after an item add on a PUBLISHED journey.

Concurrent curation → last-write-wins, stated non-goal. Server-assigns-positions makes the write safe (no duplicate positions), but a stale-order reorder silently clobbers. Acceptable for a few-curator family archive. Explicitly a non-goal: no optimistic-locking conflict detection.

Items persist across PUBLISHED↔DRAFT transitions (round 6). Item content is independent of status; retracting a journey to DRAFT does not touch its items, and the empty-published warning is publish-state-conditioned (clears on retract). Stated to close the unspecified un-publish path.

Silent item removal on document delete → explicit non-goal (round 6). "When a document is deleted, its journey item(s) are removed silently via ON DELETE CASCADE; the curator is not notified in-app." Acceptable for a family archive (rare, admin-driven deletes). Confirm in the PR that document deletes are already audit-logged so the cascade is reconstructable post-hoc — if so, no new observability hook (do NOT add a journey-specific counter).

geschichten_items.document_id FK on document deletion → ON DELETE CASCADE (Option A, DECIDED round 5, was open). Deleting a letter removes its journey item(s) silently — no orphan, no invariant-violating row. Chosen over RESTRICT (cross-domain delete friction + a new error in the document-delete flow) and over SET NULL (REJECTED: produces an item with no doc and no note, violating REQ-LE-ITEM-001). Rationale: document deletion is rare and admin-driven in a family archive; a dangling journey reference is worse than a silently-shortened journey. Add the ON DELETE CASCADE clause to the migration FK AND a test (deleting_a_document_removes_its_journey_items).

Write an ADR for the GeschichteType discriminator. Record FOUR points: (1) the type discriminator that changes which child collection is authoritative; (2) the GeschichteItem lazy fetch-join read strategy — items fetched alone, persons stay EAGER, never both in one statement; (3) "items is the authoritative ordered collection; geschichten_documents is frozen for JOURNEY"; (4) JOURNEY create (not only update) rejects/ignores documentIds.

Implementation Guidance

Component decomposition (do this first): Extract GeschichteSidebar.svelte, JourneyItemRow.svelte, JourneyAddBar.svelte (two add buttons + picker state), DocumentPickerDropdown.svelte (single-select wrapper around useTypeahead). Define Props interfaces and event signatures first. Target: JourneyEditor.svelte under 150 lines.

{#each} must be keyed by (item.id) — never by position or unkeyed. The corrupted state is local JourneyItemRow state (the note open/closed toggle + an unsaved draft buffer held in the row's own $state), NOT item.note bound to the array. So the row MUST hold the draft in its own $state before blur for the keying to matter.

Pre-mutation snapshot for rollback: before any mutating API call, save const previousItems = [...items]. On failure: items = previousItems + show the error. Applies to add, remove, reorder, and note PATCH.

Named $derived values — do not inline in markup:

const canPublish = $derived(items.length > 0 && title.trim().length > 0);
const alreadyAddedIds = $derived(new Set(items.filter(i => i.documentId).map(i => i.documentId)));
const canAddInterlude = $derived(interludeDraft.trim().length > 0);

Pass alreadyAddedIds to DocumentPickerDropdown for aria-disabled on already-added results. Plain derived Set is correct — do NOT use SvelteSet.

Reorder handler: receives an ordered string[] of item IDs (from createBlockDragDrop's onReorder), reorders the local array optimistically, PUTs { itemIds } in the background, rolls back on failure. No refetch. The client never computes position. On initial load, sort items by position ASC; thereafter display order is the array order.

DocumentPickerDropdown ARIA (round 5 — fix, don't carry forward the DocumentMultiSelect smell): input role="combobox" aria-expanded aria-controls; results container role="listbox"; each result role="option" with aria-disabled="true" for already-added (accessible name includes "bereits enthalten"). DocumentMultiSelect's current per-result role="button" (line 161) must NOT be carried forward; aria-disabled only carries proper semantics inside a listbox. The dropdown must NOT be an aria-live region — use listbox + aria-activedescendant so it announces on navigation, not on mutation, keeping it out of the live-region collision the consolidation decision already worries about. Also keep the query.trim().length >= 1 empty-query guard here before calling setQuery.

Two save models, signposted differently: notes save on blur (with journey_note_save_hint); intro/title save only on Speichern. The intro must NOT carry a blur hint; it shows "Wird mit 'Speichern' gesichert."

Intro lives in the title/dirty lifecycle (round 5 clarification): the intro is treated IDENTICALLY to title — oninput calls markDirty(), its value rides the Speichern PATCH body, and it is NEVER wired to an immediate per-keystroke PATCH. This reconciles "warn on unsaved intro" with "saved with Speichern, not on blur".

Dirty-flag scope + reuse useUnsavedWarning: a reusable $lib/shared/hooks/useUnsavedWarning.svelte.ts exists (returns markDirty/discard/clearOnSuccess/isDirty). GeschichteEditor hand-rolls beforeNavigate + window.confirm (lines 103–108) — JourneyEditor adopts useUnsavedWarning instead. Instantiate it at TOP-LEVEL <script> scope (const unsaved = createUnsavedWarning();), never inside a function or $effect, or beforeNavigate throws "can only be called during component initialisation." Item mutations are immediate API calls with no pending local state — they MUST NOT call markDirty (a spurious dialog would fire). Tests: (a) after an item add isDirty stays false → no warning; (b) a title/intro/person edit sets dirty → warns.

Intro field auto-resize: rows={1} + oninput setting el.style.height='auto'; el.style.height = el.scrollHeight+'px'; capture via bind:this. Add max-h-[40vh] overflow-y-auto so it can't grow unbounded and bury the item list / push the sticky save bar (matters at 320px mobile and 400% zoom).

csrfFetch covers DELETE and PUT (cookies.ts line 37). The reorder PUT MUST go through csrfFetch, not raw fetch. Match existing transcription reorder.

Do NOT copy transcription's reorder error handling. TranscriptionEditView.svelte line 128 swallows errors (catch { /* ignore */ }) with no rollback. Journeys deliberately diverge: rollback + visible error. Reviewers must not "match" the PR to that precedent.

type="button" mandatory on all non-submit buttons in JourneyEditor/JourneyAddBar — both may be inside a <form>.

Permission check already satisfied — do not regress. edit/+page.server.ts line 8 already enforces canBlogWrite and redirects. Do not re-implement; do not remove.

Micrometer cardinality (round 5): the five item endpoints must use @PathVariable UUID itemId in templated paths (/items/{itemId}) so the http_server_requests uri tag stays templated and low-cardinality. A literal id in the route string would explode cardinality.

After implementation: run npm run generate:api in frontend/ once backend types are merged, and npm run check 2>&1 | grep -E "src/lib/geschichte|src/routes/geschichten" to confirm no new svelte-check errors (informational only — the type-regression GATE is the Vitest/tsc guard, not svelte-check).

First step in worktree: npm install in frontend/ before any code changes (pre-commit hook runs npm run lint). No new dependency is added.

Update src/lib/geschichte/README.md to document the new components + props as part of this PR.

Accessibility Requirements

  • Visually-hidden <div aria-live="polite" aria-atomic="true"> in JourneyEditor.svelte announcing item position changes when an item is moved (via the move-up/down buttons — round 6, not keyboard-drag), via parameterized i18n key journey_item_moved — no hardcoded German.
  • Consolidate to ONE move-announcer region. Keep per-row pending-status (journey_item_pending_add/_remove) but make those regions live only while pending and remove them from the DOM on resolve (not just empty them), so two aria-live="polite" regions don't collide. The document picker is a THIRD potential live surface — it must use listbox/aria-activedescendant semantics (NOT a live region) so it stays out of this collision.
  • Pending optimistic state: dim affected rows to opacity-60 with the per-row status removed on resolve.
  • Remove button: wrap the × in a <button class="p-3 -m-1"> (or equivalent negative margin) so the touch target is ≥44×44px while the visible column stays narrow.
  • Drag handle hit area ≥44px on desktop too. The spec's w-4 (16px) fails WCAG 2.5.5/2.5.8. Use class="w-11 flex items-center justify-center cursor-grab" with the glyph centered.
  • Note-action links are buttons, not 12px text links. "Notiz hinzufügen"/"Notiz entfernen" render as <button class="min-h-[44px] inline-flex items-center gap-1 px-2 text-sm font-semibold text-primary"> with a leading icon (+ / ✕).
  • On mobile, use aria-disabled="true" (not HTML disabled) on the Publish button so an onclick can open the Status collapsible and scroll to it when tapped while disabled.
  • Savebar hint for published journeys: "Änderungen werden sofort für alle Leser sichtbar."
  • aria-hidden="true" on the "REISE" type-badge span. Because the badge is hidden, the accessibility tree needs a non-visual mode cue: make the edit-page <h1> (currently {m.btn_edit()}: {title}, type-neutral, edit/+page.svelte line 48) or a visually-hidden sibling type-aware — "Lesereise bearbeiten" for JOURNEY vs "Geschichte bearbeiten" for STORY — so a blind curator knows which editor mode they're in.
  • Check interlude fg/bg AND label contrast (≥4.5:1) in BOTH light and dark before merge. The single AxeBuilder E2E scan must run in BOTH themes (add the theme-toggle step) and assert all three interlude tokens (bg, border, label).
  • <details>/<summary> mobile collapsibles with min-h-[44px] flex items-center px-4 on each <summary>.
  • Mobile row layout — vertical stacking at <sm: handle + position + title on row 1; note toggle + remove on a second line. Never truncate the document title. Use min-w-0 + break-words, full-width on mobile.
  • Reduced-motion: any pointer-drag "settle"/return transition must respect prefers-reduced-motion. (The keyboard-drag settle is moot — round 6 replaced keyboard-drag with move-up/down buttons.)
  • Move-up/down buttons (round 6): each row has keyboard-accessible move-up and move-down buttons (min-h-[44px] min-w-[44px] hit area, journey_move_up/journey_move_down aria-labels with {title}). Disable "up" on the first row and "down" on the last. They fire the same optimistic-reorder + background-PUT + rollback path as pointer-drag and trigger the journey_item_moved announcement.
  • Live-region text is plain interpolation only. The journey_item_moved announcement and per-row pending status interpolate {title}; document titles are attacker-influenceable via import — never {@html} in a live region (reflected XSS).

Security Flags (for backend implementer)

  • No standalone GET /items endpoint — items are a FIELD on the existing guarded GET /api/geschichten/{id} / list, inheriting the DRAFT 404 guard. A separate items GET (or a guard-free findByIdWithItems) re-opens the DRAFT leak.
  • Type immutability at the API: reject a differing type in PATCH with DomainException.conflict(GESCHICHTE_TYPE_IMMUTABLE). Test create (type honored) AND update (type rejected → 409).
  • Item ownership via scoped query findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY) for PATCH/DELETE; reorder loop scoped per-id like getBlock. Never load-then-compare (TOCTOU smell + risks dirtying the managed parent → republish abuse).
  • Reorder validates a full permutation of the journey's current item set (same size, same id set, no dupes) → else 400. Closes the gapped-position / partial-apply hole the transcription loop doesn't guard.
  • Item DTOs are minimal records (AddItemRequest(documentId, note), UpdateItemRequest(note), ReorderRequest(itemIds)) — no shared DTO with the parent, no type/status/author/publishedAt. Test patch_item_ignores_unexpected_fields.
  • JOURNEY create AND update reject/ignore documentIds (frozen geschichten_documents).
  • POST /items validates the documentId via documentService.getDocumentById (same path resolveDocuments uses) so a non-existent/forbidden id yields a mapped 404 DOCUMENT_NOT_FOUND, NOT a raw findById (dangling FK / 500).
  • @RequirePermission(Permission.BLOG_WRITE) on ALL five new item endpoints incl. the reorder PUT. A 403 test per endpoint, plus a 401-unauthenticated test for at least the reorder endpoint.
  • Note is stored verbatim (no sanitization, renders as text). Add a code comment on the note column/field: "stored as plain text; render with text interpolation only — never {@html}." Holds for any future "rich note" feature.
  • Flyway migration for geschichten_items must include:
    • UNIQUE (geschichte_id, position).
    • Partial UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL — DB-level document dedup. Catch DataIntegrityViolationExceptionDomainException.conflict(JOURNEY_DOCUMENT_ALREADY_ADDED).
    • CHECK constraint on note max length (2000 chars).
    • CHECK / service guard enforcing REQ-LE-ITEM-001 (document_id OR non-empty note).
    • FK document_id → documents ON DELETE CASCADE (Option A — deleting a letter removes its journey items; no orphan, no invariant violation).
    • Migration comment: "Forward-only; creates empty geschichten_items; does not read or modify geschichten_documents; no backfill (no JOURNEY rows exist pre-migration)." Add to MigrationIntegrationTest. Confirm the new version number is the strict max (FK ordering: table created after geschichten and documents).
  • New ErrorCode values (add to ErrorCode.java, errors.ts, all three messages/*.json): JOURNEY_ITEM_NOT_FOUND (404), JOURNEY_ITEM_NOT_IN_JOURNEY (403), JOURNEY_NOTE_TOO_LONG (400), JOURNEY_DOCUMENT_ALREADY_ADDED (409), GESCHICHTE_TYPE_IMMUTABLE (409).
  • XSS in the editor preview too, not only the reader. Any preview of an interlude/note in the editor (LE-4 shows interlude text statically) MUST use {text} interpolation, never {@html}. Also applies to live-region announcements (see Accessibility).

i18n Keys Required

All three language files need at minimum:

  • journey_add_document — "Brief hinzufügen"
  • journey_add_interlude — "Zwischentext hinzufügen"
  • journey_note_add — "Notiz hinzufügen"
  • journey_note_remove — "Notiz entfernen"
  • journey_note_save_hint — "Wird gespeichert, wenn du das Feld verlässt."
  • journey_intro_save_hint — "Wird mit 'Speichern' gesichert."
  • journey_already_added — "Bereits enthalten"
  • journey_note_aria_label — "Kuratoren-Notiz für {title}"
  • journey_drag_aria_label — "Reihenfolge von '{title}' ändern"
  • journey_move_up — "'{title}' nach oben verschieben" (move-up button aria-label — round 6)
  • journey_move_down — "'{title}' nach unten verschieben" (move-down button aria-label — round 6)
  • journey_note_error — "Notiz konnte nicht gespeichert werden"
  • journey_item_moved — "Eintrag {position} von {total} — nach Position {newPosition} verschoben"
  • journey_remove_confirm — "Wirklich entfernen?"
  • journey_mutation_error_reload — (rollback error message with reload prompt)
  • journey_item_pending_add — "wird hinzugefügt…"
  • journey_item_pending_remove — "wird entfernt…"
  • journey_published_empty_warning — "Diese Reise wird ohne Einträge veröffentlicht bleiben"

Test Plan

Component tests (*.svelte.spec.ts, vitest-browser):

  • edit/page.svelte.spec.ts — branch tests (write first): renders TipTap toolbar (role="toolbar") when STORY; does not when JOURNEY.
  • JourneyEditor: empty state renders; item list in position order; Publish disabled when items.length === 0; Publish disabled when title.trim() === ''; POST called with documentId on picker confirm; POST called with note on interlude submit; "Zwischentext" confirm disabled until text non-empty; DELETE called on remove click; rollback on failed mutation; item-add does NOT mark dirty (isDirty stays false); typing Temperatur < 0 into intro + submit PATCHes body: 'Temperatur < 0' (un-escaped on the wire).
  • Rollback tests (call counts reconciled, round 6): mock csrfFetch per-test with mockResolvedValueOnce() for EVERY call in the path. Reorder is optimistic-no-refetch → its success AND rollback tests assert toHaveBeenCalledTimes(1) on the reorder PUT URL (one PUT, no GET). The toHaveBeenCalledTimes(2) "PUT/PATCH + GET" pattern belongs ONLY to add/remove paths that refetch — NOT to reorder. The note race test asserts times(2) across two DISTINCT itemIds (two independent PATCHes), which is a different surface. Assert (1) item list unchanged after rollback, (2) error message visible, (3) exact call count per the above.
  • Reorder rollback — TWO triggers (round 6): one test uses mockResolvedValueOnce({ ok: false, status: 409 }) (non-ok response) and another uses mockRejectedValueOnce(new Error('network')) (promise rejection). The transcription precedent handles NEITHER (if (!res.ok) return; + catch { /* ignore */ }), so both are genuine regressions to guard. Same rollback path, two triggers.
  • Reader-sanitization test (load-bearing XSS control, round 6): journey_body_render_path_is_sanitized — assert the reader (geschichten/[id]/+page.svelte) interpolates body through safeHtml/{text}, never raw {@html geschichte.body}. Mandatory because JOURNEY body is now stored RAW.
  • JourneyItemRow: note textarea opens on "Notiz hinzufügen"; PATCH called on blur with non-empty value; textarea collapses + note cleared on blur empty (DOCUMENT item only); "Notiz entfernen" visible only when note non-empty AND item is a document-item (ABSENT for note-only interludes); remove aria-label contains document title; inline confirmation on remove-with-note; confirmation cancel restores item.
  • Interlude edit tests: a note-only interlude's text is editable inline; editing it to empty is blocked (confirm/save stays disabled); no "Notiz entfernen" control rendered on the interlude.
  • Keyed {#each} corruption test (red first): render 2 items; OPEN item-2's note textarea (toggle state) AND type an unsaved draft held in the ROW's own $state (do NOT bind to item.note, do NOT blur); reorder to swap 1↔2 by invoking the reorder handler/callback directly (the synchronous local-array reorder), NOT by simulating a pointer drag — pointer-capture + getBoundingClientRect hit-testing is flaky in headless vitest-browser (round 6). PUT merely mocked-to-resolve, no refetch await needed. Assert BOTH the textarea is still open AND the draft text is intact on the row now at position 1. The single real pointer/keyboard-button drag lives only in the Playwright E2E. Add a comment so a future dev doesn't "simplify" the key away.
  • noteSaving race test (per-row independent PATCH): blur row A (PATCH in-flight), immediately blur row B → assert both PATCH calls fire with distinct itemIds (toHaveBeenCalledTimes(2)), neither rolls back the other.
  • Deduplication: add a document, open picker, search the same document, assert it appears as role="option" aria-disabled="true" and its accessible name includes "bereits enthalten".
  • Picker empty-query: picker does not call fetch on empty query (the < 1 char guard).

Mock boundary: vi.mock('$lib/shared/cookies', () => ({ csrfFetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }) })). Use mockResolvedValueOnce() per-test. Trigger blur with dispatchEvent(new FocusEvent('blur')).

Load-function test: import edit/+page.server.ts directly; assert load() redirects to /geschichten/{id} when canBlogWrite is false.

Type-regression guard (NOT svelte-check): a Vitest type-test or tsc --noEmit over the changed files asserting the transcription editor still compiles against the generalized createBlockDragDrop<T> signature. Must run in the Vitest/tsc gate (CI does not gate svelte-check).

Backend integration tests (Testcontainers Postgres, NOT H2):

  • reorder_returns_403_when_an_itemId_belongs_to_another_journey
  • reorder_rejects_array_missing_an_item → 400
  • reorder_rejects_duplicate_item_id → 400
  • patch_item_returns_403_when_item_not_in_journey
  • delete_item_returns_403_when_item_not_in_journey
  • patch_item_ignores_unexpected_fields (send status/type; journey unchanged)
  • reject_item_with_no_document_and_empty_note → 400 (REQ-LE-ITEM-001)
  • duplicate_document_add_returns_409 (partial UNIQUE)
  • patch_cannot_change_geschichte_type → 409 (REQ-LE-TYPE-001 API enforcement) — plus the two PASS cases: patch_with_type_omitted → 200 and patch_with_same_type → 200 (round-6 three-case matrix)
  • journey_create_ignores_documentIds (frozen join table on the create path)
  • journey_intro_with_angle_bracket_is_preserved_as_text (REQ-LE-INTRO-001 — raw round-trip, NOT escaped)
  • note_with_script_tag_is_stored_and_rendered_as_text (note is verbatim plaintext; <img onerror> renders literal in editor + reader)
  • item_add_on_published_journey_does_not_change_parent_updatedAt (republish/promotion-abuse invariant)
  • intro_save_on_published_journey_DOES_update_updatedAt (round-6 positive counterpart — pins that an intro/title Speichern DOES republish, so a wrong "freeze updatedAt everywhere" fix is caught)
  • deleting_a_document_removes_its_journey_items (FK ON DELETE CASCADE)
  • get_journey_returns_items_in_position_order — real @SpringBootTest + Testcontainers hitting GET /api/geschichten/{id}; assert serialized items populated and ORDER BY position AND documents is null/empty for the JOURNEY (the "frozen for JOURNEY" decision's only test). Only this HTTP-layer test catches a Lazy/serialization mismatch.
  • parallel_item_posts_get_distinct_positions — fire two POSTs concurrently; assert positions 0 and 1, no UNIQUE violation surfaced to the client (server serializes position assignment).
  • concurrency: two reorder calls in one test, assert no two items share a position.
    These IDOR/constraint tests belong permanently in the regression suite.

E2E (Playwright, ONE journey test): full curator path — add document → add interlude → reorder → verify order persists on reload. Use the move-up/down buttons for reorder (deterministic; round 6 dropped keyboard-drag in favor of move buttons). One AxeBuilder scan at the end in BOTH light and dark themes, AND at a reflow width (320 CSS px / 400% zoom) asserting the interlude row stacks without the handle+position+accent-border colliding. Keep to ONE E2E (budget <8min). Do NOT drive 50 add-letter UI clicks — seed the 50-item NFR fixture via @Sql/a parallel-safe test-data API helper and assert the 50-item RENDER + one keyboard-drag completes without error or spinner. Budget-check beforeAll seeding time.

NFR

The editor must render and reorder up to 50 items without error or a page-level / blocking spinner (round 6 disambiguation — the required per-row optimistic pending indicator, opacity-60 + "wird hinzugefügt…", is exempt and required; it is NOT the spinner this NFR forbids). The unmeasurable "no layout reflow" language is dropped. Made testable via the 50-item E2E fixture seeded out-of-band (@Sql/parallel-safe test-data helper, NOT 50 UI clicks; budget-check beforeAll seeding). No NFR for larger counts.

DevOps / Deploy Notes

  • Zero new dependencies (svelte-dnd-action/@dnd-kit withdrawn; useTypeahead/createBlockDragDrop reused). No env var, no service, no Compose change. PR description must assert: "No package.json/pom.xml change. Reviewer: confirm both dependency manifests have empty diffs." The committed docs/specs/lesereisen-editor-spec.html STILL says "@dnd-kit/core oder svelte-dnd-action" in LE-2 impl-ref and STILL shows raw orange-*/blue-600 classes — strike/update those stale lines; follow the issue body, not the HTML spec, on any disagreement.
  • The only deploy-time artifact is the Flyway migration (standard migration-in-CI Testcontainers path). Confirm the new version number is the strict max.
  • Item endpoint paths use {itemId} templating so Micrometer uri tags stay low-cardinality.
  • JaCoCo branch gate is 88%, not 80% (backend CLAUDE.md). The five item endpoints + type-immutability guard + plaintext-intro branch + create-rejects-documentIds branch all add branches — budget coverage to hit 88%.
  • No new observability hook — item mutations are routine CRUD covered by existing Actuator/Micrometer + GlitchTip. Do NOT add a bespoke "journey edited" counter.
  • The transcription-compiles guard runs in Vitest/tsc --noEmit, NOT svelte-check (CI doesn't gate svelte-check).
  • Frontend coverage gate applies to new code as usual.

Documentation Blockers (required for PR merge)

  • New GeschichteItem entity → docs/architecture/db/db-orm.puml + docs/architecture/db/db-relationships.puml (new entity + FK to geschichten + FK to documents with CASCADE)
  • New GeschichteType enum on Geschichtedocs/architecture/db/db-orm.puml attribute update
  • ADR for the GeschichteType discriminator (4 points: discriminator; fetch strategy = items-alone fetch-join, persons EAGER, never both; "documents frozen for JOURNEY"; "create rejects documentIds") → docs/adr/
  • New ErrorCode values (incl. GESCHICHTE_TYPE_IMMUTABLE) → CLAUDE.md error handling section
  • src/lib/geschichte/README.md update (new components + props)
  • Strike stale @dnd-kit/svelte-dnd-action and raw orange-*/blue-600 lines in docs/specs/lesereisen-editor-spec.html

Confirmed Risks to Watch

  • Copy-paste fork of createBlockDragDrop instead of generalizing it — recreates the two-DnD-systems problem. The selector contract (data-block-wrapper/data-drag-handle) must be preserved or pointer hit-testing silently no-ops.
  • Type-regression in the transcription caller slipping through because the guard was written against svelte-check (ungated) instead of Vitest/tsc.
  • LazyInitializationException on the JOURNEY read path — only the @SpringBootTest HTTP-layer test catches it.
  • Cartesian row blow-up if the items fetch-join also fetches the EAGER persons in the same statement — fetch items alone.
  • Silent intro data loss on any intro containing < or & if the HTML sanitizer runs on the JOURNEY branch.
  • Broken document picker if the fetchUrl returns the {items:[...]} wrapper without .then(b => b.items) (renders nothing).
  • Mass-assignment type flip via PATCH bypassing the hidden UI control; and fat item DTOs leaking status/type.
  • Frozen-join-table breach on the CREATE pathcreate() populates geschichten_documents for a JOURNEY unless guarded (round-5 miss).
  • Republish/content-promotion abuse if item mutations dirty the parent's updatedAt — use scoped ownership queries so no managed parent is loaded+mutated.
  • Invariant-violating orphan rows if the document FK were SET NULL — must be CASCADE.
  • Interlude emptied to nothing if the "Notiz entfernen" control is wrongly rendered on note-only interludes (violates REQ-LE-ITEM-001).
  • Reorder test flakiness / contradictory test specs if reorder is refetch-based instead of optimistic-no-refetch.
  • Stale committed HTML spec (DnD library + raw colors) contradicting the resolved decisions.
  • Keyed-{#each} test passing for the wrong reason if the draft is bound to item.note rather than held in row-local $state; and flaking if it simulates a pointer drag instead of invoking the reorder handler directly.
  • Editor intro read-back corruption (round 6): storing the intro escaped-at-rest shows &lt; in the textarea and double-escapes (&amp;lt;) on re-save — the editor half of REQ-LE-INTRO-001 fails. Store RAW; escape only at the reader.
  • Stored XSS via raw JOURNEY body (round 6): now that the column holds raw text, any render path that isn't safeHtml/{text} is stored XSS. The journey_body_render_path_is_sanitized test is the load-bearing control.
  • Flash-of-empty-editor (round 6): a dynamic import() of the editor under SSR + adapter-node renders nothing until the client chunk resolves — static-import both editors instead.
  • Mobile save-bar / keyboard overlap (round 6): at 320px with the soft keyboard up, a sticky save bar + max-h-[40vh] intro + item list collide; the plain 320px render test misses it unless it focuses a field first. Drop sticky while a field is focused.
  • Over-rejecting type-immutability (round 6): a strict-equality guard that 409s on a type: null PATCH breaks every normal title-only save — test the three-case matrix (omitted/same/different).
  • STORY read-path regression hidden in a JOURNEY PR (round 6): making getById @Transactional in place changes STORY session semantics — use the separate getByIdWithItems delegating method.
  • Republish invariant "fixed" the wrong way (round 6): without the positive intro_save_DOES_bump_updatedAt test, someone could satisfy the negative test by freezing updatedAt everywhere. Pin both directions.
  • Silent journey shortening (round 6, accepted non-goal): ON DELETE CASCADE removes a published journey's item when a letter is deleted elsewhere, with no in-app notice — accepted, but confirm document deletes are audit-logged so the change is reconstructable.
## Goal Build the curator editing experience for Reading Journeys: an ordered item list with the ability to add letters, add interlude notes, edit notes, remove items, and drag-to-reorder. ## Background Builds on the list/detail frontend issue. The edit page at `/geschichten/[id]/edit` already handles Story editing via `GeschichteEditor`. This issue adds the parallel `JourneyEditor` component. Design spec: `docs/superpowers/specs/2026-06-06-lesereisen-design.md`. **Prerequisite:** This issue depends on a backend issue that implements `Geschichte.type` (`GeschichteType` enum), `GeschichteItem` entity + Flyway migration, and all five item endpoints (`POST /items`, `PATCH /items/{itemId}`, `DELETE /items/{itemId}`, `PUT /items/reorder`). Frontend branch must not be merged until that backend PR is merged first. **JOURNEY creation seam (REQ-LE-CREATE-001):** Today nothing creates a JOURNEY — `new/+page.svelte` mounts `GeschichteEditor` unconditionally and POSTs with no `type`, so this editor would be unreachable. Type selection at creation is owned by a **separate small frontend issue** (see Review Insights → Resolved Decisions). Since type is immutable after creation (REQ-LE-TYPE-001), it MUST be chosen at `/geschichten/new`. Link that issue as blocked-by before merge. ## Tasks ### `JourneyEditor` component (`src/lib/geschichte/JourneyEditor.svelte`) Rendered on the edit page when `type === 'JOURNEY'`. Contains: - **Ordered item list** — displays current items in position order - **"Add letter" action** — opens the existing document typeahead/picker; on select calls `POST /api/geschichten/{id}/items` with `documentId` - **"Add interlude note" action** — inline text input; on submit calls `POST /api/geschichten/{id}/items` with `note` - **Note editing** — each document item can have a note added/edited inline; calls `PATCH /api/geschichten/{id}/items/{itemId}` - **Remove item** — calls `DELETE /api/geschichten/{id}/items/{itemId}` - **Drag-to-reorder** — reordering calls `PUT /api/geschichten/{id}/items/reorder` - **Optional intro field** — maps to `Geschichte.body`; small textarea above the item list ### Edit page changes (`/geschichten/[id]/edit`) The `{#if geschichte.type === 'JOURNEY'}` branch goes in `edit/+page.svelte` — not inside `GeschichteEditor`. `GeschichteEditor` gets zero changes except factoring out the shared sidebar into `GeschichteSidebar.svelte`, which both editors import. Do not make `GeschichteEditor` type-aware. ## Requirements (EARS) - **REQ-LE-ITEM-001 (item content invariant):** Every item shall have a `documentId` OR a non-empty `note` (or both). The system shall reject (HTTP 400) an item with neither. This is the formal definition of a valid "interlude" and drives the "block empty interlude add" UI rule. - **REQ-LE-PUB-001 (last-item removal on published journey):** If the curator removes the last item from a PUBLISHED journey, the system shall allow the removal and show an inline warning ("Diese Reise wird ohne Einträge veröffentlicht bleiben") rather than blocking. The reader issue must spec an empty-journey state. - **REQ-LE-TYPE-001 (type immutability):** `GeschichteType` is set once at creation and is immutable thereafter. There is no STORY↔JOURNEY conversion path (a STORY's TipTap body and a JOURNEY's items are incompatible). No "change type" control shall exist, AND the API must reject a `type` change on PATCH (not only the UI). - **REQ-LE-CREATE-001 (type chosen at creation):** When creating a new Geschichte, the curator shall select the type (STORY or JOURNEY) before the editor opens; the type cannot be changed afterward. Owned by the dedicated `/geschichten/new` type-selector issue. - **REQ-LE-INTRO-001 (plaintext preservation):** When the Geschichte type is JOURNEY, the system shall store the intro as plain text, preserving all literal characters including `<`, `>`, and `&`. AC: a JOURNEY intro containing `Temperatur < 0`, when saved and reloaded, shows the full text including `< 0`. - **REQ-LE-INTERLUDE-EDIT-001 (interlude text is editable):** A pure interlude's text (note-only item, no `documentId`) shall be editable inline via the same note-PATCH mechanism used for document-item notes; the same confirm/blur-save model applies. The implementer MUST NOT make interludes immutable. - **REQ-LE-INTERLUDE-NONEMPTY-001 (interlude cannot be emptied):** The system shall block clearing the note of a note-only interlude (it would leave an item with neither `documentId` nor `note`, violating REQ-LE-ITEM-001). The "Notiz entfernen" control is ABSENT for note-only interludes; the only way to remove an interlude is "remove item". Editing an interlude to empty is also blocked (REQ-LE-ITEM-001 symmetry at edit time, not only add time). ## Acceptance criteria - [ ] Journey edit page renders `JourneyEditor`; when `type === 'JOURNEY'`, the edit page shows an ordered item list with "Brief hinzufügen" and "Zwischentext hinzufügen" buttons — no TipTap editor elements are visible (testable via `role="toolbar"` with `aria-label="Formatierung"` being absent) - [ ] When `type === 'STORY'`, the TipTap editor is rendered and no JourneyEditor elements are visible - [ ] Curator can add a document (letter) to the journey via document picker - [ ] The document picker marks already-added documents as "Bereits enthalten" with `aria-disabled="true"` and prevents re-adding them; they appear in search results but clicking them has no effect. The accessible name of a disabled option includes both the title and "bereits enthalten" so screen-reader users hear why it does nothing. - [ ] Curator can add a pure interlude note (no document); the "Zwischentext hinzufügen" confirm stays disabled until the interlude text is non-empty (REQ-LE-ITEM-001) - [ ] Curator can edit a pure interlude's text inline; clearing it is blocked (REQ-LE-INTERLUDE-EDIT-001 / -NONEMPTY-001); a note-only interlude has NO "Notiz entfernen" control - [ ] Curator can add a note to a document item that has no note - [ ] Curator can edit an existing note on a document item inline - [ ] Curator can remove a note from a DOCUMENT item; the textarea collapses and `note` is set to null via PATCH (control present only on document-items, never on note-only interludes) - [ ] Curator can remove any item; removing an item that has a note shows an inline confirmation ("Wirklich entfernen?" + Cancel/Bestätigen button pair in the row — not `window.confirm()`); removing one without a note does not - [ ] Removing the last item from a PUBLISHED journey is allowed and shows an inline warning (REQ-LE-PUB-001) - [ ] Curator can reorder items; a page reload after reordering shows items in the new order - [ ] When an item mutation (add/remove/reorder) fails, the list returns to its pre-mutation state and a user-visible error message is shown; DELETE/reorder failure messages include a reload prompt ("bitte Seite neu laden") - [ ] Optimistic add/remove renders the affected row in a visible "pending" state (dimmed `opacity-60` + an `aria-live="polite"` "wird hinzugefügt…" / "wird entfernt…" status) until the API resolves, so a rollback reads as "the tentative item went away" not "a real item vanished" - [ ] Optional intro field is editable and saved to `body` when the curator clicks "Speichern" or "Entwurf speichern" (not on blur); the intro carries NO blur hint and instead shows "Wird mit 'Speichern' gesichert." - [ ] JOURNEY intro preserves literal `<`, `>`, `&` on save+reload (REQ-LE-INTRO-001) - [ ] Navigating away after adding/reordering items only (without title/body/person edits) does NOT trigger the unsaved-changes warning, because item mutations are already persisted - [ ] Navigating away with an UNSAVED title/intro/person edit DOES trigger the unsaved-changes warning (inverse dirty-flag case) - [ ] All visible labels, button text, and error messages have translations in `messages/de.json`, `messages/en.json`, and `messages/es.json` - [ ] No TypeScript errors in changed files ## Review Insights ### Round 6 — Final Resolutions (these supersede any contradicting decision below) Round 6 re-examined six decisions against the **live code** and found the spec contradicted reality on two of them. All six are now closed: 1. **Intro storage → RAW-at-rest (SUPERSEDES "escape-as-text in the backend at write").** The editor loads `geschichte.body` straight into a `<textarea>` (`$state(geschichte?.body ?? '')`). Escaped-at-rest therefore shows the curator the literal `Temperatur &lt; 0` on reload and **double-escapes to `&amp;lt;` on re-save** — failing the editor half of REQ-LE-INTRO-001. Decision: persist the intro **raw** for JOURNEY. Skip `BODY_SANITIZER` on the JOURNEY branch (it drops `<`), store the raw textarea string, and let the reader's existing `{@html safeHtml(g.body)}` (DOMPurify) escape/sanitize at render — **one escaping site, at the reader, not two.** The editor textarea is `bind:value` only, never `{@html}`. Rationale: this is the only configuration where BOTH AC clauses pass (editor read-back shows raw `< 0`; reader shows rendered `< 0`). Cost: the DB column holds un-sanitized text for JOURNEY — acceptable **only because** every render path is `safeHtml`/`{text}` (see mandatory test below). _(Felix, Nora, Elicit)_ - **REQ-LE-INTRO-001 amended:** the editor textarea, on reload, shall display the raw characters the curator typed (`< 0`), NOT HTML entities. This disambiguates "preserve literal characters" (it means byte-preserved at rest AND raw in the editor, not merely render-preserved). - **Mandatory security control (load-bearing, not a nicety):** test `journey_body_render_path_is_sanitized` asserting the reader path interpolates through `safeHtml`/`{text}`, plus a code comment on the JOURNEY `body` semantics. Because the DB now holds raw text, this test is the sole stored-XSS defense — any future "journey intro preview" that uses `{@html}` without `safeHtml` is the regression to guard against. - Component test: GET-loaded textarea shows `Temperatur < 0` (raw); the `onSubmit` payload sends `body: 'Temperatur < 0'` raw on the wire; the reader renders `Temperatur < 0`. 2. **Keyboard reorder → reuse the existing move-up / move-down buttons from `TranscriptionEditView` (SUPERSEDES the Space/Arrow/Space keyboard-drag addition to `createBlockDragDrop`).** `createBlockDragDrop` has **zero** keyboard handling today, so keyboard-drag was net-new ~40 lines + a settle animation + live-region choreography. `TranscriptionEditView` already ships tested, accessible `handleMoveUp`/`handleMoveDown` buttons (lines 133–147). Decision: keep `createBlockDragDrop`'s **pointer**-drag for mouse (still generalize it to `createBlockDragDrop<T extends { id: string }>` for the pointer path and preserve the `data-block-wrapper`/`data-drag-handle` selector contract), and provide **keyboard accessibility via move-up/down buttons on each row** instead of keyboard-on-the-handle. Rationale: simpler, already tested, no new keyboard state machine, no `prefers-reduced-motion` settle transition, and arguably clearer for the 60+ audience than a drag affordance. The buttons fire the same optimistic-local-reorder + background-PUT + rollback path as pointer-drag. _(Felix)_ - **Consequences:** the `prefers-reduced-motion` "settle" requirement is dropped (no keyboard-drag animation). The `journey_item_moved` `aria-live` announcer stays — fire it on a button-move too. The E2E reorder step uses the move buttons (deterministic), not Space/Arrow/Space. Each move button needs a `min-h-[44px]`/`min-w-[44px]` hit area and a parameterized aria-label (e.g. `journey_move_up`/`journey_move_down` with `{title}`). Disable "up" on the first row and "down" on the last. 3. **Code-split → static-import both editors (do NOT dynamic-`import()` `JourneyEditor`).** Under SSR + `adapter-node`, a dynamic import inside the `{#if type === 'JOURNEY'}` branch is a no-op during SSR and renders nothing until the client chunk resolves — a flash-of-empty-editor on the exact page the 60+/slow-phone audience is working in, requiring a bespoke skeleton to paper over. The asymmetry: STORY already pays for TipTap; only JOURNEY would save by skipping it, and `JourneyEditor` (item list, no TipTap) is the light one. Decision: **static-import both**; the cost is JOURNEY users downloading TipTap once (cached thereafter), which is far cheaper than a first-paint loading state and an SSR-vs-client code path to maintain. KISS wins. _(Tobias)_ — **This supersedes the "Code-split the two editors" decision below.** 4. **Mobile save bar → non-sticky while a text field is focused (NOT an intro `max-h-[25vh]` cap).** At 320px with the soft keyboard up, a `max-h-[40vh]` intro + item list + a `sticky bottom-0` save bar overlap, hiding the item list or floating the bar over the keyboard. Decision: on `<sm`, toggle the save bar's `sticky` off while an input/textarea inside the editor is focused (a focus/blur listener), so it scrolls away with content during typing and returns sticky on blur. Chosen over capping the intro — a curator focused on a field is typing, not saving, so a non-sticky bar during typing is fine, and a long intro stays comfortable to edit. _(Leonie)_ - **AC additions:** a 320px test that **focuses the intro first**, then asserts the item list and a save control are both reachable (scroll). Run the dual-theme AxeBuilder scan ALSO at a reflow width (320 CSS px / 400% zoom) asserting the interlude row stacks without the handle+position+accent-border colliding. Tapping the `aria-disabled` Publish button moves **focus** into the now-open Status collapsible (not just scrolls it into view). - **Three save behaviors, three hints:** notes save on blur (`journey_note_save_hint`); intro/title save on Speichern only (`journey_intro_save_hint`, "Wird mit 'Speichern' gesichert."); interlude **add** uses an explicit confirm. Each carries its own `text-xs text-ink-2` hint, and the interlude-add confirm is a **labelled verb button ("Hinzufügen")**, not a checkmark icon. 5. **Backend read strategy → a dedicated `@Transactional(readOnly = true) getByIdWithItems(id)` that delegates to `getById(id)` first for the DRAFT guard, then runs the items fetch-join (do NOT make `getById` itself `@Transactional` in place).** `getById` is non-transactional today (line 63). Adding `@Transactional` to it would wrap the EAGER `persons` load and the `DRAFT && !blogWrite → 404` guard in a transaction and change the session lifecycle for **every STORY read** flowing through it — an acceptable-per-ADR-022 but **wider** blast radius where a STORY regression could hide inside a JOURNEY PR. The separate method keeps the STORY read path byte-for-byte unchanged at the cost of one delegating method. Record the alternative-and-why in the ADR fetch-strategy section. _(Markus)_ — refines the "JOURNEY item read inherits `getById`'s DRAFT guard" decision below toward the second listed option. 6. **Silent item removal on document delete → accepted, documented non-goal (no curator notification).** The `ON DELETE CASCADE` silently shortens a published journey when a letter is deleted elsewhere. For a family archive where document deletion is rare and admin-driven, silent removal is acceptable; a notification/tombstone would creep into the notification + reader-empty-slot domains. **Add this explicit non-goal:** "When a document is deleted, its journey item(s) are removed silently; the curator is not notified in-app." The cascade is reconstructable post-hoc **iff** document deletes are already audit-logged — confirm in the PR that the existing audit log captures document deletes; if so, no new observability hook is needed (do NOT add a journey-specific counter). _(Elicit, Tobias)_ **Additional round-6 requirement clarifications:** - **Type-source asymmetry (ADR point 4):** `create()` trusts `dto.getType()` (no existing entity — the `/geschichten/new` selector sets it); the create-path frozen-join guard is `if (dto.getType() == JOURNEY && documentIds present)`. `update()` trusts `g.getType()` and rejects a differing `dto.getType()`. These read the type from two different sources — spell both out so a reviewer doesn't write one guard for both. - **Type-immutability is a THREE-case matrix, not one:** PATCH with `type` omitted (null) → 200; `type` same as `g.type` → 200 (idempotent); `type` differing → 409. Test all three — the single 409 test invites a strict-equality bug that over-rejects every normal title-only PATCH. Allowed because a normal PATCH sends `type: null`. - **Republish invariant needs the POSITIVE test too:** alongside `item_add_on_published_journey_does_not_change_parent_updatedAt`, add `intro_save_on_published_journey_DOES_update_updatedAt`. Two tests pin the real contract (item mutations don't republish; an intro/title Speichern does); one test invites a wrong "freeze `updatedAt` everywhere" fix. - **Note XSS test (verbatim plaintext):** add `note_with_script_tag_is_stored_and_rendered_as_text` — a note containing `<img src=x onerror=alert(1)>` renders as literal text in BOTH editor preview and reader (notes are unsanitized plaintext; the only safety is `{text}` interpolation). - **Items persist across PUBLISHED↔DRAFT transitions** — status is independent of item content; retracting a journey to DRAFT does not touch its items. - **50-item NFR wording:** "no page-level / blocking spinner." The required per-row optimistic pending indicator (`opacity-60` + "wird hinzugefügt…") is exempt and required — it is NOT the spinner the NFR forbids. ### Resolved Decisions **DnD mechanism → reuse the in-house `createBlockDragDrop` module for POINTER drag + generalize; keyboard accessibility via move-up/down buttons (UPDATED round 6 — keyboard-drag dropped, see Round 6 Resolution #2).** A production PointerEvent reorder module already ships in the transcription editor (`useBlockDragDrop.svelte.ts`, callback `onReorder(ids: string[])`). Adding `svelte-dnd-action` or `@dnd-kit` would introduce a *second* DnD system. Decision: reuse `createBlockDragDrop` for **mouse/touch pointer drag**. The reuse is NOT a copy-paste — the module is hard-typed to `TranscriptionBlockData` and couples to DOM via literal selectors `[data-block-wrapper]` / `[data-drag-handle]` (lines 27, 37). Explicit tasks: (1) generalize the signature to `createBlockDragDrop<T extends { id: string }>(...)`; (2) **keep `data-block-wrapper` / `data-drag-handle` as the documented selector contract** and have `JourneyItemRow` emit those exact attributes (different attribute names silently no-op the pointer hit-testing); (3) **keyboard accessibility is provided by per-row move-up / move-down buttons** copied from `TranscriptionEditView` (`handleMoveUp`/`handleMoveDown`, lines 133–147) — NOT by adding Space/Arrow/Space to the handle (the module has zero keyboard handling today; keyboard-drag was net-new and is dropped); (4) ~~`prefers-reduced-motion` settle transition~~ DROPPED with keyboard-drag; (5) add a type-level test that transcription still compiles against the generalized signature so the refactor cannot regress the existing caller. A reviewer must reject a copy-paste fork — that recreates the exact second-DnD-system problem this decision prevents. Do NOT add `svelte-dnd-action` or `@dnd-kit`. **Type-regression guard runs in the Vitest/`tsc --noEmit` gate, NOT svelte-check (DECIDED round 5).** CI does NOT gate on `npm run check` (~834 pre-existing svelte-check errors). The "transcription still compiles against the generalized signature" guard MUST therefore be a real Vitest type-test (e.g. `expectTypeOf`/a compile-asserting spec) or a `tsc --noEmit` over the changed files — something that actually fails CI. A svelte-check-only guard would let a transcription-caller type regression ship silently. **Reorder API contract → ordered-ID array `{ itemIds: [...] }`, server assigns positions (SUPERSEDES `position = index * 10`).** Matches the existing transcription `{ blockIds: [...] }` contract. The reorder endpoint accepts `{ itemIds: [...] }` in display order; the **server is the only writer of `position`** and assigns `0..n` atomically in one transaction. This eliminates client-side gap arithmetic and makes the UNIQUE `(geschichte_id, position)` constraint trivially satisfiable. Display order on read is derived from `position` ASC; the client never computes `position`. **Reorder UI flow → optimistic local reorder + background PUT with rollback, NO refetch (DECIDED round 5, was open).** On `onReorder`, reorder the local `items` array immediately (synchronous), then fire the PUT in the background; on failure restore the pre-mutation snapshot and show a visible error. Do NOT refetch + re-sort after the PUT. Rationale: matches the optimistic add/remove model already specced; keeps the keyed-`{#each}` corruption test deterministic and synchronous (no race against a refetch); and the server's `position` assignment stays authoritative only on the next full page load — the transient cross-tab order drift this allows is already an accepted non-goal (last-write-wins). The server remains the only writer of `position`; the client's optimistic array is display-only until reload. Consequence for tests: the keyed-`{#each}` test reorders the local array synchronously with the PUT merely mocked-to-resolve — its assertion does NOT belong to the "PUT + GET, `toHaveBeenCalledTimes(2)`" pattern (that count belongs to the rollback test). Reconcile the two test descriptions accordingly. **Document search → reuse the existing `useTypeahead` hook (SUPERSEDES "create `useDocumentSearch`").** `$lib/shared/hooks/useTypeahead.svelte.ts` exists: `createTypeahead<T>({ fetchUrl, onSelect, debounceMs })`. Decision: (a) refactor `DocumentMultiSelect.svelte` (still on a raw pre-`useTypeahead` `setTimeout` debounce, lines ~36–56) onto `useTypeahead`, then (b) build `DocumentPickerDropdown` (single-select) on the same hook. "Reuse" here means reuse the HOOK, not the component — `DocumentMultiSelect` owns its own `selectedDocuments` chip state which the picker does NOT want (journey items live server-side). **Corrected `fetchUrl` (round 5 — the prior snippet was broken):** `DocumentController.search` returns a paginated `DocumentSearchResult` wrapper `{ items: [...] }`, NOT an array, and `createTypeahead<T>` expects `Promise<T[]>`. Use: `(q) => fetch('/api/documents/search?q=' + encodeURIComponent(q) + '&size=10').then(r => r.json()).then((b: { items: DocumentListItem[] }) => b.items)` Without the `.then(b => b.items)` the dropdown renders nothing. `useTypeahead` has a fixed 300ms debounce, always sets `isOpen = true` on `setQuery`, and has **no in-flight cancellation** (last-write-wins; request N can resolve after N+1). This is **accepted as-is** (KISS — a 10-result archive search does not justify an `AbortController`); the `alreadyAddedIds` derivation is `$derived` over `items` (not over results), so it stays correct regardless of which response lands last. State this in the PR so a reviewer doesn't flag it as a missing-cancellation bug. **Document picker empty-query behavior → keep the `< 1 char` guard (Option A, DECIDED round 5, was open).** `DocumentPickerDropdown` guards `query.trim().length >= 1` before calling `setQuery`; the dropdown stays empty until the curator types. Chosen over preloading the 10 most-recent on focus: consistent with the existing `DocumentMultiSelect` behavior, costs one guard line, and avoids a `q=&size=10` "latest 10 docs" surprise in what is labelled a search box. Add a unit test: `picker does not call fetch on empty query`. **`GeschichteItem` fetch strategy → lazy + dedicated `@Transactional(readOnly=true)` fetch-join (DECIDED).** Chosen over a third EAGER collection. The backend implements a `geschichteRepository`-level `LEFT JOIN FETCH g.items i ... ORDER BY i.position` query invoked from a `@Transactional(readOnly = true)` read method. Rationale: a third EAGER collection would cartesian-join with the existing EAGER `persons` set on **every** Geschichte GET including STORY reads that never use items, and `getById`/`list` are non-transactional today — a lazy `@OneToMany` serialized outside a transaction throws `LazyInitializationException` in production (ADR-022). The dedicated transactional fetch-join read pays no cost on STORY reads and avoids the lazy-init trap. **Item-load query shape → fetch ONLY items in the fetch-join; persons stay EAGER via the entity (DECIDED round 5, was open).** The `LEFT JOIN FETCH g.items` statement MUST NOT also `JOIN FETCH g.persons`. Fetching two collections (persons × items) in one statement produces a Cartesian row blow-up (and would `MultipleBagFetchException` if either were a `List`; they are `Set`s so it merely multiplies rows). Fetch one collection per query: items via the dedicated transactional fetch-join, persons via the entity's existing EAGER load — two SELECTs, no row multiplication, negligible at family-archive cardinality. Because the "documents frozen for JOURNEY" decision means `documents` is empty for journeys, only persons×items was the risk. Record this in the ADR fetch-strategy section so a future dev doesn't "optimize" into a Cartesian fetch. **JOURNEY item read inherits `getById`'s DRAFT guard — no guard-free read path (DECIDED round 5; read-strategy settled round 6).** Items must be exposed only as a FIELD on the existing, already-guarded `GET /api/geschichten/{id}` (and `list`). **Round 6 picks the second option:** a dedicated `@Transactional(readOnly = true) getByIdWithItems(id)` that calls `getById(id)` FIRST for the `DRAFT && !blogWrite → 404` guard, then runs the items fetch-join — leaving the STORY read path byte-for-byte unchanged (see Round 6 Resolution #5). Do NOT make `getById` itself `@Transactional` in place (wider STORY blast radius). Do NOT add a separate `GET /api/geschichten/{id}/items` endpoint and do NOT add a `findByIdWithItems` repository call that bypasses the DRAFT check — either would leak a DRAFT journey's items to anonymous readers. The `get_journey_returns_items_in_position_order` `@SpringBootTest` asserts against `getByIdWithItems`'s path. **JOURNEY `create()` AND `update()` both reject/ignore `documentIds` for the frozen join table (DECIDED round 5 — `create` was the missed path).** The "documents frozen for JOURNEY" rule is not update-only. `create()` (the JOURNEY-creation path via the `/geschichten/new` selector) currently builds `documents(resolveDocuments(dto.getDocumentIds()))` unconditionally — for a JOURNEY this would populate the frozen `geschichten_documents` table. Guard BOTH paths: `if (type == JOURNEY && documentIds present) ignore-or-reject`. This is the FOURTH ADR point (see ADR task). **JOURNEY intro encoding on write → store RAW for JOURNEY, escape only at the reader (UPDATED round 6 — SUPERSEDES "escape-as-text in the backend at write"; see Round 6 Resolution #1).** `GeschichteService.sanitize()` runs `body` through `BODY_SANITIZER` (OWASP `HtmlPolicyBuilder`), which treats `<` as markup and silently DROPS everything from a literal `<` onward. ~~Earlier decision: escape-as-text at write via `escapeHtml`.~~ That breaks the editor read-back (the textarea would show `&lt;` and double-escape on re-save). **Final decision: for JOURNEY, skip the HTML sanitizer entirely on the journey branch and persist the intro RAW** (the unescaped textarea string). The frontend sends `body` as the RAW textarea string (no `<p>` wrapping, no TipTap); the backend stores it verbatim; the reader's existing `{@html safeHtml(g.body)}` (DOMPurify) is the single escaping/sanitizing site. Add unit test `journey_intro_with_angle_bracket_is_preserved_as_text` (raw round-trip through the service), the component tests for raw textarea read-back + raw-on-the-wire PATCH, and the mandatory `journey_body_render_path_is_sanitized` reader-sanitization test (load-bearing stored-XSS control now that the column holds raw text). **JOURNEY creation ownership → a new small dedicated frontend issue (DECIDED; REQ-LE-CREATE-001).** Create the `/geschichten/new` type-selector issue and link it as a blocked-by dependency. Without it the editor is unreachable except via a manual DB insert. **`noteSaving` concurrency model → per-row independent PATCH (DECIDED).** Different rows share no state; cross-row serialization adds coupling for no benefit. Each row PATCHes independently with its own `itemId`; neither row rolls back the other. Race test asserts both PATCH calls fire with distinct itemIds (`toHaveBeenCalledTimes(2)`), no rollback on either. Keep a per-row `noteSaving = $state(false)` only to prevent two concurrent PATCH calls from the **same** row on a fast blur. **Interlude visual treatment → neutral surface + left accent border + "ZWISCHENTEXT" label (DECIDED).** Chosen over warm-orange (reads as caution/alarm to the 60+ audience). **Colors MUST be semantic tokens with explicit light AND dark values** — never raw `orange-*` or `text-blue-600`. Add `--color-interlude-bg`, `--color-interlude-border`, AND `--color-interlude-label` to `layout.css` (light + dark); replace every `orange-*`/`blue-600` literal in the spec's impl-ref with tokens; the note-action link uses `text-primary` (mint/navy). The "ZWISCHENTEXT" label color must independently pass 4.5:1 in BOTH themes (token it, or pin to `text-ink-2` which is token-safe) and include it in the dual-theme axe assertion. Merge blocker. **Item content invariant → block the add until non-empty (REQ-LE-ITEM-001).** "Zwischentext hinzufügen" confirm stays disabled (one `$derived`) until text is non-empty; backend rejects neither (400). Chosen over auto-DELETE-on-empty-blur (would race the just-completed POST). Symmetric rule now applies to interlude EDIT (REQ-LE-INTERLUDE-NONEMPTY-001): editing an interlude to empty is blocked too. **Last item removed from a PUBLISHED journey → allow + inline warning (REQ-LE-PUB-001).** Allow removal; show "Diese Reise wird ohne Einträge veröffentlicht bleiben". Reader issue must spec an empty-journey state. **Type immutability enforced at BOTH UI and API (REQ-LE-TYPE-001).** No "change type" control. The shared `GeschichteUpdateDTO` is mass-assignment-risky once `type` exists. `update()` must reject any differing `type`: `if (dto.getType() != null && dto.getType() != g.getType()) throw conflict(GESCHICHTE_TYPE_IMMUTABLE)`. **Test the THREE-case matrix (round 6):** `type` omitted/null → 200, `type` same → 200 (idempotent), `type` differing → 409 (`patch_cannot_change_geschichte_type`). The single-409 test alone invites a strict-equality bug that over-rejects normal title-only PATCHes (which send `type: null`). `create()` reads the type from `dto.getType()` (no existing entity); `update()` trusts `g.getType()` and rejects a differing `dto.getType()` — two different type sources, spell both out. This is a FIFTH new ErrorCode beyond the four item codes. **Item DTOs are minimal Java `record`s — NOT a shared/fat DTO (DECIDED round 5).** `record AddItemRequest(UUID documentId, String note)`, `record UpdateItemRequest(String note)`, `record ReorderRequest(List<UUID> itemIds)`. They MUST NOT reuse `GeschichteUpdateDTO` or carry `type`/`status`/`author`/`publishedAt` — otherwise a `PATCH /items/{id}` carrying `{"note":"x","status":"PUBLISHED"}` becomes a mass-assignment vector if any path reads those fields. Add test `patch_item_ignores_unexpected_fields` (send `status`/`type` in the item PATCH body; assert journey unchanged). **Item ownership validation → scoped query, not load-then-compare (DECIDED round 5).** Use `findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY)` (mirrors `TranscriptionService.getBlock`). A raw `findById` then `if (!item.getGeschichte().getId().equals(journeyId))` is a TOCTOU-ish smell, leaks existence via timing, AND — critically for the republish-abuse invariant — loading the managed parent risks dirtying it. Validate ownership with the scoped boolean/scoped-fetch query so no managed `Geschichte` is mutated. The reorder loop is scoped per-id the same way. **Reorder must validate a full permutation of the journey's current items (DECIDED round 5).** The submitted `itemIds` array must have the same size and the same id set as the journey's current items, with no duplicates → else 400. The transcription reorder loops positionally and would assign gapped positions on a partial array. Add `reorder_rejects_array_missing_an_item → 400` and `reorder_rejects_duplicate_item_id → 400` (alongside the existing cross-journey IDOR 403 test). **Editor loading → static-import both editors (UPDATED round 6 — SUPERSEDES the dynamic-`import()` code-split; see Round 6 Resolution #3).** Both `GeschichteEditor` (TipTap, STORY) and `JourneyEditor` (item list, JOURNEY) are statically imported on the edit page; the `{#if geschichte.type === 'JOURNEY'}` branch selects which renders. Dynamic `import()` was rejected: under SSR + `adapter-node` it renders nothing until the client chunk resolves (flash-of-empty-editor on a slow phone, needing a bespoke skeleton). The savings are asymmetric — STORY already pays for TipTap; only JOURNEY would save by skipping it. Downloading TipTap once on a JOURNEY edit (cached thereafter) is cheaper than a first-paint loading state plus an SSR/client code path. KISS. **Branching location → `edit/+page.svelte`, not inside `GeschichteEditor`.** Making `GeschichteEditor` type-aware via a `type` prop violates single-responsibility. **`GeschichteSidebar.svelte` — extract first.** Before any Journey logic, extract the Personen + Status sidebar from `GeschichteEditor` into `GeschichteSidebar.svelte`; both editors import it. On mobile these sections become collapsibles. **Confirmation dialog for remove-with-note → inline button pair.** Remove of an item with a note transforms the remove button into "Wirklich entfernen?" + Cancel/Bestätigen inline in the row. No `window.confirm()`. **Maximum note length → 2000 characters.** Flyway CHECK constraint AND `maxlength="2000"` on note textareas AND a backend service guard (`DomainException.badRequest(JOURNEY_NOTE_TOO_LONG)`) so the CHECK is a backstop, not the primary error path. **Hint text minimum size → `text-xs` (12px).** The spec's `text-[9px]` fails WCAG AA. Use `text-xs text-ink-2 mt-1`. Non-negotiable. **Mobile collapsibles → native `<details>/<summary>` with a 44px summary hit area.** Native `<summary>` defaults to ~20px (fails WCAG 2.5.5) — add `class="min-h-[44px] flex items-center px-4"`. **`Geschichte.documents` join table for JOURNEY type — deprecated.** For JOURNEY, `GeschichteItem` is the ordered source of truth. The backend rejects writes to the unordered `geschichten_documents` ManyToMany for JOURNEY geschichten (in BOTH `create` and `update`). For JOURNEY reads, null out / omit `documents` in the response. The frontend `JourneyEditor` reads items from the new `items` field, **never** from `geschichte.documents` (which `GeschichteEditor` uses on line 47). Add a code comment pointing at this decision. **Item-mutation vs. republish semantics (backend product + security decision).** `list()` orders by `COALESCE(publishedAt, updatedAt) DESC`. Item add/remove/reorder on a PUBLISHED journey must NOT bump `updatedAt` (republish recommendation: item mutations do not republish / do not re-sort the public list). Also a content-promotion-abuse vector: a BLOG_WRITE attacker could repeatedly add/remove an interlude to keep a journey pinned. The item endpoints save the *item*, not the parent; a `@OneToMany` won't dirty the parent unless code calls `geschichteRepository.save(parent)` — and ownership validation must use the scoped query so no managed parent is loaded+mutated (see decision above). Integration test: parent `updatedAt`/`publishedAt` unchanged after an item add on a PUBLISHED journey. **Concurrent curation → last-write-wins, stated non-goal.** Server-assigns-positions makes the write safe (no duplicate positions), but a stale-order reorder silently clobbers. Acceptable for a few-curator family archive. Explicitly a non-goal: no optimistic-locking conflict detection. **Items persist across PUBLISHED↔DRAFT transitions (round 6).** Item content is independent of status; retracting a journey to DRAFT does not touch its items, and the empty-published warning is publish-state-conditioned (clears on retract). Stated to close the unspecified un-publish path. **Silent item removal on document delete → explicit non-goal (round 6).** "When a document is deleted, its journey item(s) are removed silently via `ON DELETE CASCADE`; the curator is not notified in-app." Acceptable for a family archive (rare, admin-driven deletes). Confirm in the PR that document deletes are already audit-logged so the cascade is reconstructable post-hoc — if so, no new observability hook (do NOT add a journey-specific counter). **`geschichten_items.document_id` FK on document deletion → `ON DELETE CASCADE` (Option A, DECIDED round 5, was open).** Deleting a letter removes its journey item(s) silently — no orphan, no invariant-violating row. Chosen over RESTRICT (cross-domain delete friction + a new error in the document-delete flow) and over SET NULL (REJECTED: produces an item with no doc and no note, violating REQ-LE-ITEM-001). Rationale: document deletion is rare and admin-driven in a family archive; a dangling journey reference is worse than a silently-shortened journey. Add the `ON DELETE CASCADE` clause to the migration FK AND a test (`deleting_a_document_removes_its_journey_items`). **Write an ADR for the `GeschichteType` discriminator.** Record FOUR points: (1) the type discriminator that changes which child collection is authoritative; (2) the `GeschichteItem` lazy fetch-join read strategy — **items fetched alone, persons stay EAGER, never both in one statement**; (3) "items is the authoritative ordered collection; `geschichten_documents` is frozen for JOURNEY"; (4) **JOURNEY `create` (not only `update`) rejects/ignores `documentIds`**. ### Implementation Guidance **Component decomposition (do this first):** Extract `GeschichteSidebar.svelte`, `JourneyItemRow.svelte`, `JourneyAddBar.svelte` (two add buttons + picker state), `DocumentPickerDropdown.svelte` (single-select wrapper around `useTypeahead`). Define Props interfaces and event signatures first. Target: `JourneyEditor.svelte` under 150 lines. **`{#each}` must be keyed by `(item.id)`** — never by position or unkeyed. The corrupted state is **local `JourneyItemRow` state** (the note open/closed toggle + an unsaved draft buffer held in the row's own `$state`), NOT `item.note` bound to the array. So the row MUST hold the draft in its own `$state` before blur for the keying to matter. **Pre-mutation snapshot for rollback:** before any mutating API call, save `const previousItems = [...items]`. On failure: `items = previousItems` + show the error. Applies to add, remove, reorder, and note PATCH. **Named `$derived` values — do not inline in markup:** ```svelte const canPublish = $derived(items.length > 0 && title.trim().length > 0); const alreadyAddedIds = $derived(new Set(items.filter(i => i.documentId).map(i => i.documentId))); const canAddInterlude = $derived(interludeDraft.trim().length > 0); ``` Pass `alreadyAddedIds` to `DocumentPickerDropdown` for `aria-disabled` on already-added results. Plain derived `Set` is correct — do NOT use `SvelteSet`. **Reorder handler:** receives an ordered `string[]` of item IDs (from `createBlockDragDrop`'s `onReorder`), reorders the local array optimistically, PUTs `{ itemIds }` in the background, rolls back on failure. No refetch. The client never computes `position`. On initial load, sort items by `position` ASC; thereafter display order is the array order. **`DocumentPickerDropdown` ARIA (round 5 — fix, don't carry forward the `DocumentMultiSelect` smell):** input `role="combobox" aria-expanded aria-controls`; results container `role="listbox"`; each result `role="option"` with `aria-disabled="true"` for already-added (accessible name includes "bereits enthalten"). `DocumentMultiSelect`'s current per-result `role="button"` (line 161) must NOT be carried forward; `aria-disabled` only carries proper semantics inside a `listbox`. The dropdown must NOT be an `aria-live` region — use listbox + `aria-activedescendant` so it announces on navigation, not on mutation, keeping it out of the live-region collision the consolidation decision already worries about. Also keep the `query.trim().length >= 1` empty-query guard here before calling `setQuery`. **Two save models, signposted differently:** notes save on blur (with `journey_note_save_hint`); intro/title save only on Speichern. The intro must NOT carry a blur hint; it shows "Wird mit 'Speichern' gesichert." **Intro lives in the title/dirty lifecycle (round 5 clarification):** the intro is treated IDENTICALLY to title — `oninput` calls `markDirty()`, its value rides the Speichern PATCH `body`, and it is NEVER wired to an immediate per-keystroke PATCH. This reconciles "warn on unsaved intro" with "saved with Speichern, not on blur". **Dirty-flag scope + reuse `useUnsavedWarning`:** a reusable `$lib/shared/hooks/useUnsavedWarning.svelte.ts` exists (returns `markDirty/discard/clearOnSuccess/isDirty`). `GeschichteEditor` hand-rolls `beforeNavigate` + `window.confirm` (lines 103–108) — `JourneyEditor` adopts `useUnsavedWarning` instead. Instantiate it at TOP-LEVEL `<script>` scope (`const unsaved = createUnsavedWarning();`), never inside a function or `$effect`, or `beforeNavigate` throws "can only be called during component initialisation." Item mutations are immediate API calls with no pending local state — they MUST NOT call `markDirty` (a spurious dialog would fire). Tests: (a) after an item add `isDirty` stays false → no warning; (b) a title/intro/person edit sets dirty → warns. **Intro field auto-resize:** `rows={1}` + `oninput` setting `el.style.height='auto'; el.style.height = el.scrollHeight+'px'`; capture via `bind:this`. Add `max-h-[40vh] overflow-y-auto` so it can't grow unbounded and bury the item list / push the sticky save bar (matters at 320px mobile and 400% zoom). **`csrfFetch` covers DELETE and PUT** (`cookies.ts` line 37). The reorder `PUT` MUST go through `csrfFetch`, not raw `fetch`. Match existing transcription reorder. **Do NOT copy transcription's reorder error handling.** `TranscriptionEditView.svelte` line 128 swallows errors (`catch { /* ignore */ }`) with no rollback. Journeys deliberately diverge: rollback + visible error. Reviewers must not "match" the PR to that precedent. **`type="button"` mandatory** on all non-submit buttons in `JourneyEditor`/`JourneyAddBar` — both may be inside a `<form>`. **Permission check already satisfied — do not regress.** `edit/+page.server.ts` line 8 already enforces `canBlogWrite` and redirects. Do not re-implement; do not remove. **Micrometer cardinality (round 5):** the five item endpoints must use `@PathVariable UUID itemId` in templated paths (`/items/{itemId}`) so the `http_server_requests` `uri` tag stays templated and low-cardinality. A literal id in the route string would explode cardinality. **After implementation:** run `npm run generate:api` in `frontend/` once backend types are merged, and `npm run check 2>&1 | grep -E "src/lib/geschichte|src/routes/geschichten"` to confirm no new svelte-check errors (informational only — the type-regression GATE is the Vitest/`tsc` guard, not svelte-check). **First step in worktree:** `npm install` in `frontend/` before any code changes (pre-commit hook runs `npm run lint`). No new dependency is added. **Update `src/lib/geschichte/README.md`** to document the new components + props as part of this PR. ### Accessibility Requirements - Visually-hidden `<div aria-live="polite" aria-atomic="true">` in `JourneyEditor.svelte` announcing item position changes when an item is moved (via the move-up/down buttons — round 6, not keyboard-drag), via parameterized i18n key `journey_item_moved` — no hardcoded German. - **Consolidate to ONE move-announcer region.** Keep per-row pending-status (`journey_item_pending_add`/`_remove`) but make those regions live only while pending and **remove them from the DOM on resolve** (not just empty them), so two `aria-live="polite"` regions don't collide. The document picker is a THIRD potential live surface — it must use listbox/`aria-activedescendant` semantics (NOT a live region) so it stays out of this collision. - Pending optimistic state: dim affected rows to `opacity-60` with the per-row status removed on resolve. - Remove button: wrap the × in a `<button class="p-3 -m-1">` (or equivalent negative margin) so the touch target is ≥44×44px while the visible column stays narrow. - **Drag handle hit area ≥44px on desktop too.** The spec's `w-4` (16px) fails WCAG 2.5.5/2.5.8. Use `class="w-11 flex items-center justify-center cursor-grab"` with the glyph centered. - **Note-action links are buttons, not 12px text links.** "Notiz hinzufügen"/"Notiz entfernen" render as `<button class="min-h-[44px] inline-flex items-center gap-1 px-2 text-sm font-semibold text-primary">` with a leading icon (+ / ✕). - On mobile, use `aria-disabled="true"` (not HTML `disabled`) on the Publish button so an `onclick` can open the Status collapsible and scroll to it when tapped while disabled. - Savebar hint for published journeys: "Änderungen werden sofort für alle Leser sichtbar." - `aria-hidden="true"` on the "REISE" type-badge span. **Because the badge is hidden, the accessibility tree needs a non-visual mode cue:** make the edit-page `<h1>` (currently `{m.btn_edit()}: {title}`, type-neutral, edit/+page.svelte line 48) or a visually-hidden sibling type-aware — "Lesereise bearbeiten" for JOURNEY vs "Geschichte bearbeiten" for STORY — so a blind curator knows which editor mode they're in. - Check interlude fg/bg AND label contrast (≥4.5:1) in BOTH light and dark before merge. The single `AxeBuilder` E2E scan must run in BOTH themes (add the theme-toggle step) and assert all three interlude tokens (bg, border, label). - `<details>/<summary>` mobile collapsibles with `min-h-[44px] flex items-center px-4` on each `<summary>`. - **Mobile row layout — vertical stacking at `<sm`:** handle + position + title on row 1; note toggle + remove on a second line. Never truncate the document title. Use `min-w-0` + `break-words`, full-width on mobile. - Reduced-motion: any pointer-drag "settle"/return transition must respect `prefers-reduced-motion`. (The keyboard-drag settle is moot — round 6 replaced keyboard-drag with move-up/down buttons.) - **Move-up/down buttons (round 6):** each row has keyboard-accessible move-up and move-down buttons (`min-h-[44px] min-w-[44px]` hit area, `journey_move_up`/`journey_move_down` aria-labels with `{title}`). Disable "up" on the first row and "down" on the last. They fire the same optimistic-reorder + background-PUT + rollback path as pointer-drag and trigger the `journey_item_moved` announcement. - **Live-region text is plain interpolation only.** The `journey_item_moved` announcement and per-row pending status interpolate `{title}`; document titles are attacker-influenceable via import — never `{@html}` in a live region (reflected XSS). ### Security Flags (for backend implementer) - **No standalone `GET /items` endpoint** — items are a FIELD on the existing guarded `GET /api/geschichten/{id}` / `list`, inheriting the DRAFT 404 guard. A separate items GET (or a guard-free `findByIdWithItems`) re-opens the DRAFT leak. - **Type immutability at the API:** reject a differing `type` in PATCH with `DomainException.conflict(GESCHICHTE_TYPE_IMMUTABLE)`. Test create (type honored) AND update (type rejected → 409). - **Item ownership via scoped query** `findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY)` for PATCH/DELETE; reorder loop scoped per-id like `getBlock`. Never load-then-compare (TOCTOU smell + risks dirtying the managed parent → republish abuse). - **Reorder validates a full permutation** of the journey's current item set (same size, same id set, no dupes) → else 400. Closes the gapped-position / partial-apply hole the transcription loop doesn't guard. - **Item DTOs are minimal `record`s** (`AddItemRequest(documentId, note)`, `UpdateItemRequest(note)`, `ReorderRequest(itemIds)`) — no shared DTO with the parent, no `type`/`status`/`author`/`publishedAt`. Test `patch_item_ignores_unexpected_fields`. - **JOURNEY `create` AND `update` reject/ignore `documentIds`** (frozen `geschichten_documents`). - `POST /items` validates the `documentId` via `documentService.getDocumentById` (same path `resolveDocuments` uses) so a non-existent/forbidden id yields a mapped 404 `DOCUMENT_NOT_FOUND`, NOT a raw `findById` (dangling FK / 500). - `@RequirePermission(Permission.BLOG_WRITE)` on ALL five new item endpoints incl. the reorder `PUT`. A 403 test per endpoint, plus a 401-unauthenticated test for at least the reorder endpoint. - Note is stored verbatim (no sanitization, renders as text). Add a code comment on the `note` column/field: "stored as plain text; render with text interpolation only — never {@html}." Holds for any future "rich note" feature. - Flyway migration for `geschichten_items` must include: - UNIQUE `(geschichte_id, position)`. - **Partial UNIQUE `(geschichte_id, document_id) WHERE document_id IS NOT NULL`** — DB-level document dedup. Catch `DataIntegrityViolationException` → `DomainException.conflict(JOURNEY_DOCUMENT_ALREADY_ADDED)`. - CHECK constraint on `note` max length (2000 chars). - CHECK / service guard enforcing REQ-LE-ITEM-001 (`document_id` OR non-empty `note`). - **FK `document_id → documents` `ON DELETE CASCADE`** (Option A — deleting a letter removes its journey items; no orphan, no invariant violation). - Migration comment: "Forward-only; creates empty `geschichten_items`; does not read or modify `geschichten_documents`; no backfill (no JOURNEY rows exist pre-migration)." Add to `MigrationIntegrationTest`. Confirm the new version number is the strict max (FK ordering: table created after `geschichten` and `documents`). - New `ErrorCode` values (add to `ErrorCode.java`, `errors.ts`, all three `messages/*.json`): `JOURNEY_ITEM_NOT_FOUND` (404), `JOURNEY_ITEM_NOT_IN_JOURNEY` (403), `JOURNEY_NOTE_TOO_LONG` (400), `JOURNEY_DOCUMENT_ALREADY_ADDED` (409), **`GESCHICHTE_TYPE_IMMUTABLE` (409)**. - **XSS in the editor preview too, not only the reader.** Any preview of an interlude/note in the editor (LE-4 shows interlude text statically) MUST use `{text}` interpolation, never `{@html}`. Also applies to live-region announcements (see Accessibility). ### i18n Keys Required All three language files need at minimum: - `journey_add_document` — "Brief hinzufügen" - `journey_add_interlude` — "Zwischentext hinzufügen" - `journey_note_add` — "Notiz hinzufügen" - `journey_note_remove` — "Notiz entfernen" - `journey_note_save_hint` — "Wird gespeichert, wenn du das Feld verlässt." - `journey_intro_save_hint` — "Wird mit 'Speichern' gesichert." - `journey_already_added` — "Bereits enthalten" - `journey_note_aria_label` — "Kuratoren-Notiz für {title}" - `journey_drag_aria_label` — "Reihenfolge von '{title}' ändern" - `journey_move_up` — "'{title}' nach oben verschieben" (move-up button aria-label — round 6) - `journey_move_down` — "'{title}' nach unten verschieben" (move-down button aria-label — round 6) - `journey_note_error` — "Notiz konnte nicht gespeichert werden" - `journey_item_moved` — "Eintrag {position} von {total} — nach Position {newPosition} verschoben" - `journey_remove_confirm` — "Wirklich entfernen?" - `journey_mutation_error_reload` — (rollback error message with reload prompt) - `journey_item_pending_add` — "wird hinzugefügt…" - `journey_item_pending_remove` — "wird entfernt…" - `journey_published_empty_warning` — "Diese Reise wird ohne Einträge veröffentlicht bleiben" ### Test Plan **Component tests (`*.svelte.spec.ts`, vitest-browser):** - `edit/page.svelte.spec.ts` — branch tests (write first): renders TipTap toolbar (`role="toolbar"`) when STORY; does not when JOURNEY. - `JourneyEditor`: empty state renders; item list in position order; Publish disabled when `items.length === 0`; Publish disabled when `title.trim() === ''`; POST called with `documentId` on picker confirm; POST called with `note` on interlude submit; "Zwischentext" confirm disabled until text non-empty; DELETE called on remove click; rollback on failed mutation; item-add does NOT mark dirty (`isDirty` stays false); typing `Temperatur < 0` into intro + submit PATCHes `body: 'Temperatur < 0'` (un-escaped on the wire). - **Rollback tests (call counts reconciled, round 6):** mock `csrfFetch` per-test with `mockResolvedValueOnce()` for EVERY call in the path. **Reorder is optimistic-no-refetch → its success AND rollback tests assert `toHaveBeenCalledTimes(1)` on the reorder PUT URL (one PUT, no GET).** The `toHaveBeenCalledTimes(2)` "PUT/PATCH + GET" pattern belongs ONLY to add/remove paths that refetch — NOT to reorder. The note race test asserts `times(2)` across two DISTINCT itemIds (two independent PATCHes), which is a different surface. Assert (1) item list unchanged after rollback, (2) error message visible, (3) exact call count per the above. - **Reorder rollback — TWO triggers (round 6):** one test uses `mockResolvedValueOnce({ ok: false, status: 409 })` (non-ok response) and another uses `mockRejectedValueOnce(new Error('network'))` (promise rejection). The transcription precedent handles NEITHER (`if (!res.ok) return;` + `catch { /* ignore */ }`), so both are genuine regressions to guard. Same rollback path, two triggers. - **Reader-sanitization test (load-bearing XSS control, round 6):** `journey_body_render_path_is_sanitized` — assert the reader (`geschichten/[id]/+page.svelte`) interpolates `body` through `safeHtml`/`{text}`, never raw `{@html geschichte.body}`. Mandatory because JOURNEY `body` is now stored RAW. - `JourneyItemRow`: note textarea opens on "Notiz hinzufügen"; PATCH called on blur with non-empty value; textarea collapses + note cleared on blur empty (DOCUMENT item only); "Notiz entfernen" visible only when note non-empty AND item is a document-item (ABSENT for note-only interludes); remove `aria-label` contains document title; inline confirmation on remove-with-note; confirmation cancel restores item. - **Interlude edit tests:** a note-only interlude's text is editable inline; editing it to empty is blocked (confirm/save stays disabled); no "Notiz entfernen" control rendered on the interlude. - **Keyed `{#each}` corruption test (red first):** render 2 items; OPEN item-2's note textarea (toggle state) AND type an unsaved draft held in the ROW's own `$state` (do NOT bind to `item.note`, do NOT blur); reorder to swap 1↔2 by **invoking the reorder handler/callback directly** (the synchronous local-array reorder), NOT by simulating a pointer drag — pointer-capture + `getBoundingClientRect` hit-testing is flaky in headless vitest-browser (round 6). PUT merely mocked-to-resolve, no refetch await needed. Assert BOTH the textarea is still open AND the draft text is intact on the row now at position 1. The single real pointer/keyboard-button drag lives only in the Playwright E2E. Add a comment so a future dev doesn't "simplify" the key away. - **`noteSaving` race test (per-row independent PATCH):** blur row A (PATCH in-flight), immediately blur row B → assert both PATCH calls fire with distinct itemIds (`toHaveBeenCalledTimes(2)`), neither rolls back the other. - Deduplication: add a document, open picker, search the same document, assert it appears as `role="option" aria-disabled="true"` and its accessible name includes "bereits enthalten". - Picker empty-query: `picker does not call fetch on empty query` (the `< 1 char` guard). Mock boundary: `vi.mock('$lib/shared/cookies', () => ({ csrfFetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }) }))`. Use `mockResolvedValueOnce()` per-test. Trigger blur with `dispatchEvent(new FocusEvent('blur'))`. **Load-function test:** import `edit/+page.server.ts` directly; assert `load()` redirects to `/geschichten/{id}` when `canBlogWrite` is false. **Type-regression guard (NOT svelte-check):** a Vitest type-test or `tsc --noEmit` over the changed files asserting the transcription editor still compiles against the generalized `createBlockDragDrop<T>` signature. Must run in the Vitest/tsc gate (CI does not gate svelte-check). **Backend integration tests (Testcontainers Postgres, NOT H2):** - `reorder_returns_403_when_an_itemId_belongs_to_another_journey` - `reorder_rejects_array_missing_an_item → 400` - `reorder_rejects_duplicate_item_id → 400` - `patch_item_returns_403_when_item_not_in_journey` - `delete_item_returns_403_when_item_not_in_journey` - `patch_item_ignores_unexpected_fields` (send `status`/`type`; journey unchanged) - `reject_item_with_no_document_and_empty_note → 400` (REQ-LE-ITEM-001) - `duplicate_document_add_returns_409` (partial UNIQUE) - `patch_cannot_change_geschichte_type → 409` (REQ-LE-TYPE-001 API enforcement) — plus the two PASS cases: `patch_with_type_omitted → 200` and `patch_with_same_type → 200` (round-6 three-case matrix) - `journey_create_ignores_documentIds` (frozen join table on the create path) - `journey_intro_with_angle_bracket_is_preserved_as_text` (REQ-LE-INTRO-001 — raw round-trip, NOT escaped) - `note_with_script_tag_is_stored_and_rendered_as_text` (note is verbatim plaintext; `<img onerror>` renders literal in editor + reader) - `item_add_on_published_journey_does_not_change_parent_updatedAt` (republish/promotion-abuse invariant) - `intro_save_on_published_journey_DOES_update_updatedAt` (round-6 positive counterpart — pins that an intro/title Speichern DOES republish, so a wrong "freeze updatedAt everywhere" fix is caught) - `deleting_a_document_removes_its_journey_items` (FK `ON DELETE CASCADE`) - **`get_journey_returns_items_in_position_order`** — real `@SpringBootTest` + Testcontainers hitting `GET /api/geschichten/{id}`; assert serialized `items` populated and `ORDER BY position` AND `documents` is null/empty for the JOURNEY (the "frozen for JOURNEY" decision's only test). Only this HTTP-layer test catches a Lazy/serialization mismatch. - `parallel_item_posts_get_distinct_positions` — fire two POSTs concurrently; assert positions 0 and 1, no UNIQUE violation surfaced to the client (server serializes position assignment). - concurrency: two reorder calls in one test, assert no two items share a position. These IDOR/constraint tests belong permanently in the regression suite. **E2E (Playwright, ONE journey test):** full curator path — add document → add interlude → reorder → verify order persists on reload. **Use the move-up/down buttons for reorder** (deterministic; round 6 dropped keyboard-drag in favor of move buttons). One `AxeBuilder` scan at the end in BOTH light and dark themes, AND at a reflow width (320 CSS px / 400% zoom) asserting the interlude row stacks without the handle+position+accent-border colliding. Keep to ONE E2E (budget <8min). **Do NOT drive 50 add-letter UI clicks** — seed the 50-item NFR fixture via `@Sql`/a parallel-safe test-data API helper and assert the 50-item RENDER + one keyboard-drag completes without error or spinner. Budget-check `beforeAll` seeding time. ### NFR The editor must render and reorder up to 50 items without error or a **page-level / blocking spinner** (round 6 disambiguation — the required per-row optimistic pending indicator, `opacity-60` + "wird hinzugefügt…", is exempt and required; it is NOT the spinner this NFR forbids). The unmeasurable "no layout reflow" language is dropped. Made testable via the 50-item E2E fixture seeded out-of-band (`@Sql`/parallel-safe test-data helper, NOT 50 UI clicks; budget-check `beforeAll` seeding). No NFR for larger counts. ### DevOps / Deploy Notes - **Zero new dependencies** (`svelte-dnd-action`/`@dnd-kit` withdrawn; `useTypeahead`/`createBlockDragDrop` reused). No env var, no service, no Compose change. PR description must assert: "No `package.json`/`pom.xml` change. Reviewer: confirm both dependency manifests have empty diffs." The committed `docs/specs/lesereisen-editor-spec.html` STILL says "`@dnd-kit/core` oder `svelte-dnd-action`" in LE-2 impl-ref and STILL shows raw `orange-*`/`blue-600` classes — strike/update those stale lines; follow the issue body, not the HTML spec, on any disagreement. - The only deploy-time artifact is the Flyway migration (standard migration-in-CI Testcontainers path). Confirm the new version number is the strict max. - Item endpoint paths use `{itemId}` templating so Micrometer `uri` tags stay low-cardinality. - **JaCoCo branch gate is 88%, not 80%** (backend CLAUDE.md). The five item endpoints + type-immutability guard + plaintext-intro branch + create-rejects-documentIds branch all add branches — budget coverage to hit 88%. - No new observability hook — item mutations are routine CRUD covered by existing Actuator/Micrometer + GlitchTip. Do NOT add a bespoke "journey edited" counter. - The transcription-compiles guard runs in Vitest/`tsc --noEmit`, NOT svelte-check (CI doesn't gate svelte-check). - Frontend coverage gate applies to new code as usual. ### Documentation Blockers (required for PR merge) - New `GeschichteItem` entity → `docs/architecture/db/db-orm.puml` + `docs/architecture/db/db-relationships.puml` (new entity + FK to `geschichten` + FK to `documents` with CASCADE) - New `GeschichteType` enum on `Geschichte` → `docs/architecture/db/db-orm.puml` attribute update - ADR for the `GeschichteType` discriminator (4 points: discriminator; fetch strategy = items-alone fetch-join, persons EAGER, never both; "documents frozen for JOURNEY"; "create rejects documentIds") → `docs/adr/` - New `ErrorCode` values (incl. `GESCHICHTE_TYPE_IMMUTABLE`) → `CLAUDE.md` error handling section - `src/lib/geschichte/README.md` update (new components + props) - Strike stale `@dnd-kit`/`svelte-dnd-action` and raw `orange-*`/`blue-600` lines in `docs/specs/lesereisen-editor-spec.html` ### Confirmed Risks to Watch - **Copy-paste fork of `createBlockDragDrop`** instead of generalizing it — recreates the two-DnD-systems problem. The selector contract (`data-block-wrapper`/`data-drag-handle`) must be preserved or pointer hit-testing silently no-ops. - **Type-regression in the transcription caller** slipping through because the guard was written against svelte-check (ungated) instead of Vitest/`tsc`. - **`LazyInitializationException` on the JOURNEY read path** — only the `@SpringBootTest` HTTP-layer test catches it. - **Cartesian row blow-up** if the items fetch-join also fetches the EAGER `persons` in the same statement — fetch items alone. - **Silent intro data loss** on any intro containing `<` or `&` if the HTML sanitizer runs on the JOURNEY branch. - **Broken document picker** if the `fetchUrl` returns the `{items:[...]}` wrapper without `.then(b => b.items)` (renders nothing). - **Mass-assignment `type` flip** via PATCH bypassing the hidden UI control; and fat item DTOs leaking `status`/`type`. - **Frozen-join-table breach on the CREATE path** — `create()` populates `geschichten_documents` for a JOURNEY unless guarded (round-5 miss). - **Republish/content-promotion abuse** if item mutations dirty the parent's `updatedAt` — use scoped ownership queries so no managed parent is loaded+mutated. - **Invariant-violating orphan rows** if the document FK were SET NULL — must be CASCADE. - **Interlude emptied to nothing** if the "Notiz entfernen" control is wrongly rendered on note-only interludes (violates REQ-LE-ITEM-001). - **Reorder test flakiness / contradictory test specs** if reorder is refetch-based instead of optimistic-no-refetch. - **Stale committed HTML spec** (DnD library + raw colors) contradicting the resolved decisions. - **Keyed-`{#each}` test passing for the wrong reason** if the draft is bound to `item.note` rather than held in row-local `$state`; and flaking if it simulates a pointer drag instead of invoking the reorder handler directly. - **Editor intro read-back corruption (round 6):** storing the intro escaped-at-rest shows `&lt;` in the textarea and double-escapes (`&amp;lt;`) on re-save — the editor half of REQ-LE-INTRO-001 fails. Store RAW; escape only at the reader. - **Stored XSS via raw JOURNEY `body` (round 6):** now that the column holds raw text, any render path that isn't `safeHtml`/`{text}` is stored XSS. The `journey_body_render_path_is_sanitized` test is the load-bearing control. - **Flash-of-empty-editor (round 6):** a dynamic `import()` of the editor under SSR + adapter-node renders nothing until the client chunk resolves — static-import both editors instead. - **Mobile save-bar / keyboard overlap (round 6):** at 320px with the soft keyboard up, a sticky save bar + `max-h-[40vh]` intro + item list collide; the plain 320px render test misses it unless it focuses a field first. Drop `sticky` while a field is focused. - **Over-rejecting type-immutability (round 6):** a strict-equality guard that 409s on a `type: null` PATCH breaks every normal title-only save — test the three-case matrix (omitted/same/different). - **STORY read-path regression hidden in a JOURNEY PR (round 6):** making `getById` `@Transactional` in place changes STORY session semantics — use the separate `getByIdWithItems` delegating method. - **Republish invariant "fixed" the wrong way (round 6):** without the positive `intro_save_DOES_bump_updatedAt` test, someone could satisfy the negative test by freezing `updatedAt` everywhere. Pin both directions. - **Silent journey shortening (round 6, accepted non-goal):** `ON DELETE CASCADE` removes a published journey's item when a letter is deleted elsewhere, with no in-app notice — accepted, but confirm document deletes are audit-logged so the change is reconstructable.
marcel added this to the Lesereisen (Reading Journeys) milestone 2026-06-06 16:07:33 +02:00
marcel added the P1-highfeatureui labels 2026-06-06 16:08:08 +02:00
Author
Owner

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

Covers four screens:

  • LE-1 — Leerer JourneyEditor (Titel-Input, optionale Einleitung, Leerstate-Platzhalter, Veröffentlichen disabled bis ≥1 Item + Titel)
  • LE-2 — Editor mit gemischten Einträgen (Dokument-Items mit Drag-Handle + Positions-Nr., Interlude-Items mit orangenem Hintergrund, Notiz-Toggle, Entfernen-Button; veröffentlichte Journey mit Retract-Savebar)
  • LE-3 — Inline-Notiz-Editing (Textarea expandiert direkt unter dem Brief; Fokusring; blur → PATCH-Semantik)
  • LE-4 — Mobile (kein Split, Personen/Status als Collapsibles, Long-Press-Drag)

Desktop- und Mobile-Mockups + impl-ref-Tabellen für alle vier Screens. Enthält API-Referenz für alle fünf Item-Endpoints (add/note/remove/reorder).

UI spec committed to main: [`docs/specs/lesereisen-editor-spec.html`](https://git.raddatz.cloud/marcel/familienarchiv/src/branch/main/docs/specs/lesereisen-editor-spec.html) Covers four screens: - **LE-1** — Leerer `JourneyEditor` (Titel-Input, optionale Einleitung, Leerstate-Platzhalter, Veröffentlichen disabled bis ≥1 Item + Titel) - **LE-2** — Editor mit gemischten Einträgen (Dokument-Items mit Drag-Handle + Positions-Nr., Interlude-Items mit orangenem Hintergrund, Notiz-Toggle, Entfernen-Button; veröffentlichte Journey mit Retract-Savebar) - **LE-3** — Inline-Notiz-Editing (Textarea expandiert direkt unter dem Brief; Fokusring; blur → PATCH-Semantik) - **LE-4** — Mobile (kein Split, Personen/Status als Collapsibles, Long-Press-Drag) Desktop- und Mobile-Mockups + impl-ref-Tabellen für alle vier Screens. Enthält API-Referenz für alle fünf Item-Endpoints (add/note/remove/reorder).
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#753