diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 3b0f986c..58b96595 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -2,48 +2,83 @@ import * as m from '$lib/paraglide/messages.js'; import LetterCard from './LetterCard.svelte'; import BucketHeaderChip from './BucketHeaderChip.svelte'; +import YearLetterStrip from './YearLetterStrip.svelte'; import { entryKey } from './entryKey'; -import type { LetterBucket } from './timelineGrouping'; +import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping'; /** - * One cluster of loose letters under a header, in Ereignis or Thema mode (#827). The axis-fixed - * event/world-band layers are rendered elsewhere — this is only the loose-letter bundling. - * Ereignis: a "✉ · " header over `.lcard.ev` cards (REQ-003/014). Thema: a tinted - * root-tag header chip over cards whose own tag chip is suppressed (REQ-004/015/017). A bucket - * with no title (kind `fallback`) uses the localized "Weitere Briefe"/"Ohne Thema" label and - * keeps its letters as plain cards (REQ-006/007). + * One cluster of loose letters, bound together by a coloured left rail so the group reads as a + * unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the + * loose-letter bundling. + * + * - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own + * tag chip is suppressed (REQ-004/015/017). + * - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the + * header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" / + * "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007). + * + * A bucket larger than the density threshold collapses to the month-density `YearLetterStrip` + * instead of flooding the timeline with every card (#827) — the catch-all buckets are the biggest. */ -let { bucket, mode }: { bucket: LetterBucket; mode: 'event' | 'thema' } = $props(); +let { + bucket, + mode, + year = 0, + nested = false +}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props(); const count = $derived(bucket.letters.length); +const dense = $derived(isBucketDense(count)); const fallbackLabel = $derived( mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic() ); +// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster, +// neutral for the fallback (and for a colourless/unknown tag token). +const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null); +const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : ''); +const isEventCluster = $derived(nested || bucket.kind === 'event'); +const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain'); -
-
- {#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} -
+
+ {#if !nested} +
+ {#if mode === 'thema' && bucket.kind === 'tag'} + + {:else if mode === 'event' && bucket.kind === 'event'} + + + {bucket.title} + + {:else} + {fallbackLabel} + {/if} + · {count} +
+ {/if} + + {#if dense} + + + {:else} +
    + {#each bucket.letters as letter (entryKey(letter))} +
  • + +
  • + {/each} +
+ {/if}
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index c024c9f5..22b6b137 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -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)'); + }); +}); diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts index 13f317ac..c2d745c2 100644 --- a/frontend/src/lib/timeline/timelineGrouping.ts +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -14,6 +14,41 @@ export type GroupingMode = 'date' | 'event' | 'thema'; /** The default mode — chronological, as #779 shipped. */ export const DEFAULT_GROUPING: GroupingMode = 'date'; +/** + * A bucket larger than this collapses to a month-density strip instead of flooding the + * timeline with individual cards (#827) — the catch-all "Weitere Briefe"/"Ohne Thema" + * buckets are always the biggest, so without this they swamp the grouped view. Lower than + * Datum mode's `DENSE_THRESHOLD` (12) because a bucket is a narrower context than a year. + */ +export const BUCKET_DENSE_THRESHOLD = 6; + +export function isBucketDense(letterCount: number): boolean { + return letterCount > BUCKET_DENSE_THRESHOLD; +} + +/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */ +const TAG_COLOR_TOKENS = new Set([ + 'sage', + 'sienna', + 'amber', + 'slate', + 'violet', + 'rose', + 'cobalt', + 'moss', + 'sand', + 'coral' +]); + +/** + * Maps a root-tag colour-name token to its CSS variable reference, or `null` for an absent + * or unknown token (so a colourless/unrecognised tag falls back to a neutral rail, never a + * broken `var(--c-tag-undefined)`). + */ +export function tagColorVar(token: string | null | undefined): string | null { + return token && TAG_COLOR_TOKENS.has(token) ? `var(--c-tag-${token})` : null; +} + /** * One bundle of loose letters under a single header, within a year (Ereignis/Thema modes). * `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized