fix(journey): editor review round — labels, errors, pending state, a11y, tests

Addresses the remaining #792 review blockers and concerns in the journey
editor cluster:

- Interlude rows show 'Zwischentext' (dedicated key), not the add-button text
- All four mutation handlers route the backend ErrorCode through
  getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden')
  and console.error their failures so client-side errors leave a trace
- Remove implements the spec'd pending state: row stays dimmed with an
  aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row
- Move announcements fire after the reorder resolves (no false 'verschoben')
- Touch targets ≥44px (remove ×, note links, create submit); focus moves to
  the new row after add, to a sensible neighbor after remove, back to × on
  confirm-cancel; drag handle is pointer-only; title/intro get aria-labels;
  publish-disabled reason is a visible hint, not a title tooltip
- Amber warning styles use new --color-warning-* tokens with dark remaps
- Blocked interlude-clear restores the draft instead of showing phantom text
- useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports
  another domain's internals
- Test hardening: reorder-failure rollback (non-ok + reject), publish/
  unpublish/empty-warning surface, destructive confirm path, maxlength
  assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch,
  fixture factory, m.* assertions, all fixed sleeps replaced with polling

67 component tests green across 6 spec files; transcription consumer of the
moved hook re-verified (30 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 07:55:12 +02:00
parent 7977d22d0b
commit f10b0cb73e
13 changed files with 843 additions and 268 deletions

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
@@ -8,18 +9,29 @@ interface Props {
item: JourneyItemView;
index: number;
total: number;
pendingRemove?: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
onNotePatch: (note: string | null) => Promise<void>;
}
let { item, index, total, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props();
let {
item,
index,
total,
pendingRemove = false,
onMoveUp,
onMoveDown,
onRemove,
onNotePatch
}: Props = $props();
const isInterlude = $derived(!item.document);
const itemTitle = $derived(item.document?.title ?? m.journey_add_interlude());
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
const needsConfirmOnRemove = $derived(!!item.note);
let rootEl: HTMLElement | null = $state(null);
let showNote = $state(!!item.note);
let noteDraft = $state(item.note ?? '');
let noteSaving = $state(false);
@@ -29,7 +41,12 @@ let showRemoveConfirm = $state(false);
async function handleNoteBlur() {
if (noteSaving) return;
if (noteDraft === item.note) return;
if (isInterlude && noteDraft.trim().length === 0) return;
if (isInterlude && noteDraft.trim().length === 0) {
// Interludes must keep a note — restore the draft so the UI doesn't show
// an emptied text that the server still holds.
noteDraft = item.note ?? '';
return;
}
noteSaving = true;
noteError = '';
@@ -64,23 +81,37 @@ function handleRemoveClick() {
onRemove();
}
}
function handleRemoveConfirm() {
showRemoveConfirm = false;
onRemove();
}
async function handleRemoveCancel() {
showRemoveConfirm = false;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
}
</script>
<div
bind:this={rootEl}
data-block-id={item.id}
class={[
'flex min-w-0 flex-col rounded border transition-colors',
pendingRemove ? 'opacity-60' : '',
isInterlude
? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
: 'border-line bg-surface'
].join(' ')}
>
<div class="flex min-w-0 items-start gap-1 px-2 py-2">
<!-- Drag handle (desktop) -->
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button
type="button"
data-drag-handle
aria-label={m.journey_drag_aria_label({ title: itemTitle })}
tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;"
>
@@ -91,6 +122,7 @@ function handleRemoveClick() {
<div class="flex shrink-0 flex-col">
<button
type="button"
data-move-up
onclick={onMoveUp}
disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })}
@@ -120,7 +152,7 @@ function handleRemoveClick() {
class="font-sans text-xs font-bold tracking-widest uppercase"
style="color: var(--color-interlude-label);"
>
{m.journey_add_interlude()}
{m.journey_interlude_label()}
</span>
{:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
@@ -128,21 +160,25 @@ function handleRemoveClick() {
{/if}
</div>
<!-- Remove button / confirm -->
<!-- Remove button / confirm / pending -->
<div class="shrink-0">
{#if showRemoveConfirm}
{#if pendingRemove}
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
{m.journey_item_pending_remove()}
</span>
{:else if showRemoveConfirm}
<div class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button
type="button"
onclick={onRemove}
onclick={handleRemoveConfirm}
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_yes()}
</button>
<button
type="button"
onclick={() => (showRemoveConfirm = false)}
onclick={handleRemoveCancel}
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_remove_confirm_cancel()}
@@ -151,9 +187,10 @@ function handleRemoveClick() {
{:else}
<button
type="button"
data-remove-btn
onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria()}
class="-m-1 rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
class="h-4 w-4"
@@ -186,7 +223,7 @@ function handleRemoveClick() {
<button
type="button"
onclick={handleNoteRemove}
class="font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_remove()}
</button>
@@ -203,7 +240,7 @@ function handleRemoveClick() {
onclick={() => {
showNote = true;
}}
class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.journey_note_add()}
</button>