diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 4d764a61..a0f00812 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte'; import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; -import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; +import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte'; import { csrfFetch } from '$lib/shared/cookies'; type Props = { diff --git a/frontend/src/lib/geschichte/JourneyAddBar.svelte b/frontend/src/lib/geschichte/JourneyAddBar.svelte index c9af4b61..c096e669 100644 --- a/frontend/src/lib/geschichte/JourneyAddBar.svelte +++ b/frontend/src/lib/geschichte/JourneyAddBar.svelte @@ -40,6 +40,7 @@ function handleInterludeCancel() {
- +
- {#if showRemoveConfirm} + {#if pendingRemove} + + {m.journey_item_pending_remove()} + + {:else if showRemoveConfirm}
{m.journey_remove_confirm()} @@ -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()} diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts index c22c874a..685f505f 100644 --- a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -1,12 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; import JourneyItemRow from './JourneyItemRow.svelte'; const docItem = (overrides: Partial<{ note: string }> = {}) => ({ id: 'item-1', position: 0, - document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const }, + document: { + id: 'doc-1', + title: 'Brief von Karl', + datePrecision: 'DAY' as const, + receiverCount: 0 + }, ...overrides }); @@ -28,11 +34,32 @@ const defaultProps = (overrides = {}) => ({ afterEach(() => cleanup()); +describe('JourneyItemRow — interlude label', () => { + it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps() }); + + await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument(); + await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument(); + }); + + it('uses "Zwischentext" in the move button aria-labels', async () => { + render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) }); + + await expect + .element( + page.getByRole('button', { + name: m.journey_move_up({ title: m.journey_interlude_label() }) + }) + ) + .toBeInTheDocument(); + }); +}); + describe('JourneyItemRow — note textarea', () => { it('opens note textarea on "Notiz hinzufügen" click', async () => { render(JourneyItemRow, { item: docItem(), ...defaultProps() }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); await expect .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })) @@ -43,13 +70,20 @@ describe('JourneyItemRow — note textarea', () => { const onNotePatch = vi.fn().mockResolvedValue(undefined); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); await userEvent.fill(textarea, 'Eine neue Notiz'); await textarea.element().dispatchEvent(new FocusEvent('blur')); expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz'); }); + + it('limits the note textarea to 2000 characters', async () => { + render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await expect.element(textarea).toHaveAttribute('maxlength', '2000'); + }); }); describe('JourneyItemRow — note error state', () => { @@ -57,7 +91,7 @@ describe('JourneyItemRow — note error state', () => { const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz hinzufügen')); + await userEvent.click(page.getByText(m.journey_note_add())); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); await userEvent.fill(textarea, 'Eine Notiz'); await textarea.element().dispatchEvent(new FocusEvent('blur')); @@ -74,8 +108,7 @@ describe('JourneyItemRow — note remove error state', () => { ...defaultProps({ onNotePatch }) }); - await userEvent.click(page.getByText('Notiz entfernen')); - await new Promise((r) => setTimeout(r, 50)); + await userEvent.click(page.getByText(m.journey_note_remove())); // textarea should be visible again (showNote restored) await expect @@ -95,7 +128,7 @@ describe('JourneyItemRow — interlude rules', () => { .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) .toBeInTheDocument(); // But "Notiz entfernen" must be absent - await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument(); + await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument(); }); it('blocks saving empty text on interlude note blur', async () => { @@ -111,6 +144,19 @@ describe('JourneyItemRow — interlude rules', () => { expect(onNotePatch).not.toHaveBeenCalled(); }); + + it('restores the original note text after a blocked empty-clear blur', async () => { + render(JourneyItemRow, { + item: interludeItem('original text'), + ...defaultProps() + }); + + const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); + await userEvent.clear(textarea); + await textarea.element().dispatchEvent(new FocusEvent('blur')); + + await expect.element(textarea).toHaveValue('original text'); + }); }); describe('JourneyItemRow — remove confirm', () => { @@ -121,10 +167,25 @@ describe('JourneyItemRow — remove confirm', () => { }); // Click remove (x button) - await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); - await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument(); - await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument(); + await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: m.journey_remove_confirm_yes() })) + .toBeInTheDocument(); + }); + + it('clicking Bestätigen invokes onRemove (destructive path)', async () => { + const onRemove = vi.fn(); + render(JourneyItemRow, { + item: docItem({ note: 'Wichtige Notiz' }), + ...defaultProps({ onRemove }) + }); + + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() })); + + expect(onRemove).toHaveBeenCalledTimes(1); }); it('confirm cancel restores remove button without calling onRemove', async () => { @@ -134,13 +195,55 @@ describe('JourneyItemRow — remove confirm', () => { ...defaultProps({ onRemove }) }); - await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); - await userEvent.click(page.getByRole('button', { name: 'Abbrechen' })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); expect(onRemove).not.toHaveBeenCalled(); // The remove button should be back await expect - .element(page.getByRole('button', { name: 'Eintrag entfernen' })) + .element(page.getByRole('button', { name: m.journey_remove_item_aria() })) .toBeInTheDocument(); }); + + it('confirm cancel returns keyboard focus to the row remove button', async () => { + render(JourneyItemRow, { + item: docItem({ note: 'Notiz' }), + ...defaultProps() + }); + + await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() })); + await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() })); + + await vi.waitFor(() => { + const removeBtn = page.getByRole('button', { name: m.journey_remove_item_aria() }).element(); + expect(document.activeElement).toBe(removeBtn); + }); + }); +}); + +describe('JourneyItemRow — pending remove state', () => { + it('renders dimmed with the pending text and without a remove button', async () => { + render(JourneyItemRow, { + item: docItem(), + ...defaultProps({ pendingRemove: true }) + }); + + await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: m.journey_remove_item_aria() })) + .not.toBeInTheDocument(); + + const root = document.querySelector('[data-block-id="item-1"]')!; + expect(root.className).toContain('opacity-60'); + }); +}); + +describe('JourneyItemRow — drag handle', () => { + it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => { + render(JourneyItemRow, { item: docItem(), ...defaultProps() }); + + const handle = document.querySelector('[data-drag-handle]')!; + expect(handle.getAttribute('tabindex')).toBe('-1'); + expect(handle.getAttribute('aria-hidden')).toBe('true'); + }); }); diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts similarity index 100% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.test.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts diff --git a/frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts b/frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts similarity index 100% rename from frontend/src/lib/document/transcription/useBlockDragDrop.svelte.ts rename to frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts diff --git a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts index 5376edb6..82098c52 100644 --- a/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; vi.mock('$app/navigation', () => ({ beforeNavigate: () => {}, @@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte'); afterEach(cleanup); const baseData = (overrides: Record = {}) => ({ + user: undefined, + canWrite: true, + canAnnotate: false, + canBlogWrite: true, geschichte: { id: 'g1', title: 'Die Reise nach Berlin', body: '

