diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte
new file mode 100644
index 00000000..3b0f986c
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterBucket.svelte
@@ -0,0 +1,49 @@
+
+
+
+
+ {#if mode === 'thema' && bucket.kind === 'tag'}
+
+ {:else if mode === 'event' && bucket.kind === 'event'}
+
+ ✉
+ {bucket.title}
+
+ {:else}
+ {fallbackLabel}
+ {/if}
+ · {count}
+
+
+ {#each bucket.letters as letter (entryKey(letter))}
+ -
+ {#if mode === 'event'}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
new file mode 100644
index 00000000..c024c9f5
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import * as m from '$lib/paraglide/messages.js';
+import LetterBucket from './LetterBucket.svelte';
+import { makeEntry } from './test-factories';
+import type { LetterBucket as Bucket } from './timelineGrouping';
+
+afterEach(() => cleanup());
+
+const eventBucket: Bucket = {
+ key: 'event:e1',
+ kind: 'event',
+ title: 'Briefe von der Front',
+ color: null,
+ letters: [makeEntry({ documentId: 'a' }), makeEntry({ documentId: 'b' })]
+};
+
+const tagBucket: Bucket = {
+ key: 'tag:t1',
+ kind: 'tag',
+ title: 'Krieg',
+ color: 'sienna',
+ letters: [makeEntry({ documentId: 'c', rootTagName: 'Krieg', rootTagColor: 'sienna' })]
+};
+
+describe('LetterBucket — Ereignis mode (REQ-003/006/014)', () => {
+ it('shows the event title and the cluster count', () => {
+ render(LetterBucket, { bucket: eventBucket, mode: 'event' });
+ expect(document.body.textContent).toContain('Briefe von der Front');
+ expect(document.querySelector('[data-testid="bucket-count"]')?.textContent).toContain('2');
+ });
+
+ it('renders its letters as .lcard.ev event cards (REQ-014)', () => {
+ render(LetterBucket, { bucket: eventBucket, mode: 'event' });
+ expect(document.querySelectorAll('a.lcard.ev')).toHaveLength(2);
+ });
+
+ it('uses the localized "Weitere Briefe" label and plain cards for the fallback bucket (REQ-006)', () => {
+ const fb: Bucket = {
+ key: '__fallback__',
+ kind: 'fallback',
+ color: null,
+ letters: [makeEntry({ documentId: 'x' })]
+ };
+ render(LetterBucket, { bucket: fb, mode: 'event' });
+ expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
+ // fallback letters are not clustered under a curated event → plain card, never .lcard.ev
+ expect(document.querySelector('a.ev')).toBeNull();
+ });
+});
+
+describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
+ it('renders a tinted bucket-header chip carrying the root-tag name (REQ-015)', () => {
+ render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
+ const chip = document.querySelector('[data-testid="bucket-header-chip"]');
+ expect(chip?.textContent).toContain('Krieg');
+ });
+
+ it('suppresses the per-letter tag chip inside its own root-tag bucket (REQ-017)', () => {
+ render(LetterBucket, { bucket: tagBucket, mode: 'thema' });
+ expect(document.querySelector('[data-testid="tag-chip"]')).toBeNull();
+ });
+
+ it('uses the localized "Ohne Thema" label for the untagged fallback bucket (REQ-007)', () => {
+ const fb: Bucket = {
+ key: '__fallback__',
+ kind: 'fallback',
+ color: null,
+ letters: [makeEntry({ documentId: 'y', rootTagName: undefined })]
+ };
+ render(LetterBucket, { bucket: fb, mode: 'thema' });
+ expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
+ });
+});