merge: resolve conflict with origin/main + fix WCAG AA contrast + add API test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m57s

- Merge origin/main (resolved conflict in +page.svelte: use res.ok check from main)
- fix(transcription): bump button text from text-brand-navy/60 (3.83:1) to
  text-brand-navy/80 (6.75:1) to pass WCAG AA 4.5:1 for 12px text
- feat(api-tests): add Transcription.http with PUT /review-all entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 08:11:58 +02:00
29 changed files with 752 additions and 67 deletions

View File

@@ -0,0 +1,3 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -154,6 +154,13 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden()); .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 @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -40,6 +40,26 @@ export default defineConfig(
parser: ts.parser, parser: ts.parser,
svelteConfig 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.'
}
]
} }
} }
); );

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Briefwechsel", "nav_conversations": "Briefwechsel",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Abmelden", "nav_logout": "Abmelden",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"btn_save": "Speichern", "btn_save": "Speichern",
"btn_cancel": "Abbrechen", "btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen", "btn_confirm": "Bestätigen",
@@ -816,6 +818,7 @@
"pagination_next": "Weiter", "pagination_next": "Weiter",
"pagination_page_of": "Seite {page} von {total}", "pagination_page_of": "Seite {page} von {total}",
"pagination_nav_label": "Seitennavigation", "pagination_nav_label": "Seitennavigation",
"pagination_page_button": "Seite {page}",
"common_opens_new_tab": "(öffnet in neuem Tab)", "common_opens_new_tab": "(öffnet in neuem Tab)",

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Letters", "nav_conversations": "Letters",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Sign out", "nav_logout": "Sign out",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"btn_save": "Save", "btn_save": "Save",
"btn_cancel": "Cancel", "btn_cancel": "Cancel",
"btn_confirm": "Confirm", "btn_confirm": "Confirm",
@@ -816,6 +818,7 @@
"pagination_next": "Next", "pagination_next": "Next",
"pagination_page_of": "Page {page} of {total}", "pagination_page_of": "Page {page} of {total}",
"pagination_nav_label": "Pagination", "pagination_nav_label": "Pagination",
"pagination_page_button": "Page {page}",
"common_opens_new_tab": "(opens in new tab)", "common_opens_new_tab": "(opens in new tab)",

View File

@@ -23,6 +23,8 @@
"nav_conversations": "Cartas", "nav_conversations": "Cartas",
"nav_admin": "Admin", "nav_admin": "Admin",
"nav_logout": "Cerrar sesión", "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_save": "Guardar",
"btn_cancel": "Cancelar", "btn_cancel": "Cancelar",
"btn_confirm": "Confirmar", "btn_confirm": "Confirmar",
@@ -816,6 +818,7 @@
"pagination_next": "Siguiente", "pagination_next": "Siguiente",
"pagination_page_of": "Página {page} de {total}", "pagination_page_of": "Página {page} de {total}",
"pagination_nav_label": "Paginación", "pagination_nav_label": "Paginación",
"pagination_page_button": "Página {page}",
"common_opens_new_tab": "(abre en pestaña nueva)", "common_opens_new_tab": "(abre en pestaña nueva)",

View File

@@ -18,7 +18,8 @@ let {
dimmed = false, dimmed = false,
flashAnnotationId = null, flashAnnotationId = null,
onDraw, onDraw,
onAnnotationClick onAnnotationClick,
onDeleteRequest
}: { }: {
annotations: Annotation[]; annotations: Annotation[];
canDraw: boolean; canDraw: boolean;
@@ -29,6 +30,7 @@ let {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void; onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props(); } = $props();
let drawStart = $state<{ x: number; y: number } | null>(null); let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -112,6 +114,8 @@ const containerStyle = $derived(
dimmed={dimmed} dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]} blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id} isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)} onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)} onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1'); 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, { render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')], annotations: [makeAnnotation('ann-1')],
canDraw: true, canDraw: true,
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
}); });
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); 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();
}); });
}); });