Im Jahr 1923...

', status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED', + type: 'STORY' as 'STORY' | 'JOURNEY', persons: [], - documents: [] + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' }, ...overrides }); @@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => { const inputs = document.querySelectorAll('input, textarea, [contenteditable]'); expect(inputs.length).toBeGreaterThan(0); }); + + it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '', + status: 'DRAFT' as const, + type: 'JOURNEY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByText(m.journey_add_document())).toBeVisible(); + expect(document.querySelector('[role="toolbar"]')).toBeNull(); + }); + + it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => { + render(GeschichtenEditPage, { + props: { + data: baseData({ + geschichte: { + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923...

', + status: 'DRAFT' as const, + type: 'STORY' as const, + persons: [], + items: [], + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' + } + }) + } + }); + + await expect.element(page.getByRole('toolbar')).toBeVisible(); + await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte b/frontend/src/routes/geschichten/new/JourneyCreate.svelte index f6f54395..cedd7d8b 100644 --- a/frontend/src/routes/geschichten/new/JourneyCreate.svelte +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte @@ -60,6 +60,7 @@ async function handleSubmit(e: SubmitEvent) { bind:value={title} onblur={() => (titleTouched = true)} placeholder={m.geschichte_editor_title_placeholder()} + aria-label={m.journey_title_aria_label()} aria-invalid={showTitleError} class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError ? 'border-danger' @@ -74,7 +75,7 @@ async function handleSubmit(e: SubmitEvent) { diff --git a/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts new file mode 100644 index 00000000..30ee0549 --- /dev/null +++ b/frontend/src/routes/geschichten/new/JourneyCreate.svelte.spec.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import { getErrorMessage } from '$lib/shared/errors'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: JourneyCreate } = await import('./JourneyCreate.svelte'); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('JourneyCreate — failure path', () => { + it('renders the mapped error message when POST /api/geschichten fails with a code', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + const alert = page.getByRole('alert'); + await expect.element(alert).toBeInTheDocument(); + await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR')); + }); + + it('navigates to the edit page on success', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ id: 'g-new' }) + }) + ); + + render(JourneyCreate, {}); + + await userEvent.fill( + page.getByRole('textbox', { name: m.journey_title_aria_label() }), + 'Meine Lesereise' + ); + await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() })); + + await vi.waitFor(() => { + expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit'); + }); + }); + + it('has an accessible label on the title input', async () => { + vi.stubGlobal('fetch', vi.fn()); + render(JourneyCreate, {}); + + await expect + .element(page.getByRole('textbox', { name: m.journey_title_aria_label() })) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index a4af174c..dfd048ff 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -77,6 +77,11 @@ --color-warning: #b45309; --color-warning-fg: #ffffff; + /* Warning surface — amber banner (bg/border/text), mode-aware */ + --color-warning-bg: var(--c-warning-bg); + --color-warning-border: var(--c-warning-border); + --color-warning-text: var(--c-warning-text); + /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ --color-journey-tint: var(--c-journey-bg); --color-journey: var(--c-journey-text); @@ -149,6 +154,11 @@ --c-interlude-border: #a1dcd8; --c-interlude-label: #4b5563; + /* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */ + --c-warning-bg: #fffbeb; + --c-warning-border: #fcd34d; + --c-warning-text: #92400e; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -278,6 +288,12 @@ --c-interlude-bg: #151c22; --c-interlude-border: #00c7b1; --c-interlude-label: #8b97a5; + + /* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓ + KEEP IN SYNC with :root[data-theme='dark'] */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } } @@ -363,6 +379,11 @@ --c-interlude-bg: #151c22; --c-interlude-border: #00c7b1; --c-interlude-label: #8b97a5; + + /* Warning surface — KEEP IN SYNC with the @media block above */ + --c-warning-bg: #2a2113; + --c-warning-border: #6d5417; + --c-warning-text: #fbd38d; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */