diff --git a/backend/api_tests/Transcription.http b/backend/api_tests/Transcription.http new file mode 100644 index 00000000..7f3aa250 --- /dev/null +++ b/backend/api_tests/Transcription.http @@ -0,0 +1,3 @@ +### Mark all blocks as reviewed +PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all +Authorization: Basic admin admin123 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 7a4546b6..368a21a0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -154,6 +154,13 @@ class AnnotationControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception { + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + @Test @WithMockUser(authorities = "ANNOTATE_ALL") void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index b1d59d3c..3de63ff2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -40,6 +40,26 @@ export default defineConfig( parser: ts.parser, svelteConfig } + }, + rules: { + // text-accent resolves to #a1dcd8 in light mode (1.52:1 on white — WCAG fail). + // layout.css documents it as decorative-only (borders, icon tints, bg fills). + // For any text label use text-primary or text-ink instead. This rule catches + // the pattern where text-accent appears inside a JavaScript string literal + // (e.g. conditional ternary class expressions in Svelte templates). + 'no-restricted-syntax': [ + 'error', + { + selector: 'Literal[value=/\\btext-accent\\b/]', + message: + 'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.' + }, + { + selector: 'TemplateLiteral > TemplateElement[value.raw=/\\btext-accent\\b/]', + message: + 'text-accent is decorative-only (#a1dcd8 in light mode = 1.52:1 contrast — WCAG fail). Use text-primary or text-ink-2 for text labels.' + } + ] } } ); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8522580c..7fa3867e 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -23,6 +23,8 @@ "nav_conversations": "Briefwechsel", "nav_admin": "Admin", "nav_logout": "Abmelden", + "theme_toggle_to_light": "Zu hellem Design wechseln", + "theme_toggle_to_dark": "Zu dunklem Design wechseln", "btn_save": "Speichern", "btn_cancel": "Abbrechen", "btn_confirm": "Bestätigen", @@ -816,6 +818,7 @@ "pagination_next": "Weiter", "pagination_page_of": "Seite {page} von {total}", "pagination_nav_label": "Seitennavigation", + "pagination_page_button": "Seite {page}", "common_opens_new_tab": "(öffnet in neuem Tab)", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cddafac1..f718d543 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -23,6 +23,8 @@ "nav_conversations": "Letters", "nav_admin": "Admin", "nav_logout": "Sign out", + "theme_toggle_to_light": "Switch to light mode", + "theme_toggle_to_dark": "Switch to dark mode", "btn_save": "Save", "btn_cancel": "Cancel", "btn_confirm": "Confirm", @@ -816,6 +818,7 @@ "pagination_next": "Next", "pagination_page_of": "Page {page} of {total}", "pagination_nav_label": "Pagination", + "pagination_page_button": "Page {page}", "common_opens_new_tab": "(opens in new tab)", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f5125884..86837a8f 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -23,6 +23,8 @@ "nav_conversations": "Cartas", "nav_admin": "Admin", "nav_logout": "Cerrar sesión", + "theme_toggle_to_light": "Cambiar a modo claro", + "theme_toggle_to_dark": "Cambiar a modo oscuro", "btn_save": "Guardar", "btn_cancel": "Cancelar", "btn_confirm": "Confirmar", @@ -816,6 +818,7 @@ "pagination_next": "Siguiente", "pagination_page_of": "Página {page} de {total}", "pagination_nav_label": "Paginación", + "pagination_page_button": "Página {page}", "common_opens_new_tab": "(abre en pestaña nueva)", diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index ac7dbc7c..129fef36 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -18,7 +18,8 @@ let { dimmed = false, flashAnnotationId = null, onDraw, - onAnnotationClick + onAnnotationClick, + onDeleteRequest }: { annotations: Annotation[]; canDraw: boolean; @@ -29,6 +30,7 @@ let { flashAnnotationId?: string | null; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; + onDeleteRequest?: (annotationId: string) => void; } = $props(); let drawStart = $state<{ x: number; y: number } | null>(null); @@ -112,6 +114,8 @@ const containerStyle = $derived( dimmed={dimmed} blockNumber={blockNumbers[annotation.id]} isFlashing={flashAnnotationId === annotation.id} + showDelete={canDraw} + onDeleteRequest={() => onDeleteRequest?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)} onpointerenter={() => (hoveredId = annotation.id)} onpointerleave={() => (hoveredId = null)} diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts index 7a4b4e07..b134c50f 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -98,7 +98,7 @@ describe('AnnotationLayer', () => { expect(el2.style.opacity).toBe('1'); }); - it('does not show delete buttons (annotations owned by blocks)', async () => { + it('does not show delete button when annotation is not hovered or active', async () => { render(AnnotationLayer, { annotations: [makeAnnotation('ann-1')], canDraw: true, @@ -107,6 +107,19 @@ describe('AnnotationLayer', () => { }); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); - expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull(); + expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull(); + }); + + it('does not show delete button when canDraw is false even if annotation is active', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canDraw: false, + color: '#00C7B1', + activeAnnotationId: 'ann-1', + onDraw: () => {} + }); + + await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); + expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull(); }); }); diff --git a/frontend/src/lib/components/AnnotationShape.svelte b/frontend/src/lib/components/AnnotationShape.svelte index a7c667f2..31be34f4 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte +++ b/frontend/src/lib/components/AnnotationShape.svelte @@ -11,6 +11,8 @@ let { blockNumber = undefined, isFlashing = false, isResizable = false, + showDelete = false, + onDeleteRequest, onclick, onpointerenter, onpointerleave @@ -23,11 +25,15 @@ let { blockNumber?: number | undefined; isFlashing?: boolean; isResizable?: boolean; + showDelete?: boolean; + onDeleteRequest?: () => void; onclick: () => void; onpointerenter: () => void; onpointerleave: () => void; } = $props(); +const deleteVisible = $derived(showDelete && (isHovered || isActive)); + function hexToRgba(hex: string, alpha: number): string { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); @@ -83,6 +89,7 @@ let shapeStyle = $derived( onclick={onclick} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onclick(); + if (e.key === 'Delete' && showDelete) onDeleteRequest?.(); }} onpointerenter={onpointerenter} onpointerleave={onpointerleave} @@ -112,6 +119,51 @@ let shapeStyle = $derived( {blockNumber} {/if} + {#if deleteVisible} + + {/if} {#if isResizable} {/if} diff --git a/frontend/src/lib/components/AnnotationShape.svelte.spec.ts b/frontend/src/lib/components/AnnotationShape.svelte.spec.ts new file mode 100644 index 00000000..87f1cae1 --- /dev/null +++ b/frontend/src/lib/components/AnnotationShape.svelte.spec.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import AnnotationShape from './AnnotationShape.svelte'; + +afterEach(cleanup); + +function makeAnnotation(id = 'ann-1') { + return { + id, + documentId: 'doc-1', + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.2, + color: '#00C7B1', + createdAt: new Date().toISOString() + }; +} + +describe('AnnotationShape', () => { + it('renders the annotation element', async () => { + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: false, + isActive: false, + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); + }); + + it('does not show delete button when showDelete is false', async () => { + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: true, + isActive: false, + showDelete: false, + onDeleteRequest: vi.fn(), + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull(); + }); + + it('does not show delete button when showDelete is true but neither hovered nor active', async () => { + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: false, + isActive: false, + showDelete: true, + onDeleteRequest: vi.fn(), + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull(); + }); + + it('shows delete button when showDelete is true and isHovered is true', async () => { + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: true, + isActive: false, + showDelete: true, + onDeleteRequest: vi.fn(), + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument(); + }); + + it('shows delete button when showDelete is true and isActive is true', async () => { + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: false, + isActive: true, + showDelete: true, + onDeleteRequest: vi.fn(), + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument(); + }); + + it('calls onDeleteRequest when delete button is clicked', async () => { + const onDeleteRequest = vi.fn(); + + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: true, + isActive: false, + showDelete: true, + onDeleteRequest, + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + const deleteBtn = page.getByTestId('annotation-delete-ann-1'); + await deleteBtn.click(); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + }); + + it('does not call onclick when delete button is clicked', async () => { + const onclick = vi.fn(); + const onDeleteRequest = vi.fn(); + + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: true, + isActive: false, + showDelete: true, + onDeleteRequest, + onclick, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + const deleteBtn = page.getByTestId('annotation-delete-ann-1'); + await deleteBtn.click(); + + expect(onclick).not.toHaveBeenCalled(); + expect(onDeleteRequest).toHaveBeenCalledOnce(); + }); + + it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => { + const onDeleteRequest = vi.fn(); + + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: false, + isActive: true, + showDelete: true, + onDeleteRequest, + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; + annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); + + expect(onDeleteRequest).toHaveBeenCalledOnce(); + }); + + it('does not call onDeleteRequest on Delete key when showDelete is false', async () => { + const onDeleteRequest = vi.fn(); + + render(AnnotationShape, { + annotation: makeAnnotation(), + isHovered: false, + isActive: true, + showDelete: false, + onDeleteRequest, + onclick: () => {}, + onpointerenter: () => {}, + onpointerleave: () => {} + }); + + const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; + annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); + + expect(onDeleteRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index c48d1f2a..b22f3d6b 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -24,6 +24,7 @@ type Props = { flashAnnotationId?: string | null; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; + onDeleteAnnotationRequest?: (annotationId: string) => void; }; let { @@ -38,7 +39,8 @@ let { annotationsDimmed = false, flashAnnotationId = null, onAnnotationClick, - onTranscriptionDraw + onTranscriptionDraw, + onDeleteAnnotationRequest }: Props = $props(); @@ -98,6 +100,7 @@ let { flashAnnotationId={flashAnnotationId} onAnnotationClick={onAnnotationClick} onTranscriptionDraw={onTranscriptionDraw} + onDeleteAnnotationRequest={onDeleteAnnotationRequest} documentFileHash={doc.fileHash ?? null} /> {:else if fileUrl} diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index 0e390357..a06d90e4 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -48,6 +48,12 @@ function handleKeydown(event: KeyboardEvent) { } } +const bellLabel = $derived( + stream.unreadCount > 0 + ? m.notification_bell_unread_label({ count: stream.unreadCount }) + : m.notification_bell_label() +); + function attachBellButton(node: HTMLButtonElement) { bellButtonEl = node; return () => { @@ -72,12 +78,11 @@ onDestroy(() => { {@attach attachBellButton} type="button" onclick={toggleDropdown} - aria-label={stream.unreadCount > 0 - ? m.notification_bell_unread_label({ count: stream.unreadCount }) - : m.notification_bell_label()} + aria-label={bellLabel} + title={bellLabel} aria-expanded={open} aria-haspopup="true" - class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" > { + it('bell button has cursor-pointer class', async () => { + render(NotificationBell); + const btn = document.querySelector('button[aria-haspopup="true"]')!; + expect(btn.classList.contains('cursor-pointer')).toBe(true); + }); + + it('bell button title equals aria-label when unreadCount is 0', async () => { + mockNotificationList.value = []; + render(NotificationBell); + const btn = document.querySelector('button[aria-haspopup="true"]')!; + expect(btn.getAttribute('title')).toBe('Benachrichtigungen'); + expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); + }); + + it('bell button title equals aria-label when unreadCount is 3', async () => { + mockNotificationList.value = [ + makeNotification({ id: 'n1' }), + makeNotification({ id: 'n2' }), + makeNotification({ id: 'n3' }) + ]; + render(NotificationBell); + const btn = document.querySelector('button[aria-haspopup="true"]')!; + expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen'); + expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); + }); +}); + describe('NotificationBell', () => { it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => { mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })]; diff --git a/frontend/src/lib/components/Pagination.svelte b/frontend/src/lib/components/Pagination.svelte index 01659e9e..b9c4834b 100644 --- a/frontend/src/lib/components/Pagination.svelte +++ b/frontend/src/lib/components/Pagination.svelte @@ -20,6 +20,48 @@ const controlBase = 'inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1.5 rounded-sm border border-line bg-white px-4 py-2 font-sans text-sm font-bold text-ink'; const linkBase = `${controlBase} transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none`; const disabledBase = `${controlBase} cursor-not-allowed opacity-40`; +const activePageBase = + 'inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded-sm border border-brand-navy bg-brand-navy px-4 py-2 font-sans text-sm font-bold text-white'; + +/** + * Builds the sliding window of 1-indexed page numbers to render as buttons. + * Always shows: first, last, current, one neighbor each side. + * null entries represent ellipsis gaps. + */ +const pageWindow = $derived.by(() => { + const first = 1; + const last = totalPages; + const current = page + 1; // convert to 1-indexed + + const windowStart = Math.max(first, current - 1); + const windowEnd = Math.min(last, current + 1); + + const result: (number | null)[] = []; + + result.push(first); + + if (windowStart > first + 2) { + result.push(null); // left ellipsis + } else if (windowStart === first + 2) { + result.push(first + 1); // bridge: one page gap, show directly instead of ellipsis + } + + for (let p = Math.max(windowStart, first + 1); p <= Math.min(windowEnd, last - 1); p++) { + result.push(p); + } + + if (windowEnd < last - 2) { + result.push(null); // right ellipsis + } else if (windowEnd === last - 2) { + result.push(last - 1); // bridge: one page gap, show directly instead of ellipsis + } + + if (last > first) { + result.push(last); + } + + return result; +}); {#if totalPages > 1} @@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`; {/if} + + + + + {m.pagination_page_of({ page: page + 1, total: totalPages })} + + + + {#if hasNext} { await expect.element(label).toHaveTextContent(/10/); }); - it('marks the current page label with aria-current="page"', async () => { + it('mobile page label is aria-hidden (desktop buttons carry the aria-current role)', async () => { render(Pagination, { page: 0, totalPages: 3, makeHref }); const label = page.getByTestId('pagination-page-label'); - await expect.element(label).toHaveAttribute('aria-current', 'page'); + await expect.element(label).toHaveAttribute('aria-hidden', 'true'); + }); + + describe('page number buttons', () => { + it('renders page number buttons when totalPages > 1', async () => { + render(Pagination, { page: 4, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + // active page button — the current page (5, 1-indexed) + const activeBtn = nav.getByTestId('pagination-page-5'); + await expect.element(activeBtn).toBeInTheDocument(); + }); + + it('does not render page number buttons when totalPages <= 1', async () => { + render(Pagination, { page: 0, totalPages: 1, makeHref }); + + // entire nav is hidden + const nav = page.getByRole('navigation'); + await expect.element(nav).not.toBeInTheDocument(); + }); + + it('marks the active page button with aria-current="page"', async () => { + render(Pagination, { page: 4, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const activeBtn = nav.getByTestId('pagination-page-5'); + await expect.element(activeBtn).toHaveAttribute('aria-current', 'page'); + }); + + it('active page button has brand-navy background', async () => { + render(Pagination, { page: 4, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const activeBtn = nav.getByTestId('pagination-page-5'); + await expect.element(activeBtn).toHaveClass(/bg-brand-navy/); + }); + + it('active page button has 44px touch target', async () => { + render(Pagination, { page: 4, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const activeBtn = nav.getByTestId('pagination-page-5'); + await expect.element(activeBtn).toHaveClass(/min-h-\[44px\]/); + await expect.element(activeBtn).toHaveClass(/min-w-\[44px\]/); + }); + + it('inactive page buttons link to their target page via makeHref', async () => { + const spy = vi.fn(makeHref); + render(Pagination, { page: 4, totalPages: 12, makeHref: spy }); + + const nav = page.getByRole('navigation'); + // page button for page 1 (0-indexed: 0) should link to /documents?page=0 + const firstPageBtn = nav.getByTestId('pagination-page-1'); + await expect.element(firstPageBtn).toHaveAttribute('href', '/documents?page=0'); + }); + + it('renders first and last page buttons always visible', async () => { + render(Pagination, { page: 5, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument(); + await expect.element(nav.getByTestId('pagination-page-12')).toBeInTheDocument(); + }); + + it('renders ellipsis span between first page and window when gap exists', async () => { + // page 6 (0-indexed: 5) — window is 5,6,7 — gap between 1 and 5 + render(Pagination, { page: 5, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const ellipses = nav.getByTestId('pagination-ellipsis-left'); + await expect.element(ellipses).toBeInTheDocument(); + }); + + it('renders ellipsis span between window and last page when gap exists', async () => { + // page 1 (0-indexed: 0) — window is 1,2 — gap between 2 and 12 + render(Pagination, { page: 0, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const ellipsis = nav.getByTestId('pagination-ellipsis-right'); + await expect.element(ellipsis).toBeInTheDocument(); + }); + + it('does not render left ellipsis when window is adjacent to first page', async () => { + // page 1 (0-indexed: 0) — window starts at 1, adjacent to first page + render(Pagination, { page: 0, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const leftEllipsis = nav.getByTestId('pagination-ellipsis-left'); + await expect.element(leftEllipsis).not.toBeInTheDocument(); + }); + + it('does not render right ellipsis when window is adjacent to last page', async () => { + // last page (0-indexed: 11) — window ends at 12, adjacent to last page + render(Pagination, { page: 11, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const rightEllipsis = nav.getByTestId('pagination-ellipsis-right'); + await expect.element(rightEllipsis).not.toBeInTheDocument(); + }); + + it('page buttons container has hidden class on mobile (sm: prefix)', async () => { + // The page buttons container must be hidden below sm: breakpoint + render(Pagination, { page: 4, totalPages: 12, makeHref }); + + const nav = page.getByRole('navigation'); + const pageButtons = nav.getByTestId('pagination-pages'); + await expect.element(pageButtons).toHaveClass(/hidden/); + await expect.element(pageButtons).toHaveClass(/sm:flex/); + }); + + it('renders both pages without ellipsis when totalPages is 2', async () => { + render(Pagination, { page: 0, totalPages: 2, makeHref }); + + const nav = page.getByRole('navigation'); + await expect.element(nav.getByTestId('pagination-page-1')).toBeInTheDocument(); + await expect.element(nav.getByTestId('pagination-page-2')).toBeInTheDocument(); + await expect.element(nav.getByTestId('pagination-ellipsis-left')).not.toBeInTheDocument(); + await expect.element(nav.getByTestId('pagination-ellipsis-right')).not.toBeInTheDocument(); + }); + }); + + it('mobile page label is aria-hidden so screen readers skip it on wide screens', async () => { + render(Pagination, { page: 2, totalPages: 10, makeHref }); + + const label = page.getByTestId('pagination-page-label'); + await expect.element(label).toHaveAttribute('aria-hidden', 'true'); + }); + + it('sr-only span always provides aria-current="page" for screen readers at all breakpoints', async () => { + render(Pagination, { page: 2, totalPages: 10, makeHref }); + + const nav = page.getByRole('navigation'); + const srLabel = nav.getByTestId('pagination-current-page-sr'); + await expect.element(srLabel).toBeInTheDocument(); + await expect.element(srLabel).toHaveAttribute('aria-current', 'page'); }); it('renders prev as a link pointing at page - 1 when not on first page', async () => { diff --git a/frontend/src/lib/components/PdfControls.svelte b/frontend/src/lib/components/PdfControls.svelte index 17c3ef96..da586f82 100644 --- a/frontend/src/lib/components/PdfControls.svelte +++ b/frontend/src/lib/components/PdfControls.svelte @@ -91,7 +91,7 @@ let { aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()} class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations ? 'text-ink-2 hover:bg-surface/10' - : 'bg-surface/10 text-accent'}" + : 'bg-surface/10 text-primary'}" > { + it('renders annotation toggle when annotationCount is greater than zero', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 3 }); + await expect + .element(page.getByRole('button', { name: /annotierungen anzeigen/i })) + .toBeInTheDocument(); + }); + + it('does not render annotation toggle when annotationCount is zero', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 0 }); + await expect + .element(page.getByRole('button', { name: /annotierungen/i })) + .not.toBeInTheDocument(); + }); +}); + +describe('PdfControls — annotation toggle label', () => { + it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false }); + const btn = page.getByRole('button', { name: /annotierungen anzeigen/i }); + await expect.element(btn).toBeInTheDocument(); + }); + + it('shows "Annotierungen verbergen" label when annotations are visible', async () => { + render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true }); + const btn = page.getByRole('button', { name: /annotierungen verbergen/i }); + await expect.element(btn).toBeInTheDocument(); + }); +}); + +describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => { + it('uses text-primary class on annotation toggle button when annotations are hidden', async () => { + const { container } = render(PdfControls, { + ...defaultProps, + annotationCount: 2, + showAnnotations: false + }); + const allButtons = container.querySelectorAll('button'); + const annotationBtn = Array.from(allButtons).find((b) => + b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen') + ); + expect(annotationBtn).not.toBeNull(); + expect(annotationBtn!.className).toContain('text-primary'); + expect(annotationBtn!.className).not.toContain('text-accent'); + }); +}); diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 844aa338..7642b197 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -18,6 +18,7 @@ let { activeAnnotationId = $bindable(null), onAnnotationClick, onTranscriptionDraw, + onDeleteAnnotationRequest, documentFileHash, annotationsDimmed = false, flashAnnotationId = null @@ -30,6 +31,7 @@ let { activeAnnotationId?: string | null; onAnnotationClick?: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; + onDeleteAnnotationRequest?: (annotationId: string) => void; documentFileHash?: string | null; annotationsDimmed?: boolean; flashAnnotationId?: string | null; @@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) { flashAnnotationId={flashAnnotationId} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} + onDeleteRequest={onDeleteAnnotationRequest} /> {/if} diff --git a/frontend/src/lib/components/ProgressRing.svelte b/frontend/src/lib/components/ProgressRing.svelte index 389a3b9a..32992300 100644 --- a/frontend/src/lib/components/ProgressRing.svelte +++ b/frontend/src/lib/components/ProgressRing.svelte @@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props(); /> {percentage}% diff --git a/frontend/src/lib/components/ProgressRing.svelte.spec.ts b/frontend/src/lib/components/ProgressRing.svelte.spec.ts index 8efc36e5..c3e1ef46 100644 --- a/frontend/src/lib/components/ProgressRing.svelte.spec.ts +++ b/frontend/src/lib/components/ProgressRing.svelte.spec.ts @@ -25,12 +25,12 @@ describe('ProgressRing', () => { expect(el.className).toContain('text-gray-400'); }); - it('renders a mint-colored label when percentage is > 0', async () => { + it('renders a primary-colored label when percentage is > 0', async () => { render(ProgressRing, { percentage: 75 }); const label = page.getByText('75%'); await expect.element(label).toBeInTheDocument(); const el = (await label.element()) as HTMLElement; - expect(el.className).toContain('text-accent'); + expect(el.className).toContain('text-primary'); }); it('renders a fully filled arc for 100%', async () => { diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte index 012fd670..834f765c 100644 --- a/frontend/src/lib/components/ThemeToggle.svelte +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -1,5 +1,6 @@ -
+