View File

@@ -11,6 +11,8 @@ let {
blockNumber = undefined, blockNumber = undefined,
isFlashing = false, isFlashing = false,
isResizable = false, isResizable = false,
showDelete = false,
onDeleteRequest,
onclick, onclick,
onpointerenter, onpointerenter,
onpointerleave onpointerleave
@@ -23,11 +25,15 @@ let {
blockNumber?: number | undefined; blockNumber?: number | undefined;
isFlashing?: boolean; isFlashing?: boolean;
isResizable?: boolean; isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void; onclick: () => void;
onpointerenter: () => void; onpointerenter: () => void;
onpointerleave: () => void; onpointerleave: () => void;
} = $props(); } = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string { function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16); const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16); const g = parseInt(hex.slice(3, 5), 16);
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
onclick={onclick} onclick={onclick}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(); if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}} }}
onpointerenter={onpointerenter} onpointerenter={onpointerenter}
onpointerleave={onpointerleave} onpointerleave={onpointerleave}
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
{blockNumber} {blockNumber}
</div> </div>
{/if} {/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable} {#if isResizable}
<AnnotationEditOverlay annotation={annotation} /> <AnnotationEditOverlay annotation={annotation} />
{/if} {/if}

View File

@@ -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();
});
});

View File

@@ -24,6 +24,7 @@ type Props = {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void; onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
}; };
let { let {
@@ -38,7 +39,8 @@ let {
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null, flashAnnotationId = null,
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -98,6 +100,7 @@ let {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick} onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw} onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null} documentFileHash={doc.fileHash ?? null}
/> />
{:else if fileUrl} {:else if fileUrl}

View File

@@ -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) { function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node; bellButtonEl = node;
return () => { return () => {
@@ -72,12 +78,11 @@ onDestroy(() => {
{@attach attachBellButton} {@attach attachBellButton}
type="button" type="button"
onclick={toggleDropdown} onclick={toggleDropdown}
aria-label={stream.unreadCount > 0 aria-label={bellLabel}
? m.notification_bell_unread_label({ count: stream.unreadCount }) title={bellLabel}
: m.notification_bell_label()}
aria-expanded={open} aria-expanded={open}
aria-haspopup="true" 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"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -55,6 +55,34 @@ async function openDropdownAndClickFirstNotification() {
notifButton.click(); notifButton.click();
} }
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
});
describe('NotificationBell', () => { describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => { it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })]; mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];

View File

