diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index e45d2a22..b07e377b 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -36,13 +36,7 @@ jobs:
run: npm run lint
working-directory: frontend
- - name: Run unit and component tests
- run: npm test
- working-directory: frontend
- env:
- TZ: Europe/Berlin
-
- - name: Run coverage (server + client)
+ - name: Run unit and component tests with coverage
run: npm run test:coverage
working-directory: frontend
env:
diff --git a/CLAUDE.md b/CLAUDE.md
index 3628e703..09e8d2c8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -202,8 +202,7 @@ frontend/src/routes/
├── profile/ User profile settings
├── users/[id]/ Public user profile page
├── login/ logout/ register/
-├── forgot-password/ reset-password/
-└── demo/ Dev-only demos
+└── forgot-password/ reset-password/
```
### API Client Pattern
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index 8b912685..c58a26c6 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -40,8 +40,7 @@ src/
│ ├── profile/ # User profile settings
│ ├── users/[id]/ # Public user profile page
│ ├── login/ logout/ register/
-│ ├── forgot-password/ reset-password/
-│ └── demo/ # Dev-only demos
+│ └── forgot-password/ reset-password/
├── lib/ # Domain-based package structure (mirrors backend)
│ ├── document/ # Document domain: components, stores, services, utils
│ │ ├── annotation/ # Annotation overlay components
diff --git a/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts b/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts
new file mode 100644
index 00000000..920a76db
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikEmptyState.svelte.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import ChronikEmptyState from './ChronikEmptyState.svelte';
+
+afterEach(cleanup);
+
+describe('ChronikEmptyState', () => {
+ it('renders the first-run title and body and the clock icon', async () => {
+ render(ChronikEmptyState, { props: { variant: 'first-run' as const } });
+
+ await expect.element(page.getByText('Noch nichts geschehen')).toBeVisible();
+ await expect.element(page.getByText(/sobald jemand aus der familie/i)).toBeVisible();
+
+ const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
+ expect(wrapper?.getAttribute('data-variant')).toBe('first-run');
+ });
+
+ it('renders the filter-empty title and body', async () => {
+ render(ChronikEmptyState, { props: { variant: 'filter-empty' as const } });
+
+ await expect.element(page.getByText('Nichts in dieser Ansicht')).toBeVisible();
+ await expect.element(page.getByText('In diesem Filter gibt es keine Einträge.')).toBeVisible();
+
+ const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
+ expect(wrapper?.getAttribute('data-variant')).toBe('filter-empty');
+ });
+
+ it('renders the inbox-zero title and no body paragraph', async () => {
+ render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } });
+
+ await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeVisible();
+
+ // Only one
(the title) since body is empty
+ const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
+ const paragraphs = wrapper?.querySelectorAll('p');
+ expect(paragraphs?.length).toBe(1);
+ expect(wrapper?.getAttribute('data-variant')).toBe('inbox-zero');
+ });
+
+ it('uses the accent color icon for inbox-zero (vs ink-3 for others)', async () => {
+ render(ChronikEmptyState, { props: { variant: 'inbox-zero' as const } });
+
+ const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
+ const svg = wrapper?.querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('text-accent');
+ });
+
+ it('uses the ink-3 color icon for first-run', async () => {
+ render(ChronikEmptyState, { props: { variant: 'first-run' as const } });
+
+ const wrapper = document.querySelector('[data-testid="chronik-empty-state"]');
+ const svg = wrapper?.querySelector('svg');
+ expect(svg?.getAttribute('class')).toContain('text-ink-3');
+ });
+});
diff --git a/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts b/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts
new file mode 100644
index 00000000..8a516501
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikErrorCard.svelte.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import ChronikErrorCard from './ChronikErrorCard.svelte';
+
+afterEach(cleanup);
+
+describe('ChronikErrorCard', () => {
+ it('renders the default error message when no message is supplied', async () => {
+ render(ChronikErrorCard, { props: { onRetry: () => {} } });
+
+ await expect.element(page.getByText(/Aktivitäten konnten nicht/i)).toBeVisible();
+ });
+
+ it('renders the supplied message when provided', async () => {
+ render(ChronikErrorCard, {
+ props: { onRetry: () => {}, message: 'Custom error message' }
+ });
+
+ await expect.element(page.getByText('Custom error message')).toBeVisible();
+ });
+
+ it('calls onRetry when the retry button is clicked', async () => {
+ const onRetry = vi.fn();
+ render(ChronikErrorCard, { props: { onRetry } });
+
+ await page.getByRole('button', { name: /erneut versuchen/i }).click();
+
+ expect(onRetry).toHaveBeenCalledOnce();
+ });
+
+ it('marks the card as role="alert" for assistive tech', async () => {
+ render(ChronikErrorCard, { props: { onRetry: () => {} } });
+
+ await expect.element(page.getByRole('alert')).toBeVisible();
+ });
+});
diff --git a/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts b/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts
new file mode 100644
index 00000000..c04fdf4b
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikFilterPills.svelte.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import ChronikFilterPills from './ChronikFilterPills.svelte';
+
+afterEach(cleanup);
+
+describe('ChronikFilterPills', () => {
+ it('renders the radiogroup with the label', async () => {
+ render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } });
+
+ await expect
+ .element(page.getByRole('radiogroup', { name: /aktivitäten filtern/i }))
+ .toBeVisible();
+ });
+
+ it('renders all five filter pills', async () => {
+ render(ChronikFilterPills, { props: { value: 'alle' as const, onChange: () => {} } });
+
+ const radios = document.querySelectorAll('[role="radio"]');
+ expect(radios.length).toBe(5);
+ });
+
+ it('marks the active filter as aria-checked=true', async () => {
+ render(ChronikFilterPills, { props: { value: 'fuer-dich' as const, onChange: () => {} } });
+
+ const active = document.querySelector('[data-filter-value="fuer-dich"]') as HTMLElement;
+ expect(active.getAttribute('aria-checked')).toBe('true');
+ });
+
+ it('sets tabindex=0 on the active pill and -1 on others', async () => {
+ render(ChronikFilterPills, { props: { value: 'kommentare' as const, onChange: () => {} } });
+
+ const active = document.querySelector('[data-filter-value="kommentare"]') as HTMLElement;
+ const others = Array.from(document.querySelectorAll('[role="radio"]')).filter(
+ (el) => el !== active
+ ) as HTMLElement[];
+ expect(active.tabIndex).toBe(0);
+ others.forEach((el) => expect(el.tabIndex).toBe(-1));
+ });
+
+ it('calls onChange with the new filter value when clicked', async () => {
+ const onChange = vi.fn();
+ render(ChronikFilterPills, { props: { value: 'alle' as const, onChange } });
+
+ const transcription = document.querySelector(
+ '[data-filter-value="transkription"]'
+ ) as HTMLElement;
+ transcription.click();
+
+ expect(onChange).toHaveBeenCalledWith('transkription');
+ });
+});
diff --git a/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts
new file mode 100644
index 00000000..6b887a86
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikFuerDichBox.svelte.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import ChronikFuerDichBox from './ChronikFuerDichBox.svelte';
+import type { NotificationItem } from '$lib/notification/notifications';
+
+afterEach(cleanup);
+
+const mention = (overrides: Partial = {}): NotificationItem => ({
+ id: 'n-1',
+ type: 'MENTION',
+ documentId: 'doc-1',
+ referenceId: 'ref-1',
+ annotationId: null,
+ read: false,
+ createdAt: new Date().toISOString(),
+ actorName: 'Anna',
+ documentTitle: 'Brief 1899',
+ ...overrides
+});
+
+describe('ChronikFuerDichBox', () => {
+ it('renders the inbox-zero state when there are no unread', async () => {
+ render(ChronikFuerDichBox, {
+ props: { unread: [], onMarkRead: () => {}, onMarkAllRead: () => {} }
+ });
+
+ await expect.element(page.getByText(/keine neuen erwähnungen/i)).toBeVisible();
+ const link = document.querySelector('a[href="/aktivitaeten?filter=fuer-dich"]');
+ expect(link).not.toBeNull();
+ });
+
+ it('renders the count badge with the unread count', async () => {
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention(), mention({ id: 'n-2' }), mention({ id: 'n-3' })],
+ onMarkRead: () => {},
+ onMarkAllRead: () => {}
+ }
+ });
+
+ const badge = document.querySelector('[data-testid="chronik-fuerdich-count"]');
+ expect(badge?.textContent).toContain('3');
+ });
+
+ it('uses the @ glyph for MENTION and ↩ for REPLY', async () => {
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention({ id: 'n-m', type: 'MENTION' }), mention({ id: 'n-r', type: 'REPLY' })],
+ onMarkRead: () => {},
+ onMarkAllRead: () => {}
+ }
+ });
+
+ const items = document.querySelectorAll('ul[role="list"] li');
+ expect(items.length).toBe(2);
+ expect(items[0].textContent).toContain('@');
+ expect(items[1].textContent).toContain('↩');
+ });
+
+ it('renders MENTION verb text from paraglide messages', async () => {
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention({ actorName: 'Bertha' })],
+ onMarkRead: () => {},
+ onMarkAllRead: () => {}
+ }
+ });
+
+ await expect
+ .element(page.getByText(/bertha hat dich in einem kommentar erwähnt/i))
+ .toBeVisible();
+ });
+
+ it('renders REPLY verb text from paraglide messages', async () => {
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention({ type: 'REPLY', actorName: 'Carl' })],
+ onMarkRead: () => {},
+ onMarkAllRead: () => {}
+ }
+ });
+
+ await expect
+ .element(page.getByText(/carl hat auf deinen kommentar geantwortet/i))
+ .toBeVisible();
+ });
+
+ it('calls onMarkRead with the notification when its dismiss button is clicked', async () => {
+ const onMarkRead = vi.fn();
+ const item = mention({ id: 'n-7' });
+ render(ChronikFuerDichBox, {
+ props: { unread: [item], onMarkRead, onMarkAllRead: () => {} }
+ });
+
+ const dismiss = document.querySelector(
+ '[data-testid="chronik-fuerdich-dismiss"]'
+ ) as HTMLElement;
+ dismiss.click();
+
+ expect(onMarkRead).toHaveBeenCalledWith(item);
+ });
+
+ it('calls onMarkAllRead when the mark-all-read button is clicked', async () => {
+ const onMarkAllRead = vi.fn();
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention()],
+ onMarkRead: () => {},
+ onMarkAllRead
+ }
+ });
+
+ const btn = document.querySelector('[data-testid="chronik-mark-all-read"]') as HTMLElement;
+ btn.click();
+
+ expect(onMarkAllRead).toHaveBeenCalledOnce();
+ });
+
+ it('builds a deep-link href to the comment for each notification', async () => {
+ render(ChronikFuerDichBox, {
+ props: {
+ unread: [mention({ documentId: 'doc-x', referenceId: 'ref-y', annotationId: null })],
+ onMarkRead: () => {},
+ onMarkAllRead: () => {}
+ }
+ });
+
+ const link = document.querySelector('ul[role="list"] li a') as HTMLAnchorElement;
+ expect(link.getAttribute('href')).toContain('doc-x');
+ });
+});
diff --git a/frontend/src/lib/activity/ChronikRow.svelte.test.ts b/frontend/src/lib/activity/ChronikRow.svelte.test.ts
new file mode 100644
index 00000000..c62f4a1e
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikRow.svelte.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import ChronikRow from './ChronikRow.svelte';
+
+afterEach(cleanup);
+
+const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' };
+
+const makeItem = (overrides: Record = {}) => ({
+ id: 'i1',
+ kind: 'TEXT_SAVED' as string,
+ actor: baseActor as null | typeof baseActor,
+ documentId: 'd1',
+ documentTitle: 'Brief 1923',
+ count: 1,
+ happenedAt: '2026-04-15T10:00:00Z',
+ happenedAtUntil: null as string | null,
+ commentId: null as string | null,
+ commentPreview: null as string | null,
+ annotationId: null as string | null,
+ youMentioned: false,
+ ...overrides
+});
+
+describe('ChronikRow', () => {
+ it('renders the actor avatar with initials when actor is present', async () => {
+ render(ChronikRow, { props: { item: makeItem() } });
+
+ expect(document.body.textContent).toContain('AS');
+ });
+
+ it('renders the question-mark fallback avatar when actor is null', async () => {
+ render(ChronikRow, { props: { item: makeItem({ actor: null }) } });
+
+ const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]');
+ expect(fallback).not.toBeNull();
+ });
+
+ it('renders the for-you marker when youMentioned is true', async () => {
+ render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } });
+
+ const marker = document.querySelector('[data-testid="chronik-foryou-marker"]');
+ expect(marker).not.toBeNull();
+ });
+
+ it('renders the for-you data-variant when youMentioned is true', async () => {
+ render(ChronikRow, { props: { item: makeItem({ youMentioned: true }) } });
+
+ const link = document.querySelector('a[data-variant]') as HTMLElement;
+ expect(link.getAttribute('data-variant')).toBe('for-you');
+ });
+
+ it('renders the rollup variant when count > 1', async () => {
+ render(ChronikRow, { props: { item: makeItem({ count: 3 }) } });
+
+ const link = document.querySelector('a[data-variant]') as HTMLElement;
+ expect(link.getAttribute('data-variant')).toBe('rollup');
+ const badge = document.querySelector('[data-testid="chronik-count-badge"]');
+ expect(badge).not.toBeNull();
+ });
+
+ it('renders the comment variant for COMMENT_ADDED kind', async () => {
+ render(ChronikRow, {
+ props: { item: makeItem({ kind: 'COMMENT_ADDED', commentPreview: 'Tolle Geschichte!' }) }
+ });
+
+ const link = document.querySelector('a[data-variant]') as HTMLElement;
+ expect(link.getAttribute('data-variant')).toBe('comment');
+ const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
+ expect(preview?.textContent).toContain('Tolle Geschichte!');
+ });
+
+ it('falls back to ellipsis comment preview when commentPreview is null', async () => {
+ render(ChronikRow, { props: { item: makeItem({ kind: 'COMMENT_ADDED' }) } });
+
+ const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
+ expect(preview?.textContent).toContain('…');
+ });
+
+ it('renders the document title in a styled span', async () => {
+ render(ChronikRow, { props: { item: makeItem() } });
+
+ const title = document.querySelector('[data-testid="chronik-doc-title"]');
+ expect(title?.textContent).toBe('Brief 1923');
+ });
+
+ it('uses /documents/{id} as default href', async () => {
+ render(ChronikRow, { props: { item: makeItem() } });
+
+ const link = document.querySelector('a[data-variant]') as HTMLAnchorElement;
+ expect(link.href).toContain('/documents/d1');
+ });
+
+ it('uses comment-deep-link href when commentId is set', async () => {
+ render(ChronikRow, {
+ props: { item: makeItem({ commentId: 'c1', kind: 'COMMENT_ADDED' }) }
+ });
+
+ const link = document.querySelector('a[data-variant]') as HTMLAnchorElement;
+ expect(link.href).toContain('c1');
+ });
+
+ it('renders a time-range label when rollup has happenedAtUntil', async () => {
+ render(ChronikRow, {
+ props: {
+ item: makeItem({
+ count: 5,
+ happenedAt: '2026-04-15T10:00:00Z',
+ happenedAtUntil: '2026-04-15T14:30:00Z'
+ })
+ }
+ });
+
+ // Time range uses U+2013 between two HH:MM strings — check for any colon-bearing time
+ expect(document.body.textContent).toMatch(/\d{2}:\d{2}/);
+ });
+});
diff --git a/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts b/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts
new file mode 100644
index 00000000..49300ce0
--- /dev/null
+++ b/frontend/src/lib/activity/ChronikTimeline.svelte.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import ChronikTimeline from './ChronikTimeline.svelte';
+
+afterEach(cleanup);
+
+const baseActor = { id: 'a1', name: 'Anna Schmidt', initials: 'AS', color: '#012851' };
+
+const makeItem = (overrides: Record = {}) => ({
+ id: 'i1',
+ kind: 'TEXT_SAVED' as string,
+ actor: baseActor,
+ documentId: 'd1',
+ documentTitle: 'Brief 1923',
+ count: 1,
+ happenedAt: new Date().toISOString(),
+ youMentioned: false,
+ ...overrides
+});
+
+describe('ChronikTimeline', () => {
+ it('renders nothing when items is empty', async () => {
+ render(ChronikTimeline, { props: { items: [] } });
+
+ const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]');
+ expect(buckets.length).toBe(0);
+ });
+
+ it('renders the today bucket for today items', async () => {
+ const today = new Date();
+ render(ChronikTimeline, {
+ props: { items: [makeItem({ id: 'i1', happenedAt: today.toISOString() })] }
+ });
+
+ const today_bucket = document.querySelector('[data-testid="chronik-bucket-today"]');
+ expect(today_bucket).not.toBeNull();
+ });
+
+ it('renders the older bucket for old items', async () => {
+ render(ChronikTimeline, {
+ props: { items: [makeItem({ id: 'i1', happenedAt: '2020-01-01T10:00:00Z' })] }
+ });
+
+ const olderBucket = document.querySelector('[data-testid="chronik-bucket-older"]');
+ expect(olderBucket).not.toBeNull();
+ });
+
+ it('renders multiple buckets when items span time ranges', async () => {
+ const today = new Date();
+ render(ChronikTimeline, {
+ props: {
+ items: [
+ makeItem({ id: 'i1', kind: 'TEXT_SAVED', happenedAt: today.toISOString() }),
+ makeItem({
+ id: 'i2',
+ kind: 'FILE_UPLOADED',
+ documentId: 'd2',
+ happenedAt: '2020-01-01T10:00:00Z'
+ })
+ ]
+ }
+ });
+
+ const buckets = document.querySelectorAll('[data-testid^="chronik-bucket-"]');
+ expect(buckets.length).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts b/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts
new file mode 100644
index 00000000..a0e7fbfc
--- /dev/null
+++ b/frontend/src/lib/activity/DashboardActivityFeed.svelte.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import DashboardActivityFeed from './DashboardActivityFeed.svelte';
+import type { components } from '$lib/generated/api';
+
+type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
+
+afterEach(cleanup);
+
+const baseItem = (overrides: Partial = {}): ActivityFeedItemDTO =>
+ ({
+ kind: 'TEXT_SAVED',
+ documentId: 'doc-1',
+ documentTitle: 'Brief 1899',
+ actor: {
+ id: 'u-1',
+ name: 'Anna Schmidt',
+ initials: 'AS',
+ color: '#336699'
+ },
+ count: 1,
+ happenedAt: '2026-04-14T14:02:00Z',
+ happenedAtUntil: null,
+ youMentioned: false,
+ ...overrides
+ }) as ActivityFeedItemDTO;
+
+describe('DashboardActivityFeed', () => {
+ it('renders the feed caption and show-all link', async () => {
+ render(DashboardActivityFeed, { props: { feed: [] } });
+
+ await expect.element(page.getByText('Kommentare & Aktivität')).toBeVisible();
+ const link = document.querySelector('a[href="/aktivitaeten"]');
+ expect(link).not.toBeNull();
+ });
+
+ it('renders nothing in the list when the feed is empty', async () => {
+ render(DashboardActivityFeed, { props: { feed: [] } });
+
+ const lists = document.querySelectorAll('ul');
+ expect(lists.length).toBe(0);
+ });
+
+ it('renders one row per feed item with the actor initials', async () => {
+ render(DashboardActivityFeed, {
+ props: {
+ feed: [baseItem(), baseItem({ documentId: 'doc-2', documentTitle: 'Brief 1900' })]
+ }
+ });
+
+ const items = document.querySelectorAll('li');
+ expect(items.length).toBe(2);
+ expect(document.body.textContent).toContain('AS');
+ });
+
+ it('renders the question-mark badge when no actor is set', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ actor: null as unknown as undefined })] }
+ });
+
+ const li = document.querySelector('li');
+ expect(li?.textContent).toContain('?');
+ });
+
+ it('renders the rollup count badge when count > 1', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ count: 5 })] }
+ });
+
+ const badge = document.querySelector('[data-testid="feed-rollup-count"]');
+ expect(badge?.textContent?.trim()).toBe('5');
+ });
+
+ it('omits the rollup count badge when count is 1', async () => {
+ render(DashboardActivityFeed, { props: { feed: [baseItem({ count: 1 })] } });
+
+ const badge = document.querySelector('[data-testid="feed-rollup-count"]');
+ expect(badge).toBeNull();
+ });
+
+ it('renders the "für dich" badge when youMentioned is true', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ youMentioned: true })] }
+ });
+
+ await expect.element(page.getByText(/für dich/i)).toBeVisible();
+ });
+
+ it('maps the kind enum to a localized verb (TEXT_SAVED)', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ kind: 'TEXT_SAVED' as ActivityFeedItemDTO['kind'] })] }
+ });
+
+ expect(document.body.textContent).toContain('hat Text gespeichert in');
+ });
+
+ it('maps the kind enum to a localized verb (FILE_UPLOADED)', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ kind: 'FILE_UPLOADED' as ActivityFeedItemDTO['kind'] })] }
+ });
+
+ expect(document.body.textContent).toContain('hat eine Datei hochgeladen');
+ });
+
+ it('falls back to the raw kind when no verb is mapped', async () => {
+ render(DashboardActivityFeed, {
+ props: {
+ feed: [baseItem({ kind: 'UNKNOWN_KIND' as unknown as ActivityFeedItemDTO['kind'] })]
+ }
+ });
+
+ expect(document.body.textContent).toContain('UNKNOWN_KIND');
+ });
+
+ it('renders a rollup time range when happenedAtUntil is set and count > 1', async () => {
+ render(DashboardActivityFeed, {
+ props: {
+ feed: [
+ baseItem({
+ happenedAt: '2026-04-14T14:02:00Z',
+ happenedAtUntil: '2026-04-14T14:32:00Z',
+ count: 3
+ })
+ ]
+ }
+ });
+
+ // "14:02–14:32" appears (with the en-dash)
+ expect(document.body.textContent).toMatch(/\d{2}:\d{2}–\d{2}:\d{2}/);
+ });
+
+ it('uses the actor initials as the fallback name when name is null', async () => {
+ render(DashboardActivityFeed, {
+ props: {
+ feed: [
+ baseItem({
+ actor: {
+ id: 'u-2',
+ name: null as unknown as undefined,
+ initials: 'XR',
+ color: '#000'
+ }
+ })
+ ]
+ }
+ });
+
+ const strong = document.querySelector('strong');
+ expect(strong?.textContent).toBe('XR');
+ });
+
+ it('builds the document detail href from documentId', async () => {
+ render(DashboardActivityFeed, {
+ props: { feed: [baseItem({ documentId: 'doc-xyz', documentTitle: 'Brief 1901' })] }
+ });
+
+ const link = document.querySelector('a[href="/documents/doc-xyz"]');
+ expect(link).not.toBeNull();
+ });
+});
diff --git a/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts b/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts
new file mode 100644
index 00000000..8077f9b3
--- /dev/null
+++ b/frontend/src/lib/document/DocumentMetadataDrawer.svelte.test.ts
@@ -0,0 +1,207 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
+
+afterEach(cleanup);
+
+const sender = { id: 's1', firstName: 'Anna', lastName: 'Schmidt', displayName: 'Anna Schmidt' };
+const receiver = (id: string, name: string) => ({
+ id,
+ firstName: name.split(' ')[0],
+ lastName: name.split(' ').slice(1).join(' ') || name,
+ displayName: name
+});
+
+const baseProps = {
+ documentDate: '1923-04-15' as string | null,
+ location: 'Berlin' as string | null,
+ status: 'UPLOADED',
+ sender: null as typeof sender | null,
+ receivers: [] as ReturnType[],
+ tags: [] as { id: string; name: string }[],
+ inferredRelationship: null,
+ geschichten: [] as {
+ id: string;
+ title: string;
+ publishedAt?: string;
+ author?: { firstName?: string; lastName?: string; email: string };
+ }[],
+ documentId: 'doc-1',
+ canBlogWrite: false
+};
+
+describe('DocumentMetadataDrawer', () => {
+ it('renders the three default section headings', async () => {
+ render(DocumentMetadataDrawer, { props: baseProps });
+
+ await expect.element(page.getByRole('heading', { name: 'Details' })).toBeVisible();
+ await expect.element(page.getByRole('heading', { name: 'Personen' })).toBeVisible();
+ await expect.element(page.getByRole('heading', { name: 'Schlagwörter' })).toBeVisible();
+ });
+
+ it('renders the formatted long date when documentDate is provided', async () => {
+ render(DocumentMetadataDrawer, { props: baseProps });
+
+ // formatDate default ('long') format is "15. April 1923" in de-DE.
+ await expect.element(page.getByText(/1923/)).toBeVisible();
+ });
+
+ it('renders an em-dash when documentDate is null', async () => {
+ render(DocumentMetadataDrawer, { props: { ...baseProps, documentDate: null } });
+
+ // The dash appears in date AND location AND geschichten — multiple matches expected
+ const dashes = document.querySelectorAll('dd, p');
+ const dashTexts = Array.from(dashes)
+ .map((el) => el.textContent?.trim())
+ .filter((t) => t === '—');
+ expect(dashTexts.length).toBeGreaterThan(0);
+ });
+
+ it('renders the no-persons placeholder when sender and receivers are empty', async () => {
+ render(DocumentMetadataDrawer, { props: baseProps });
+
+ await expect.element(page.getByText('Keine Personen zugeordnet')).toBeVisible();
+ });
+
+ it('renders the sender and inferred relationship label when both are present', async () => {
+ render(DocumentMetadataDrawer, {
+ props: {
+ ...baseProps,
+ sender,
+ inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' }
+ }
+ });
+
+ await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
+ });
+
+ it('renders the receivers list with up to five visible by default', async () => {
+ const receivers = Array.from({ length: 7 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
+ render(DocumentMetadataDrawer, {
+ props: { ...baseProps, sender, receivers }
+ });
+
+ await expect.element(page.getByText('Person 0')).toBeVisible();
+ await expect.element(page.getByText('Person 4')).toBeVisible();
+ await expect.element(page.getByText('Person 5')).not.toBeInTheDocument();
+ });
+
+ it('renders the +N more button when there are more than five receivers', async () => {
+ const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
+ render(DocumentMetadataDrawer, {
+ props: { ...baseProps, sender, receivers }
+ });
+
+ await expect.element(page.getByRole('button', { name: /\+3 weitere/i })).toBeVisible();
+ });
+
+ it('expands the receiver list when the +N more button is clicked', async () => {
+ const receivers = Array.from({ length: 8 }, (_, i) => receiver(`r${i}`, `Person ${i}`));
+ render(DocumentMetadataDrawer, {
+ props: { ...baseProps, sender, receivers }
+ });
+
+ await page.getByRole('button', { name: /\+3 weitere/i }).click();
+
+ await expect.element(page.getByText('Person 7')).toBeVisible();
+ });
+
+ it('renders the no-tags placeholder when tags is empty', async () => {
+ render(DocumentMetadataDrawer, { props: baseProps });
+
+ await expect.element(page.getByText('Keine Schlagwörter zugeordnet')).toBeVisible();
+ });
+
+ it('renders one anchor per tag when tags are present', async () => {
+ render(DocumentMetadataDrawer, {
+ props: {
+ ...baseProps,
+ tags: [
+ { id: 't1', name: 'Familie' },
+ { id: 't2', name: 'Reise' }
+ ]
+ }
+ });
+
+ await expect
+ .element(page.getByRole('link', { name: 'Familie' }))
+ .toHaveAttribute('href', '/?tag=Familie');
+ await expect
+ .element(page.getByRole('link', { name: 'Reise' }))
+ .toHaveAttribute('href', '/?tag=Reise');
+ });
+
+ it('hides the geschichten column when there are no stories and no canBlogWrite', async () => {
+ render(DocumentMetadataDrawer, { props: baseProps });
+
+ await expect
+ .element(page.getByRole('heading', { name: 'Geschichten' }))
+ .not.toBeInTheDocument();
+ });
+
+ it('shows the geschichten column when canBlogWrite is true even with no stories', async () => {
+ render(DocumentMetadataDrawer, { props: { ...baseProps, canBlogWrite: true } });
+
+ await expect.element(page.getByRole('heading', { name: 'Geschichten' })).toBeVisible();
+ });
+
+ it('renders the attach link to the new-geschichte route when canBlogWrite + documentId', async () => {
+ render(DocumentMetadataDrawer, {
+ props: { ...baseProps, canBlogWrite: true, documentId: 'doc-42' }
+ });
+
+ const links = document.querySelectorAll('a[href*="/geschichten/new?documentId="]');
+ expect(links.length).toBe(1);
+ expect((links[0] as HTMLAnchorElement).href).toContain('documentId=doc-42');
+ });
+
+ it('renders the geschichten list when stories are present', async () => {
+ render(DocumentMetadataDrawer, {
+ props: {
+ ...baseProps,
+ geschichten: [
+ {
+ id: 'g1',
+ title: 'Reise nach Berlin',
+ publishedAt: '2026-04-15T10:00:00Z',
+ author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' }
+ }
+ ]
+ }
+ });
+
+ await expect.element(page.getByRole('link', { name: /reise nach berlin/i })).toBeVisible();
+ });
+
+ it('renders the show-all geschichten link when there are at least three stories', async () => {
+ render(DocumentMetadataDrawer, {
+ props: {
+ ...baseProps,
+ geschichten: Array.from({ length: 3 }, (_, i) => ({
+ id: `g${i}`,
+ title: `Geschichte ${i}`,
+ publishedAt: '2026-04-15T10:00:00Z',
+ author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@x' }
+ }))
+ }
+ });
+
+ await expect.element(page.getByText(/zeige alle|alle/i)).toBeVisible();
+ });
+
+ it('renders the receiver-only inferred relationship pill only when there is exactly one receiver', async () => {
+ render(DocumentMetadataDrawer, {
+ props: {
+ ...baseProps,
+ sender,
+ receivers: [receiver('r1', 'Bert Meier')],
+ inferredRelationship: { labelFromA: 'Vater', labelFromB: 'Tochter' }
+ }
+ });
+
+ // Both labels should be visible — Vater for sender, Tochter for the single receiver
+ await expect.element(page.getByText(/vater/i)).toBeVisible();
+ await expect.element(page.getByText(/tochter/i)).toBeVisible();
+ });
+});
diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte b/frontend/src/lib/document/DocumentMobileMenu.svelte
new file mode 100644
index 00000000..c2892bbd
--- /dev/null
+++ b/frontend/src/lib/document/DocumentMobileMenu.svelte
@@ -0,0 +1,96 @@
+
+
+ (mobileMenuOpen = false)}>
+
+
+ {#if mobileMenuOpen}
+
+ {/if}
+
diff --git a/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts
new file mode 100644
index 00000000..9b561aa5
--- /dev/null
+++ b/frontend/src/lib/document/DocumentMobileMenu.svelte.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import DocumentMobileMenu from './DocumentMobileMenu.svelte';
+
+afterEach(cleanup);
+
+const baseProps = {
+ canWrite: false,
+ isPdf: false,
+ transcribeMode: false,
+ filePath: null as string | null,
+ originalFilename: 'brief.pdf' as string | null,
+ fileUrl: ''
+};
+
+describe('DocumentMobileMenu', () => {
+ it('renders the kebab trigger button with the more-actions aria-label', async () => {
+ render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
+
+ await expect.element(page.getByRole('button', { name: /weitere aktionen/i })).toBeVisible();
+ });
+
+ it('starts with the dropdown closed (aria-expanded=false)', async () => {
+ render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
+
+ await expect
+ .element(page.getByRole('button', { name: /weitere aktionen/i }))
+ .toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('opens the dropdown when the trigger is clicked', async () => {
+ render(DocumentMobileMenu, { props: { ...baseProps, filePath: 'docs/x.pdf' } });
+
+ await page.getByRole('button', { name: /weitere aktionen/i }).click();
+
+ await expect
+ .element(page.getByRole('button', { name: /weitere aktionen/i }))
+ .toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('shows the transcribe action inside the open menu when canWrite, isPdf, and not in transcribe mode', async () => {
+ render(DocumentMobileMenu, {
+ props: { ...baseProps, canWrite: true, isPdf: true, filePath: 'docs/x.pdf' }
+ });
+
+ await page.getByRole('button', { name: /weitere aktionen/i }).click();
+
+ await expect.element(page.getByRole('button', { name: /transkribieren/i })).toBeVisible();
+ });
+
+ it('hides the transcribe action when already in transcribeMode', async () => {
+ render(DocumentMobileMenu, {
+ props: {
+ ...baseProps,
+ canWrite: true,
+ isPdf: true,
+ transcribeMode: true,
+ filePath: 'docs/x.pdf'
+ }
+ });
+
+ await page.getByRole('button', { name: /weitere aktionen/i }).click();
+
+ await expect
+ .element(page.getByRole('button', { name: /transkribieren/i }))
+ .not.toBeInTheDocument();
+ });
+
+ it('shows the download link inside the open menu when filePath is present', async () => {
+ render(DocumentMobileMenu, {
+ props: { ...baseProps, filePath: 'docs/x.pdf', fileUrl: '/api/docs/x' }
+ });
+
+ await page.getByRole('button', { name: /weitere aktionen/i }).click();
+
+ await expect.element(page.getByRole('link', { name: /herunterladen/i })).toBeVisible();
+ });
+
+ it('omits the download link when filePath is null', async () => {
+ render(DocumentMobileMenu, {
+ props: { ...baseProps, canWrite: true, isPdf: true }
+ });
+
+ await page.getByRole('button', { name: /weitere aktionen/i }).click();
+
+ await expect
+ .element(page.getByRole('link', { name: /herunterladen/i }))
+ .not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/document/DocumentRow.svelte.test.ts b/frontend/src/lib/document/DocumentRow.svelte.test.ts
new file mode 100644
index 00000000..704428bc
--- /dev/null
+++ b/frontend/src/lib/document/DocumentRow.svelte.test.ts
@@ -0,0 +1,150 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+vi.mock('$app/navigation', () => ({
+ beforeNavigate: () => {},
+ afterNavigate: () => {},
+ goto: vi.fn(),
+ invalidate: vi.fn(),
+ invalidateAll: vi.fn(),
+ preloadCode: vi.fn(),
+ preloadData: vi.fn(),
+ pushState: vi.fn(),
+ replaceState: vi.fn(),
+ disableScrollHandling: vi.fn(),
+ onNavigate: () => () => {}
+}));
+
+const { default: DocumentRow } = await import('./DocumentRow.svelte');
+
+afterEach(cleanup);
+
+const sender = { id: 's1', displayName: 'Anna Schmidt' };
+const receiver = { id: 'r1', displayName: 'Bert Meier' };
+
+const makeDoc = (overrides: Record = {}) => ({
+ id: 'd1',
+ title: 'Brief 1923',
+ originalFilename: 'b.pdf',
+ documentDate: '1923-04-15',
+ sender,
+ receivers: [receiver],
+ tags: [],
+ thumbnailUrl: null,
+ contentType: 'application/pdf',
+ summary: null,
+ archiveBox: null,
+ archiveFolder: null,
+ location: null,
+ ...overrides
+});
+
+const baseItem = (docOverrides: Record = {}) => ({
+ document: makeDoc(docOverrides),
+ matchData: null,
+ completionPercentage: 0,
+ contributors: []
+});
+
+describe('DocumentRow', () => {
+ it('renders the title', async () => {
+ render(DocumentRow, { props: { item: baseItem() } });
+
+ await expect
+ .element(page.getByRole('heading', { level: 3, name: /brief 1923/i }))
+ .toBeVisible();
+ });
+
+ it('falls back to originalFilename when title is null', async () => {
+ render(DocumentRow, { props: { item: baseItem({ title: null }) } });
+
+ await expect.element(page.getByRole('heading', { level: 3, name: /b\.pdf/i })).toBeVisible();
+ });
+
+ it('renders the sender name in the metadata column', async () => {
+ render(DocumentRow, { props: { item: baseItem() } });
+
+ await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
+ });
+
+ it('renders the unknown placeholder when sender is null', async () => {
+ render(DocumentRow, { props: { item: baseItem({ sender: null }) } });
+
+ const unknownTexts = document.querySelectorAll('.italic');
+ const hasUnknown = Array.from(unknownTexts).some((el) => el.textContent?.includes('Unbekannt'));
+ expect(hasUnknown).toBe(true);
+ });
+
+ it('renders one tag button per document tag', async () => {
+ render(DocumentRow, {
+ props: {
+ item: baseItem({
+ tags: [
+ { id: 't1', name: 'Familie', color: null },
+ { id: 't2', name: 'Reise', color: '#ffaabb' }
+ ]
+ })
+ }
+ });
+
+ await expect.element(page.getByRole('button', { name: 'Familie' })).toBeVisible();
+ await expect.element(page.getByRole('button', { name: 'Reise' })).toBeVisible();
+ });
+
+ it('renders the bulk-select checkbox when canWrite is true', async () => {
+ render(DocumentRow, { props: { item: baseItem(), canWrite: true } });
+
+ const checkbox = document.querySelector('input[type="checkbox"]');
+ expect(checkbox).not.toBeNull();
+ });
+
+ it('hides the bulk-select checkbox when canWrite is false', async () => {
+ render(DocumentRow, { props: { item: baseItem(), canWrite: false } });
+
+ const checkbox = document.querySelector('input[type="checkbox"]');
+ expect(checkbox).toBeNull();
+ });
+
+ it('renders archive chips when archive metadata is present', async () => {
+ render(DocumentRow, {
+ props: {
+ item: baseItem({ archiveBox: 'Box 1', archiveFolder: 'Mappe A', location: 'Berlin' })
+ }
+ });
+
+ await expect.element(page.getByText('Box 1')).toBeVisible();
+ await expect.element(page.getByText('Mappe A')).toBeVisible();
+ await expect.element(page.getByText('Berlin')).toBeVisible();
+ });
+
+ it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
+ render(DocumentRow, {
+ props: {
+ item: {
+ document: makeDoc(),
+ matchData: { transcriptionSnippet: 'Hello world snippet' },
+ completionPercentage: 50,
+ contributors: []
+ }
+ }
+ });
+
+ await expect.element(page.getByTestId('search-snippet')).toBeVisible();
+ });
+
+ it('renders the summary when present', async () => {
+ render(DocumentRow, {
+ props: { item: baseItem({ summary: 'Brief über die Reise nach Berlin' }) }
+ });
+
+ await expect.element(page.getByTestId('doc-summary')).toBeVisible();
+ });
+
+ it('renders an em-dash for missing documentDate', async () => {
+ render(DocumentRow, { props: { item: baseItem({ documentDate: null }) } });
+
+ // Multiple em-dashes possible; just ensure at least one is rendered
+ expect(document.body.textContent).toContain('—');
+ });
+});
diff --git a/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts
new file mode 100644
index 00000000..bb0ed12e
--- /dev/null
+++ b/frontend/src/lib/document/DocumentStatusChip.svelte.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import DocumentStatusChip from './DocumentStatusChip.svelte';
+
+afterEach(cleanup);
+
+describe('DocumentStatusChip', () => {
+ it('renders the placeholder label and gray dot for PLACEHOLDER status', async () => {
+ render(DocumentStatusChip, { props: { status: 'PLACEHOLDER' } });
+
+ const dot = await page.getByTitle('Platzhalter').element();
+ expect(dot.classList.contains('bg-gray-400')).toBe(true);
+ });
+
+ it('renders the uploaded label and emerald dot for UPLOADED status', async () => {
+ render(DocumentStatusChip, { props: { status: 'UPLOADED' } });
+
+ const dot = await page.getByTitle('Hochgeladen').element();
+ expect(dot.classList.contains('bg-emerald-500')).toBe(true);
+ });
+
+ it('renders the transcribed label and blue dot for TRANSCRIBED status', async () => {
+ render(DocumentStatusChip, { props: { status: 'TRANSCRIBED' } });
+
+ const dot = await page.getByTitle('Transkribiert').element();
+ expect(dot.classList.contains('bg-blue-400')).toBe(true);
+ });
+
+ it('renders the reviewed label and amber dot for REVIEWED status', async () => {
+ render(DocumentStatusChip, { props: { status: 'REVIEWED' } });
+
+ const dot = await page.getByTitle('Geprüft').element();
+ expect(dot.classList.contains('bg-amber-400')).toBe(true);
+ });
+
+ it('renders the archived label and dark emerald dot for ARCHIVED status', async () => {
+ render(DocumentStatusChip, { props: { status: 'ARCHIVED' } });
+
+ const dot = await page.getByTitle('Archiviert').element();
+ expect(dot.classList.contains('bg-emerald-600')).toBe(true);
+ });
+
+ it('exposes the status as both a title tooltip and an aria-label', async () => {
+ render(DocumentStatusChip, { props: { status: 'UPLOADED' } });
+
+ const dot = await page.getByTitle('Hochgeladen').element();
+ expect(dot.getAttribute('aria-label')).toBe('Hochgeladen');
+ });
+});
diff --git a/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts b/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts
new file mode 100644
index 00000000..41562aa6
--- /dev/null
+++ b/frontend/src/lib/document/DocumentThumbnail.svelte.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import DocumentThumbnail from './DocumentThumbnail.svelte';
+
+afterEach(cleanup);
+
+describe('DocumentThumbnail', () => {
+ it('renders the supplied thumbnail image when thumbnailUrl is set', async () => {
+ render(DocumentThumbnail, {
+ props: {
+ doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' }
+ }
+ });
+
+ const img = document.querySelector('img') as HTMLImageElement;
+ expect(img).not.toBeNull();
+ expect(img.src).toContain('/api/d1/thumb');
+ });
+
+ it('renders the placeholder icon when thumbnailUrl is missing', async () => {
+ render(DocumentThumbnail, {
+ props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } }
+ });
+
+ const svg = document.querySelector('svg');
+ expect(svg).not.toBeNull();
+ });
+
+ it('uses the small container size by default', async () => {
+ render(DocumentThumbnail, {
+ props: { doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' } }
+ });
+
+ const container = document.querySelector('.h-\\[84px\\]');
+ expect(container).not.toBeNull();
+ });
+
+ it('uses the large container size when size="lg"', async () => {
+ render(DocumentThumbnail, {
+ props: {
+ doc: { id: 'd1', thumbnailUrl: null, contentType: 'application/pdf' },
+ size: 'lg'
+ }
+ });
+
+ const container = document.querySelector('.h-\\[168px\\]');
+ expect(container).not.toBeNull();
+ });
+
+ it('uses lazy loading attributes on the thumbnail image', async () => {
+ render(DocumentThumbnail, {
+ props: {
+ doc: { id: 'd1', thumbnailUrl: '/api/d1/thumb', contentType: 'application/pdf' }
+ }
+ });
+
+ const img = document.querySelector('img') as HTMLImageElement;
+ expect(img.loading).toBe('lazy');
+ expect(img.decoding).toBe('async');
+ });
+});
diff --git a/frontend/src/lib/document/DocumentTopBar.svelte b/frontend/src/lib/document/DocumentTopBar.svelte
index 96381a69..8d8968dc 100644
--- a/frontend/src/lib/document/DocumentTopBar.svelte
+++ b/frontend/src/lib/document/DocumentTopBar.svelte
@@ -1,11 +1,12 @@
-{#snippet transcribeBtn(mobile: boolean)}
-
-{/snippet}
-
-{#snippet transcribeStopBtn(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}
-
@@ -161,20 +77,11 @@ let mobileMenuOpen = $state(false);
-
-
- {doc.title || doc.originalFilename}
-
- {#if shortDate}
-
- {shortDate}
- {longDate}
-
- {/if}
-
+
@@ -192,7 +99,9 @@ let mobileMenuOpen = $state(false);
onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen}
aria-label={m.doc_details_toggle()}
- class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
+ class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded border px-3 py-1 font-sans text-sm font-semibold transition-colors {detailsOpen
+ ? 'border-primary bg-primary text-primary-fg'
+ : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.doc_details_toggle()}