{m.person_merge_heading()}

diff --git a/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts new file mode 100644 index 00000000..4cfe9898 --- /dev/null +++ b/frontend/src/routes/persons/[id]/PersonMergePanel.svelte.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import PersonMergePanel from './PersonMergePanel.svelte'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); +vi.mock('$lib/components/PersonTypeahead.svelte', () => ({ + default: () => null +})); + +afterEach(cleanup); + +const makePerson = (overrides = {}) => ({ + displayName: 'Hans Müller', + ...overrides +}); + +// ─── Danger indicator ──────────────────────────────────────────────────────── + +describe('PersonMergePanel — danger indicator', () => { + it('renders outer container with red border class', () => { + const { container } = render(PersonMergePanel, { + props: { person: makePerson(), form: null } + }); + const panel = container.firstElementChild as HTMLElement; + expect(panel?.classList.contains('border-red-200')).toBe(true); + }); +}); + +// ─── Initial state ──────────────────────────────────────────────────────────── + +describe('PersonMergePanel — initial state', () => { + it('renders merge heading', async () => { + render(PersonMergePanel, { props: { person: makePerson(), form: null } }); + const heading = page.getByRole('heading', { level: 2 }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('merge button is disabled when no target selected', async () => { + render(PersonMergePanel, { props: { person: makePerson(), form: null } }); + const mergeBtn = page.getByRole('button', { name: /zusammenführen/i }); + await expect.element(mergeBtn).toBeDisabled(); + }); +}); + +// ─── Error state ────────────────────────────────────────────────────────────── + +describe('PersonMergePanel — error state', () => { + it('renders mergeError when form contains error', async () => { + render(PersonMergePanel, { + props: { person: makePerson(), form: { mergeError: 'Zielperson nicht gefunden.' } } + }); + await expect.element(page.getByText('Zielperson nicht gefunden.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/+page.svelte b/frontend/src/routes/persons/[id]/edit/+page.svelte index 43701e13..b49dc471 100644 --- a/frontend/src/routes/persons/[id]/edit/+page.svelte +++ b/frontend/src/routes/persons/[id]/edit/+page.svelte @@ -5,7 +5,7 @@ import BackButton from '$lib/components/BackButton.svelte'; import PersonEditForm from './PersonEditForm.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte'; import NameHistoryEditCard from './NameHistoryEditCard.svelte'; -import PersonDangerZone from './PersonDangerZone.svelte'; +import PersonMergePanel from '../PersonMergePanel.svelte'; let { data, form } = $props(); const person = $derived(data.person); @@ -35,7 +35,9 @@ const person = $derived(data.person); - + {#key person.id} + + {/key}

diff --git a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte b/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte deleted file mode 100644 index 4161e1fc..00000000 --- a/frontend/src/routes/persons/[id]/edit/PersonDangerZone.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
- - - {#if open} -
- {#key person.id} - - {/key} -
- {/if} -