feat(timeline): contain buckets with a colour rail and collapse oversized ones

The grouped view flooded: buckets had no visual containment (a tiny floating pill
over cards identical to the ungrouped view) and the >12-letter density collapse was
gone, so "Weitere Briefe · 325" / "Sonstiges · 10" dumped every card.

LetterBucket now binds each cluster with a coloured left rail (tag colour in Thema,
mint for an Ereignis cluster, neutral for the fallback), renders compact cards, and
— above BUCKET_DENSE_THRESHOLD (6) — collapses to the existing month-density
YearLetterStrip instead of a flood. Adds a `nested` mode (no header) for letters that
sit under their event pill, and shares the tag-colour token allow-list via tagColorVar.

Refs #827
This commit is contained in:
Marcel
2026-06-15 12:01:47 +02:00
parent ca06293dc5
commit 23534fb077
3 changed files with 165 additions and 33 deletions

View File

@@ -72,3 +72,65 @@ describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
});
});
const manyLetters = (n: number) =>
Array.from({ length: n }, (_, i) =>
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
);
describe('LetterBucket — density + containment (#827)', () => {
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Sonstiges',
color: null,
letters: manyLetters(10)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
// not ten individual cards dumped into the timeline
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
});
it('renders compact cards for a small bucket (no strip)', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Tod',
color: null,
letters: manyLetters(2)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
});
it('omits the header when nested — the event pill above is the header', () => {
const bucket: Bucket = {
key: 'event:e1',
kind: 'event',
title: 'Ein gewaltiger Stadtbrand',
color: null,
letters: manyLetters(1)
};
render(LetterBucket, { bucket, mode: 'event', nested: true, year: 1916 });
expect(document.querySelector('[data-testid="bucket-count"]')).toBeNull();
expect(document.body.textContent).not.toContain('Ein gewaltiger Stadtbrand');
// still the event-letter variant, just headerless under its pill
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
});
it('binds a tag bucket together with a coloured left rail from its token', () => {
const bucket: Bucket = {
key: 'tag:t1',
kind: 'tag',
title: 'Krieg',
color: 'sienna',
letters: manyLetters(1)
};
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
const section = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
});
});