diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 3024029b..53fd4114 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -223,6 +223,12 @@
"admin_label_initial_password": "Passwort",
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
"doc_download_title": "Herunterladen",
+ "topbar_back_label": "Zurück zur Dokumentenliste",
+ "topbar_more_actions": "Weitere Aktionen",
+ "topbar_overflow_more": "+{count} weitere",
+ "topbar_overflow_suffix": "weitere",
+ "topbar_overflow_heading": "Weitere Empfänger",
+ "topbar_overflow_show": "{count} weitere Empfänger anzeigen",
"doc_tag_filter_title": "Nach {name} filtern",
"doc_conversation_title": "Konversation anzeigen",
"doc_preview_iframe_title": "Dokumentvorschau",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "Verlauf",
"doc_panel_annotate": "Annotieren",
"doc_panel_annotate_stop": "Fertig",
+ "doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
"pdf_annotations_show": "Annotierungen anzeigen",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 109e7060..3ad3cfdb 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -223,6 +223,12 @@
"admin_label_initial_password": "Password",
"doc_file_error_preview": "Could not load preview.",
"doc_download_title": "Download",
+ "topbar_back_label": "Back to document list",
+ "topbar_more_actions": "More actions",
+ "topbar_overflow_more": "+{count} more",
+ "topbar_overflow_suffix": "more",
+ "topbar_overflow_heading": "More receivers",
+ "topbar_overflow_show": "Show {count} more receivers",
"doc_tag_filter_title": "Filter by {name}",
"doc_conversation_title": "Show conversation",
"doc_preview_iframe_title": "Document Preview",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "History",
"doc_panel_annotate": "Annotate",
"doc_panel_annotate_stop": "Done",
+ "doc_panel_annotate_hint": "Click and drag to mark an area",
"doc_panel_annotation_thread_title": "Annotation",
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
"pdf_annotations_show": "Show annotations",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index c9b445c6..8f8110c2 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -223,6 +223,12 @@
"admin_label_initial_password": "Contraseña",
"doc_file_error_preview": "No se pudo cargar la vista previa.",
"doc_download_title": "Descargar",
+ "topbar_back_label": "Volver a la lista de documentos",
+ "topbar_more_actions": "Más acciones",
+ "topbar_overflow_more": "+{count} más",
+ "topbar_overflow_suffix": "más",
+ "topbar_overflow_heading": "Más destinatarios",
+ "topbar_overflow_show": "Mostrar {count} destinatarios más",
"doc_tag_filter_title": "Filtrar por {name}",
"doc_conversation_title": "Ver conversación",
"doc_preview_iframe_title": "Vista previa del documento",
@@ -321,6 +327,7 @@
"doc_panel_tab_history": "Historial",
"doc_panel_annotate": "Anotar",
"doc_panel_annotate_stop": "Listo",
+ "doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
"doc_panel_annotation_thread_title": "Anotación",
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
"pdf_annotations_show": "Mostrar anotaciones",
diff --git a/frontend/src/lib/actions/clickOutside.svelte.spec.ts b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
new file mode 100644
index 00000000..81917cb0
--- /dev/null
+++ b/frontend/src/lib/actions/clickOutside.svelte.spec.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect, afterEach } from 'vitest';
+
+const { clickOutside } = await import('./clickOutside');
+
+describe('clickOutside action', () => {
+ const nodes: HTMLElement[] = [];
+
+ function makeNode(): HTMLElement {
+ const node = document.createElement('div');
+ document.body.appendChild(node);
+ nodes.push(node);
+ return node;
+ }
+
+ afterEach(() => {
+ nodes.forEach((n) => n.remove());
+ nodes.length = 0;
+ });
+
+ it('registers a capture-phase click listener on mount', () => {
+ const node = makeNode();
+ const original = document.addEventListener.bind(document);
+ let registered = false;
+ document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
+ if (type === 'click' && opts === true) registered = true;
+ original(type as string, _fn as EventListener, opts as boolean);
+ };
+ clickOutside(node);
+ expect(registered).toBe(true);
+ document.addEventListener = original;
+ });
+
+ it('dispatches clickoutside when clicking outside the node', () => {
+ const node = makeNode();
+ const outside = makeNode();
+ let fired = false;
+ node.addEventListener('clickoutside', () => (fired = true));
+ clickOutside(node);
+ outside.click();
+ expect(fired).toBe(true);
+ });
+
+ it('does not dispatch clickoutside when clicking inside the node', () => {
+ const node = makeNode();
+ const child = document.createElement('span');
+ node.appendChild(child);
+ let fired = false;
+ node.addEventListener('clickoutside', () => (fired = true));
+ clickOutside(node);
+ child.click();
+ expect(fired).toBe(false);
+ });
+
+ it('removes the listener on destroy', () => {
+ const node = makeNode();
+ const outside = makeNode();
+ let count = 0;
+ node.addEventListener('clickoutside', () => count++);
+ const { destroy } = clickOutside(node);
+ destroy();
+ outside.click();
+ expect(count).toBe(0);
+ });
+});
diff --git a/frontend/src/lib/actions/clickOutside.ts b/frontend/src/lib/actions/clickOutside.ts
new file mode 100644
index 00000000..ef7fc3a2
--- /dev/null
+++ b/frontend/src/lib/actions/clickOutside.ts
@@ -0,0 +1,15 @@
+export function clickOutside(node: HTMLElement): { destroy: () => void } {
+ function handleClick(event: MouseEvent) {
+ if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
+ node.dispatchEvent(new CustomEvent('clickoutside'));
+ }
+ }
+
+ document.addEventListener('click', handleClick, true);
+
+ return {
+ destroy() {
+ document.removeEventListener('click', handleClick, true);
+ }
+ };
+}
diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte b/frontend/src/lib/components/AnnotateHintStrip.svelte
new file mode 100644
index 00000000..e3ea7ab6
--- /dev/null
+++ b/frontend/src/lib/components/AnnotateHintStrip.svelte
@@ -0,0 +1,22 @@
+
+
+{#if annotateMode}
+
+ {m.doc_panel_annotate()}
+ {m.doc_panel_annotate_hint()}
+
+{/if}
diff --git a/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts b/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
new file mode 100644
index 00000000..80f72c13
--- /dev/null
+++ b/frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import AnnotateHintStrip from './AnnotateHintStrip.svelte';
+
+afterEach(cleanup);
+
+describe('AnnotateHintStrip', () => {
+ it('is absent from the DOM when annotateMode is false', async () => {
+ render(AnnotateHintStrip, { annotateMode: false });
+ const strip = page.getByTestId('annotate-hint-strip');
+ await expect.element(strip).not.toBeInTheDocument();
+ });
+
+ it('is present in the DOM when annotateMode is true', async () => {
+ render(AnnotateHintStrip, { annotateMode: true });
+ const strip = page.getByTestId('annotate-hint-strip');
+ await expect.element(strip).toBeInTheDocument();
+ });
+
+ it('has hidden md:flex class to hide below 768px', async () => {
+ render(AnnotateHintStrip, { annotateMode: true });
+ const strip = page.getByTestId('annotate-hint-strip');
+ await expect.element(strip).toHaveClass('hidden');
+ await expect.element(strip).toHaveClass('md:flex');
+ });
+});
diff --git a/frontend/src/lib/components/DocumentStatusChip.svelte b/frontend/src/lib/components/DocumentStatusChip.svelte
new file mode 100644
index 00000000..05857ebc
--- /dev/null
+++ b/frontend/src/lib/components/DocumentStatusChip.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte
index c0c7da65..9c1bd419 100644
--- a/frontend/src/lib/components/DocumentTopBar.svelte
+++ b/frontend/src/lib/components/DocumentTopBar.svelte
@@ -1,5 +1,10 @@
-
-
-
+{#snippet annotateBtn(mobile: boolean)}
+
+{/snippet}
+
+{#snippet annotateStopBtn(mobile: boolean)}
+
+{/snippet}
+
+{#snippet downloadLink(mobile: boolean)}
+
{
+ if (mobile) mobileMenuOpen = false;
+ }}
+ class={mobile
+ ? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
+ : 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
+ title={m.doc_download_title()}
+ >
+
+ {#if mobile}{m.doc_download_title()}{/if}
+
+{/snippet}
+
+
+
+
+
+
+
+
-
-

-
- {m.btn_back()}
+
-
+
+
+
+
+
{doc.title || doc.originalFilename}
- {#if compactMeta}
-
- {compactMeta}
+ {#if shortDate}
+
+ {shortDate}
+ {longDate}
{/if}
+
+
+
+
+
+ {#if extraCount > 0}
+
+ {/if}
+
+
+
+
+
+
+ {#if canAnnotate && isPdf && !annotateMode}
+ {@render annotateBtn(false)}
+ {/if}
+
+ {#if canAnnotate && isPdf && annotateMode}
+ {@render annotateStopBtn(false)}
+ {/if}
+
+ {#if canWrite && !annotateMode}
+
+
+ {m.btn_edit()}
+
+ {/if}
+
+ {#if doc.filePath && !annotateMode}
+ {@render downloadLink(false)}
+ {/if}
+
+
+ {#if (canAnnotate && isPdf) || doc.filePath}
+
(mobileMenuOpen = false)}
+ >
+
+
+ {#if mobileMenuOpen}
+
+ {#if canAnnotate && isPdf && !annotateMode}
+ {@render annotateBtn(true)}
+ {/if}
+
+ {#if doc.filePath}
+ {@render downloadLink(true)}
+ {/if}
+
+ {/if}
+
+ {/if}
+
-
-
- {#if canAnnotate && isPdf}
-
- {/if}
-
- {#if canWrite}
-
-
- {m.btn_edit()}
-
- {/if}
-
- {#if doc.filePath}
-
-
-
- {/if}
-
+
+
diff --git a/frontend/src/lib/components/OverflowPillButton.svelte b/frontend/src/lib/components/OverflowPillButton.svelte
new file mode 100644
index 00000000..5541edc7
--- /dev/null
+++ b/frontend/src/lib/components/OverflowPillButton.svelte
@@ -0,0 +1,77 @@
+
+
+
(open = false)}
+ onkeydown={handleKeydown}
+>
+
+
+ {#if open}
+
+
+ {m.topbar_overflow_heading()}
+
+ {#each persons as person (person.id)}
+
+ {/each}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts b/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts
new file mode 100644
index 00000000..cc2268a2
--- /dev/null
+++ b/frontend/src/lib/components/OverflowPillButton.svelte.spec.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page, userEvent } from 'vitest/browser';
+import OverflowPillButton from './OverflowPillButton.svelte';
+
+afterEach(cleanup);
+
+const persons = [
+ { id: 'p1', firstName: 'Anna', lastName: 'Müller' },
+ { id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
+];
+
+describe('OverflowPillButton', () => {
+ it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
+ render(OverflowPillButton, { extraCount: 2, persons });
+ const btn = page.getByRole('button');
+ await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
+ await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('shows tooltip on click and sets aria-expanded true', async () => {
+ render(OverflowPillButton, { extraCount: 2, persons });
+ const btn = page.getByRole('button');
+ await userEvent.click(btn);
+ const tooltip = page.getByRole('list');
+ await expect.element(tooltip).toBeInTheDocument();
+ await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('closes tooltip on Escape and returns focus to button', async () => {
+ render(OverflowPillButton, { extraCount: 2, persons });
+ const btn = page.getByRole('button');
+ await userEvent.click(btn);
+ await expect.element(page.getByRole('list')).toBeInTheDocument();
+ await userEvent.keyboard('{Escape}');
+ await expect.element(page.getByRole('list')).not.toBeInTheDocument();
+ await expect.element(btn).toHaveFocus();
+ });
+
+ it('renders person links inside tooltip', async () => {
+ render(OverflowPillButton, { extraCount: 2, persons });
+ await userEvent.click(page.getByRole('button'));
+ const links = page.getByRole('link');
+ await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
+ await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
+ });
+});
diff --git a/frontend/src/lib/components/OverflowPillDisplay.svelte b/frontend/src/lib/components/OverflowPillDisplay.svelte
new file mode 100644
index 00000000..a5c68868
--- /dev/null
+++ b/frontend/src/lib/components/OverflowPillDisplay.svelte
@@ -0,0 +1,14 @@
+
+
+
+ +{extraCount}
+
diff --git a/frontend/src/lib/components/PersonChip.svelte b/frontend/src/lib/components/PersonChip.svelte
new file mode 100644
index 00000000..d17e7365
--- /dev/null
+++ b/frontend/src/lib/components/PersonChip.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {initials}
+
+ {displayName}
+
diff --git a/frontend/src/lib/components/PersonChipRow.svelte b/frontend/src/lib/components/PersonChipRow.svelte
new file mode 100644
index 00000000..e50ed3e3
--- /dev/null
+++ b/frontend/src/lib/components/PersonChipRow.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {#if sender}
+
+ {/if}
+
+ {#if sender && receivers.length > 0}
+

+ {/if}
+
+ {#each visibleReceivers as receiver, i (receiver.id)}
+
+
+
+ {/each}
+
+ {#if extraCount > 0}
+
+ {/if}
+
diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte b/frontend/src/lib/components/PersonMultiSelect.svelte
index 5fc1171f..536ef97b 100644
--- a/frontend/src/lib/components/PersonMultiSelect.svelte
+++ b/frontend/src/lib/components/PersonMultiSelect.svelte
@@ -1,6 +1,7 @@
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
{/each}
-
+
(showDropdown = false)}>
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte
index c17c1e81..8e9cf930 100644
--- a/frontend/src/lib/components/PersonTypeahead.svelte
+++ b/frontend/src/lib/components/PersonTypeahead.svelte
@@ -2,6 +2,7 @@
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
+import { clickOutside } from '$lib/actions/clickOutside';
type Person = components['schemas']['Person'];
interface Props {
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
showDropdown = false;
onchange?.(person.id!);
}
-
-function clickOutside(node: HTMLElement) {
- const handleClick = (event: MouseEvent) => {
- if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
- showDropdown = false;
- }
- };
- document.addEventListener('click', handleClick, true);
- return {
- destroy() {
- document.removeEventListener('click', handleClick, true);
- }
- };
-}
-
+
(showDropdown = false)}>