@@ -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'; '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 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 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;
});
</script> </script>
{#if totalPages > 1} {#if totalPages > 1}
@@ -52,13 +94,60 @@ const disabledBase = `${controlBase} cursor-not-allowed opacity-40`;
</span> </span>
{/if} {/if}
<!-- Mobile: "Seite X von Y" label (hidden on sm: and above) -->
<!-- aria-hidden: decorative visual label; AT uses the sr-only span below for aria-current -->
<span <span
data-testid="pagination-page-label" data-testid="pagination-page-label"
aria-current="page" aria-hidden="true"
class="font-sans text-sm text-ink-2" class="font-sans text-sm text-ink-2 sm:hidden"
> >
{m.pagination_page_of({ page: page + 1, total: totalPages })} {m.pagination_page_of({ page: page + 1, total: totalPages })}
</span> </span>
<!-- Always in the AT tree: announces current page regardless of breakpoint.
On mobile, the desktop button container is display:none so this is the only AT anchor.
On desktop, the active page button also carries aria-current — both announce the same info. -->
<span data-testid="pagination-current-page-sr" aria-current="page" class="sr-only">
{m.pagination_page_of({ page: page + 1, total: totalPages })}
</span>
<!-- Desktop: numbered page buttons (hidden below sm:) -->
<div data-testid="pagination-pages" class="hidden items-center gap-1 sm:flex">
{#each pageWindow as entry, i (entry === null ? 'ellipsis-' + i : entry)}
{#if entry === null}
{#if i === 1}
<span
data-testid="pagination-ellipsis-left"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{:else}
<span
data-testid="pagination-ellipsis-right"
aria-hidden="true"
class="px-2 text-sm text-ink-2"></span
>
{/if}
{:else if entry === page + 1}
<span
data-testid="pagination-page-{entry}"
aria-current="page"
aria-label={m.pagination_page_button({ page: entry })}
class={activePageBase}
>
{entry}
</span>
{:else}
<a
data-testid="pagination-page-{entry}"
aria-label={m.pagination_page_button({ page: entry })}
href={makeHref(entry - 1)}
class={linkBase}
>
{entry}
</a>
{/if}
{/each}
</div>
{#if hasNext} {#if hasNext}
<a <a

View File

@@ -19,11 +19,145 @@ describe('Pagination', () => {
await expect.element(label).toHaveTextContent(/10/); 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 }); render(Pagination, { page: 0, totalPages: 3, makeHref });
const label = page.getByTestId('pagination-page-label'); 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 () => { it('renders prev as a link pointing at page - 1 when not on first page', async () => {

View File

@@ -91,7 +91,7 @@ let {
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()} 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 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' ? 'text-ink-2 hover:bg-surface/10'
: 'bg-surface/10 text-accent'}" : 'bg-surface/10 text-primary'}"
> >
<svg <svg
class="h-3.5 w-3.5 shrink-0" class="h-3.5 w-3.5 shrink-0"

View File

@@ -0,0 +1,67 @@
import { vi, describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import PdfControls from './PdfControls.svelte';
afterEach(cleanup);
const defaultProps = {
currentPage: 1,
totalPages: 3,
isLoaded: true,
showAnnotations: false,
annotationCount: 0,
onPrev: vi.fn(),
onNext: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onToggleAnnotations: vi.fn()
};
describe('PdfControls — annotation toggle visibility', () => {
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');
});
});

View File

@@ -18,6 +18,7 @@ let {
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw, onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash, documentFileHash,
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null flashAnnotationId = null
@@ -30,6 +31,7 @@ let {
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null; documentFileHash?: string | null;
annotationsDimmed?: boolean; annotationsDimmed?: boolean;
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onDraw={handleDraw} onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick} onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/> />
{/if} {/if}
</div> </div>

View File

@@ -19,7 +19,7 @@ let { percentage }: { percentage: number } = $props();
/> />
</svg> </svg>
<span <span
class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-accent' : 'text-gray-400'}" class="block text-center font-sans text-xs font-bold {percentage > 0 ? 'text-primary' : 'text-gray-400'}"
> >
{percentage}% {percentage}%
</span> </span>

View File

@@ -25,12 +25,12 @@ describe('ProgressRing', () => {
expect(el.className).toContain('text-gray-400'); 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 }); render(ProgressRing, { percentage: 75 });
const label = page.getByText('75%'); const label = page.getByText('75%');
await expect.element(label).toBeInTheDocument(); await expect.element(label).toBeInTheDocument();
const el = (await label.element()) as HTMLElement; 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 () => { it('renders a fully filled arc for 100%', async () => {

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
@@ -19,6 +20,10 @@ onMount(() => {
theme = resolveInitialTheme(); theme = resolveInitialTheme();
}); });
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() { function toggle() {
theme = theme === 'dark' ? 'light' : 'dark'; theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme); localStorage.setItem('theme', theme);
@@ -29,8 +34,8 @@ function toggle() {
<button <button
type="button" type="button"
onclick={toggle} onclick={toggle}
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'} aria-label={themeLabel}
title={theme === 'dark' ? 'light mode' : 'dark mode'} title={themeLabel}
class="rounded p-1.5 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="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{#if theme === 'dark'} {#if theme === 'dark'}

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ThemeToggle from './ThemeToggle.svelte';
afterEach(() => {
cleanup();
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
});
it('title equals aria-label in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
});
});

View File

@@ -170,7 +170,7 @@ async function handleLabelToggle(label: string) {
onclick={handleMarkAllReviewed} onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed} disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined} title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/60 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40" class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
> >
{#if markingAllReviewed} {#if markingAllReviewed}
<svg <svg

View File

@@ -120,7 +120,10 @@ async function handleAnnotationDeleteRequest(annotationId: string) {
await deleteBlock(block.id); await deleteBlock(block.id);
} else { } else {
// Annotation has no linked block — delete the annotation directly // Annotation has no linked block — delete the annotation directly
await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, { method: 'DELETE' }); const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++; annotationReloadKey++;
} }
} }

View File

@@ -365,6 +365,11 @@
text-underline-offset: 4px; text-underline-offset: 4px;
} }
/* Tailwind preflight resets cursor on *, overriding the browser default for buttons */
button {
cursor: pointer;
}
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */ /* Fallback focus ring for any interactive element not styled with ring-focus-ring */
:focus-visible { :focus-visible {
outline: 2px solid var(--c-focus-ring); outline: 2px solid var(--c-focus-ring);

View File

@@ -15,7 +15,7 @@ let mergeTargetId = $state('');
let showMergeConfirm = $state(false); let showMergeConfirm = $state(false);
</script> </script>
<div class="mb-10 overflow-hidden rounded-sm border border-line bg-surface shadow-sm"> <div class="mb-10 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
<div class="p-6 md:p-8"> <div class="p-6 md:p-8">
<h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2> <h2 class="mb-1 font-serif text-lg text-ink">{m.person_merge_heading()}</h2>
<p class="mb-5 font-sans text-sm text-ink-2"> <p class="mb-5 font-sans text-sm text-ink-2">

View File

@@ -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();
});
});

