diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index a2d9d227..9ab8d3e5 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -40,6 +40,10 @@ jobs:
run: npm test
working-directory: frontend
+ - name: Build frontend
+ run: npm run build
+ working-directory: frontend
+
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 6e07a47f..e8af8a25 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -459,9 +459,17 @@
"dashboard_reader_recent_docs_heading": "Zuletzt aktualisiert",
"dashboard_reader_recent_stories_heading": "Neue Geschichten",
"dashboard_badge_new": "Neu",
- "dashboard_badge_updated": "Aktualisiert",
"dashboard_reader_all_stories": "Alle Geschichten →",
"dashboard_reader_doc_count_suffix": "Dok.",
+ "dashboard_all_documents": "Alle Dokumente",
+ "dashboard_greeting_time_morning": "Morgen",
+ "dashboard_greeting_time_afternoon": "Mittag",
+ "dashboard_greeting_time_evening": "Abend",
+ "dashboard_welcome": "Herzlich willkommen, {name}.",
+ "dashboard_reader_stats_documents_short": "Dok.",
+ "dashboard_reader_stats_persons_short": "Pers.",
+ "dashboard_reader_stats_stories_short": "Gesch.",
+ "dashboard_reader_draft_meta": "Entwurf · zuletzt bearbeitet {relative}",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 1f120c98..e448d26c 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -459,9 +459,17 @@
"dashboard_reader_recent_docs_heading": "Recently Updated",
"dashboard_reader_recent_stories_heading": "New Stories",
"dashboard_badge_new": "New",
- "dashboard_badge_updated": "Updated",
"dashboard_reader_all_stories": "All Stories →",
"dashboard_reader_doc_count_suffix": "docs.",
+ "dashboard_all_documents": "All Documents",
+ "dashboard_greeting_time_morning": "Morning",
+ "dashboard_greeting_time_afternoon": "Afternoon",
+ "dashboard_greeting_time_evening": "Evening",
+ "dashboard_welcome": "Welcome, {name}.",
+ "dashboard_reader_stats_documents_short": "Docs.",
+ "dashboard_reader_stats_persons_short": "Pers.",
+ "dashboard_reader_stats_stories_short": "Stor.",
+ "dashboard_reader_draft_meta": "Draft · last edited {relative}",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index e24244bb..f8f576b8 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -459,9 +459,17 @@
"dashboard_reader_recent_docs_heading": "Actualizados recientemente",
"dashboard_reader_recent_stories_heading": "Nuevas historias",
"dashboard_badge_new": "Nuevo",
- "dashboard_badge_updated": "Actualizado",
"dashboard_reader_all_stories": "Todas las historias →",
"dashboard_reader_doc_count_suffix": "docs.",
+ "dashboard_all_documents": "Todos los documentos",
+ "dashboard_greeting_time_morning": "Mañana",
+ "dashboard_greeting_time_afternoon": "Tarde",
+ "dashboard_greeting_time_evening": "Noche",
+ "dashboard_welcome": "Bienvenido, {name}.",
+ "dashboard_reader_stats_documents_short": "Docs.",
+ "dashboard_reader_stats_persons_short": "Pers.",
+ "dashboard_reader_stats_stories_short": "Hist.",
+ "dashboard_reader_draft_meta": "Borrador · editado hace {relative}",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
index a03899e1..703ad0b3 100644
--- a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
+++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
@@ -12,24 +12,47 @@ interface Props {
const { drafts }: Props = $props();
-
-
- {m.dashboard_reader_drafts_heading()}
-
+
+
+
+
+ {m.dashboard_reader_drafts_heading()}
+
+
+
{#if drafts.length === 0}
-
{m.dashboard_reader_drafts_empty()}
+
{m.dashboard_reader_drafts_empty()}
{:else}
-
+
{#each drafts as draft (draft.id)}
-
- {draft.title}
-
- {relativeTimeDe(new Date(draft.updatedAt))}
+
+ {draft.title}
+
+ {m.dashboard_reader_draft_meta({ relative: relativeTimeDe(new Date(draft.updatedAt)) })}
+
+
{/each}
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts
index 9d4beab8..0da32564 100644
--- a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts
+++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts
@@ -36,10 +36,12 @@ describe('ReaderDraftsModule', () => {
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
});
- it('shows heading "Meine Entwürfe"', async () => {
+ it('shows heading as h3 (not h2)', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
- const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
- await expect.element(heading).toBeInTheDocument();
+ const h3 = page.getByRole('heading', { level: 3 });
+ await expect.element(h3).toBeInTheDocument();
+ const h2 = page.getByRole('heading', { level: 2 });
+ await expect.element(h2).not.toBeInTheDocument();
});
it('shows empty state when drafts is empty', async () => {
@@ -53,4 +55,45 @@ describe('ReaderDraftsModule', () => {
const emptyText = page.getByText(/Keine Entwürfe/i);
await expect.element(emptyText).not.toBeInTheDocument();
});
+
+ it('card wrapper has mint left-border classes', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const h3 = page.getByRole('heading', { level: 3 });
+ const card = ((await h3.element()) as HTMLElement).closest('div[class]');
+ const rootCard = card?.parentElement;
+ const cls = rootCard?.className ?? '';
+ expect(cls).toMatch(/border-l-\[3px\]/);
+ expect(cls).toMatch(/border-l-brand-mint/);
+ });
+
+ it('draft-row link has min-h-[44px] touch target', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('draft title has text-ink class', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
+ const el = (await link.element()) as HTMLElement;
+ const titleEl = el.querySelector('[class*="text-ink"]');
+ expect(titleEl).not.toBeNull();
+ expect(titleEl?.textContent?.trim()).toBe('Mein erster Entwurf');
+ });
+
+ it('draft meta contains "Entwurf" text', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
+ const el = (await link.element()) as HTMLElement;
+ expect(el.textContent).toMatch(/Entwurf/);
+ });
+
+ it('chevron SVG is present in each draft row', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const link = page.getByRole('link', { name: /Mein erster Entwurf/ });
+ const el = (await link.element()) as HTMLElement;
+ const svg = el.querySelector('svg');
+ expect(svg).not.toBeNull();
+ });
});
diff --git a/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte b/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
new file mode 100644
index 00000000..08509393
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte
@@ -0,0 +1,87 @@
+
+
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte.spec.ts
new file mode 100644
index 00000000..385262c5
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderHeaderBar.svelte.spec.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderHeaderBar from './ReaderHeaderBar.svelte';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('ReaderHeaderBar', () => {
+ it('renders a link to /documents with document count', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /42/ });
+ await expect.element(link).toHaveAttribute('href', '/documents');
+ });
+
+ it('renders a link to /persons with person count', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /7/ });
+ await expect.element(link).toHaveAttribute('href', '/persons');
+ });
+
+ it('renders a link to /geschichten with story count', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /3/ });
+ await expect.element(link).toHaveAttribute('href', '/geschichten');
+ });
+
+ it('documents stat link has min-h-[44px] for touch target', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /42/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('persons stat link has min-h-[44px] for touch target', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /7/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('stories stat link has min-h-[44px] for touch target', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /3/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('shows "—" when counts are null', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: null, persons: null, stories: null });
+ const wrapper = page.getByRole('banner');
+ const text = ((await wrapper.element()) as HTMLElement).textContent;
+ expect(text?.match(/—/g)?.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('time label uses text-ink class for morning hour', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
+ const timeLabel = page.getByText(/Morgen/i);
+ await expect.element(timeLabel).toBeInTheDocument();
+ const cls = ((await timeLabel.element()) as HTMLElement).className;
+ expect(cls).toMatch(/\btext-ink\b/);
+ });
+
+ it('shows afternoon label for hour 14', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 14 });
+ const timeLabel = page.getByText(/Mittag/i);
+ await expect.element(timeLabel).toBeInTheDocument();
+ });
+
+ it('shows evening label for hour 20', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 20 });
+ const timeLabel = page.getByText(/Abend/i);
+ await expect.element(timeLabel).toBeInTheDocument();
+ });
+
+ it('welcome line contains the user name', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
+ const welcome = page.getByText(/Anna/);
+ await expect.element(welcome).toBeInTheDocument();
+ });
+
+ it('wrapper uses bg-surface (CSS-variable-backed, dark-mode-aware)', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
+ const wrapper = page.getByRole('banner');
+ const cls = ((await wrapper.element()) as HTMLElement).className;
+ expect(cls).toMatch(/\bbg-surface\b/);
+ });
+
+ it('renders a vertical divider with bg-line class', async () => {
+ render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
+ const wrapper = page.getByRole('banner');
+ const el = (await wrapper.element()) as HTMLElement;
+ const divider = el.querySelector('[aria-hidden="true"]');
+ expect(divider).not.toBeNull();
+ expect(divider!.className).toMatch(/bg-line/);
+ });
+});
diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
index f78a9b6a..1d49cd56 100644
--- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
+++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
@@ -27,37 +27,38 @@ interface Props {
const { persons }: Props = $props();
-
-
- {m.dashboard_reader_person_chips_heading()}
-
+
{#if persons.length === 0}
{m.dashboard_reader_no_persons()}
{/if}
-
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts
index 4aebb735..20e63879 100644
--- a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts
+++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts
@@ -32,7 +32,7 @@ const person2: PersonSummaryDTO = {
};
describe('ReaderPersonChips', () => {
- it('renders a chip for each person with correct href', async () => {
+ it('renders a card for each person with correct href', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const link1 = page.getByRole('link', { name: /Anna Müller/ });
await expect
@@ -44,12 +44,46 @@ describe('ReaderPersonChips', () => {
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
});
- it('shows document count in each chip', async () => {
+ it('person card has min-h-[44px] touch target', async () => {
render(ReaderPersonChips, { persons: [person1] });
- const chip = page.getByRole('link', { name: /Anna Müller/ });
- await expect.element(chip).toBeInTheDocument();
- const text = ((await chip.element()) as HTMLElement).textContent;
- expect(text).toContain('23');
+ const link = page.getByRole('link', { name: /Anna Müller/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('doc count renders as neutral chip with bg-muted', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const link = page.getByRole('link', { name: /Anna Müller/ });
+ const el = (await link.element()) as HTMLElement;
+ const chip = el.querySelector('[class*="bg-muted"]');
+ expect(chip).not.toBeNull();
+ expect(chip!.textContent).toContain('23');
+ });
+
+ it('doc count chip has rounded-full and border-line classes', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const link = page.getByRole('link', { name: /Anna Müller/ });
+ const el = (await link.element()) as HTMLElement;
+ const chip = el.querySelector('[class*="bg-muted"]');
+ expect(chip).not.toBeNull();
+ expect(chip!.className).toMatch(/rounded-full/);
+ expect(chip!.className).toMatch(/border-line/);
+ });
+
+ it('person grid uses grid layout', async () => {
+ render(ReaderPersonChips, { persons: [person1, person2] });
+ const section = page.getByRole('region');
+ const el = (await section.element()) as HTMLElement;
+ const grid = el.querySelector('[class*="grid"]');
+ expect(grid).not.toBeNull();
+ });
+
+ it('wrapper is a section with aria-label', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const section = page.getByRole('region');
+ await expect.element(section).toBeInTheDocument();
+ const label = ((await section.element()) as HTMLElement).getAttribute('aria-label');
+ expect(label).toBeTruthy();
});
it('renders an "Alle Personen" link to /persons', async () => {
@@ -58,6 +92,13 @@ describe('ReaderPersonChips', () => {
await expect.element(allLink).toHaveAttribute('href', '/persons');
});
+ it('"Alle Personen" link has text-ink-2 class', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const allLink = page.getByRole('link', { name: /Alle Personen/i });
+ const cls = ((await allLink.element()) as HTMLElement).className;
+ expect(cls).toMatch(/text-ink-2/);
+ });
+
it('exposes a focus-visible ring on the "Alle Personen" link', async () => {
render(ReaderPersonChips, { persons: [person1] });
const allLink = page.getByRole('link', { name: /Alle Personen/i });
@@ -73,7 +114,13 @@ describe('ReaderPersonChips', () => {
expect(cls).toMatch(/min-h-\[44px\]/);
});
- it('renders empty state without chips when persons array is empty', async () => {
+ it('does not render h2 heading', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const heading = page.getByRole('heading', { level: 2 });
+ await expect.element(heading).not.toBeInTheDocument();
+ });
+
+ it('renders empty state without person cards when persons array is empty', async () => {
render(ReaderPersonChips, { persons: [] });
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
await expect.element(chips).not.toBeInTheDocument();
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
index d569b57c..9f54d6b5 100644
--- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
@@ -16,49 +16,71 @@ function isNew(doc: Document): boolean {
}
-
-
- {m.dashboard_reader_recent_docs_heading()}
-
-
+
+
+
+
+
+
{#each documents as doc (doc.id)}
- -
-
-
-
+ {:else}
+ —
+ {/if}
+
+
+
+
+
{relativeTimeDe(new Date(doc.updatedAt))}
-
+
{/each}
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
index 8960a5fa..c13c92b1 100644
--- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
@@ -37,30 +37,73 @@ describe('ReaderRecentDocs', () => {
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
});
- it('shows "Neu" badge when createdAt equals updatedAt', async () => {
+ it('card has overflow-hidden and flex-col classes (no p-6, no shadow-sm)', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const heading = page.getByRole('heading', { level: 3 });
+ const card = (await heading.element())?.closest('div');
+ const rootCard = card?.parentElement;
+ const cls = rootCard?.className ?? '';
+ expect(cls).toMatch(/overflow-hidden/);
+ expect(cls).toMatch(/flex-col/);
+ expect(cls).not.toMatch(/\bp-6\b/);
+ expect(cls).not.toMatch(/shadow-sm/);
+ });
+
+ it('card-head contains an h3 (not h2)', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const h3 = page.getByRole('heading', { level: 3 });
+ await expect.element(h3).toBeInTheDocument();
+ const h2 = page.getByRole('heading', { level: 2 });
+ await expect.element(h2).not.toBeInTheDocument();
+ });
+
+ it('"Alle Dokumente" link in card-head points to /documents', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Alle Dokumente/i });
+ await expect.element(link).toHaveAttribute('href', '/documents');
+ });
+
+ it('"Alle Dokumente" link has min-h-[44px]', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Alle Dokumente/i });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('doc-row link has min-h-[44px] touch target', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Brief an Hans/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('thumb element has correct classes', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Brief an Hans/ });
+ const el = (await link.element()) as HTMLElement;
+ const thumb = el.querySelector('[class*="w-5"][class*="h-6"]');
+ expect(thumb).not.toBeNull();
+ expect(thumb!.className).toMatch(/bg-canvas/);
+ expect(thumb!.className).toMatch(/border-line/);
+ expect(thumb!.className).toMatch(/rounded-/);
+ });
+
+ it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument();
- });
-
- it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
- render(ReaderRecentDocs, { documents: [updatedDoc] });
- const badge = page.getByText(/^Aktualisiert$/i);
- await expect.element(badge).toBeInTheDocument();
- });
-
- it('renders the "Aktualisiert" badge with high-contrast text-ink-1', async () => {
- render(ReaderRecentDocs, { documents: [updatedDoc] });
- const badge = page.getByText(/^Aktualisiert$/i);
const cls = ((await badge.element()) as HTMLElement).className;
- expect(cls).toMatch(/text-ink-1/);
- expect(cls).not.toMatch(/text-ink-3(?!\/)/);
+ expect(cls).toMatch(/bg-accent-bg/);
+ expect(cls).toMatch(/rounded-full/);
+ expect(cls).toMatch(/\btext-ink\b/);
});
- it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
+ it('shows no badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument();
+ const updatedBadge = page.getByText(/^Aktualisiert$/i);
+ await expect.element(updatedBadge).not.toBeInTheDocument();
});
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => {
@@ -75,7 +118,7 @@ describe('ReaderRecentDocs', () => {
await expect.element(badge).toBeInTheDocument();
});
- it('renders sender link when sender is present', async () => {
+ it('renders sender name text when sender is present', async () => {
const docWithSender: Document = {
...baseDoc,
sender: {
@@ -88,7 +131,15 @@ describe('ReaderRecentDocs', () => {
}
};
render(ReaderRecentDocs, { documents: [docWithSender] });
- const senderLink = page.getByRole('link', { name: /Anna Müller/ });
- await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
+ const link = page.getByRole('link', { name: /Brief an Hans/ });
+ const el = (await link.element()) as HTMLElement;
+ expect(el.textContent).toContain('Anna Müller');
+ });
+
+ it('shows em-dash when sender is absent', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Brief an Hans/ });
+ const el = (await link.element()) as HTMLElement;
+ expect(el.textContent).toContain('—');
});
});
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
index ab5b0a30..95c446f7 100644
--- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
@@ -24,33 +24,38 @@ function excerpt(body: string | undefined): string {
{#if stories.length > 0}
-
-
- {m.dashboard_reader_recent_stories_heading()}
-
-
+
{/if}
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts
index c749858d..c5d83051 100644
--- a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts
@@ -52,7 +52,7 @@ describe('ReaderRecentStories', () => {
await expect.element(links).not.toBeInTheDocument();
});
- it('renders "Alle Geschichten" link', async () => {
+ it('renders "Alle Geschichten" link pointing to /geschichten', async () => {
render(ReaderRecentStories, { stories: [story1] });
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
@@ -72,4 +72,44 @@ describe('ReaderRecentStories', () => {
const cls = ((await allLink.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
+
+ it('card-head contains an h3 (not h2)', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const h3 = page.getByRole('heading', { level: 3 });
+ await expect.element(h3).toBeInTheDocument();
+ const h2 = page.getByRole('heading', { level: 2 });
+ await expect.element(h2).not.toBeInTheDocument();
+ });
+
+ it('card-head div has border-b and border-line classes', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const h3 = page.getByRole('heading', { level: 3 });
+ const cardHead = ((await h3.element()) as HTMLElement).parentElement;
+ expect(cardHead?.className).toMatch(/border-b/);
+ expect(cardHead?.className).toMatch(/border-line/);
+ });
+
+ it('"Alle Geschichten" link is inside the card-head (sibling of h3)', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const h3 = page.getByRole('heading', { level: 3 });
+ const cardHead = ((await h3.element()) as HTMLElement).parentElement;
+ const allLink = cardHead?.querySelector('a');
+ expect(allLink).not.toBeNull();
+ expect(allLink?.textContent?.trim()).toMatch(/Alle Geschichten/i);
+ });
+
+ it('story-row link has min-h-[44px] touch target', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const link = page.getByRole('link', { name: /Die Familie Müller/ });
+ const cls = ((await link.element()) as HTMLElement).className;
+ expect(cls).toMatch(/min-h-\[44px\]/);
+ });
+
+ it('excerpt has text-ink-2 class', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const link = page.getByRole('link', { name: /Die Familie Müller/ });
+ const el = (await link.element()) as HTMLElement;
+ const excerptEl = el.querySelector('p');
+ expect(excerptEl?.className).toMatch(/text-ink-2/);
+ });
});
diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
deleted file mode 100644
index 8129b03c..00000000
--- a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts
deleted file mode 100644
index b33ddfc7..00000000
--- a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { cleanup, render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
-
-import ReaderStatsStrip from './ReaderStatsStrip.svelte';
-
-afterEach(() => {
- cleanup();
-});
-
-describe('ReaderStatsStrip', () => {
- it('renders a link to /documents', async () => {
- render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
- const link = page.getByRole('link', { name: /42/ });
- await expect.element(link).toHaveAttribute('href', '/documents');
- });
-
- it('renders a link to /persons', async () => {
- render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
- const link = page.getByRole('link', { name: /7/ });
- await expect.element(link).toHaveAttribute('href', '/persons');
- });
-
- it('renders a link to /geschichten', async () => {
- render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
- const link = page.getByRole('link', { name: /3/ });
- await expect.element(link).toHaveAttribute('href', '/geschichten');
- });
-
- it('shows "—" when documents count is null', async () => {
- render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
- const links = page.getByRole('link');
- await expect.element(links.first()).toBeInTheDocument();
- const text = ((await links.first().element()) as HTMLElement).textContent;
- expect(text).toContain('—');
- });
-});
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
index 20c05ebf..4b5069e0 100644
--- a/frontend/src/routes/+page.svelte
+++ b/frontend/src/routes/+page.svelte
@@ -5,7 +5,7 @@ import MissionControlStrip from '$lib/document/MissionControlStrip.svelte';
import DashboardFamilyPulse from '$lib/shared/dashboard/DashboardFamilyPulse.svelte';
import DashboardActivityFeed from '$lib/activity/DashboardActivityFeed.svelte';
import EnrichmentBlock from '$lib/document/EnrichmentBlock.svelte';
-import ReaderStatsStrip from '$lib/shared/dashboard/ReaderStatsStrip.svelte';
+import ReaderHeaderBar from '$lib/shared/dashboard/ReaderHeaderBar.svelte';
import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
@@ -30,15 +30,10 @@ const greetingText = $derived.by(() => {
- {#if data?.user}
-
-
{greetingText}
-
- {/if}
-
{#if data.isReader}
-
{
-
{:else}
+ {#if data?.user}
+
+
{greetingText}
+
+ {/if}
diff --git a/frontend/src/routes/hilfe/transkription/+page.ts b/frontend/src/routes/hilfe/transkription/+page.ts
index f71b9f54..189f71e2 100644
--- a/frontend/src/routes/hilfe/transkription/+page.ts
+++ b/frontend/src/routes/hilfe/transkription/+page.ts
@@ -1,3 +1 @@
-// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
-// before prerendered HTML is visible.
export const prerender = true;
diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts
index e1d82648..a022cafa 100644
--- a/frontend/src/routes/page.svelte.spec.ts
+++ b/frontend/src/routes/page.svelte.spec.ts
@@ -102,13 +102,19 @@ describe('Home page – dashboard layout', () => {
// ─── Reader dashboard layout ──────────────────────────────────────────────────
describe('Home page – reader dashboard layout', () => {
- it('renders ReaderStatsStrip totals when isReader is true', async () => {
+ it('renders reader header-bar totals when isReader is true', async () => {
render(Page, { data: readerData });
await expect.element(page.getByText('34')).toBeInTheDocument();
await expect.element(page.getByText('12')).toBeInTheDocument();
await expect.element(page.getByText('5')).toBeInTheDocument();
});
+ it('reader branch does not render h1 heading', async () => {
+ render(Page, { data: readerData });
+ const h1 = page.getByRole('heading', { level: 1 });
+ await expect.element(h1).not.toBeInTheDocument();
+ });
+
it('renders the recent-docs heading when isReader is true', async () => {
render(Page, { data: readerData });
await expect.element(page.getByText('Zuletzt aktualisiert')).toBeInTheDocument();
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
index 03c17f28..ca42a340 100644
--- a/frontend/svelte.config.js
+++ b/frontend/svelte.config.js
@@ -6,7 +6,10 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
- kit: { adapter: adapter() }
+ kit: {
+ adapter: adapter(),
+ prerender: { entries: ['/hilfe/transkription'] }
+ }
};
export default config;