View File

@@ -5,7 +5,7 @@ import BackButton from '$lib/components/BackButton.svelte';
import PersonEditForm from './PersonEditForm.svelte'; import PersonEditForm from './PersonEditForm.svelte';
import PersonEditSaveBar from './PersonEditSaveBar.svelte'; import PersonEditSaveBar from './PersonEditSaveBar.svelte';
import NameHistoryEditCard from './NameHistoryEditCard.svelte'; import NameHistoryEditCard from './NameHistoryEditCard.svelte';
import PersonDangerZone from './PersonDangerZone.svelte'; import PersonMergePanel from '../PersonMergePanel.svelte';
let { data, form } = $props(); let { data, form } = $props();
const person = $derived(data.person); const person = $derived(data.person);
@@ -35,7 +35,9 @@ const person = $derived(data.person);
<NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} /> <NameHistoryEditCard aliases={data.aliases} canWrite={true} aliasError={form?.aliasError} />
<PersonDangerZone person={person} form={form} /> {#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}
<PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" /> <PersonEditSaveBar discardHref="/persons/{person.id}" formId="person-edit-form" />
</div> </div>

View File

@@ -1,44 +0,0 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import PersonMergePanel from '../PersonMergePanel.svelte';
let {
person,
form
}: {
person: { id: string; firstName?: string | null; lastName: string; displayName: string };
form?: { mergeError?: string } | null;
} = $props();
let open = $state(false);
</script>
<div class="mt-8 overflow-hidden rounded-sm border border-red-200 bg-surface shadow-sm">
<button
type="button"
onclick={() => (open = !open)}
class="flex w-full items-center justify-between px-6 py-4 text-left"
aria-expanded={open}
>
<span class="text-sm font-bold tracking-widest text-red-600 uppercase">
{m.person_danger_zone_heading()}
</span>
<svg
class="h-4 w-4 text-red-400 transition-transform {open ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if open}
<div class="border-t border-red-100 px-6 py-4">
{#key person.id}
<PersonMergePanel person={person} form={form} />
{/key}
</div>
{/if}
</div>