diff --git a/frontend/messages/de.json b/frontend/messages/de.json index bc27db1a..84632358 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.", "timeline_bucket_other_letters": "Weitere Briefe", "timeline_bucket_no_topic": "Ohne Thema", + "timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen", + "timeline_bucket_show_less": "Weniger anzeigen", "timeline_provenance_derived": "abgeleitet", "timeline_provenance_curated": "kuratiert", "timeline_letter_glyph_label": "Brief", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index dd4a9271..0899da7d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.", "timeline_bucket_other_letters": "More letters", "timeline_bucket_no_topic": "No topic", + "timeline_bucket_show_more": "+ {count} more letters", + "timeline_bucket_show_less": "Show fewer", "timeline_provenance_derived": "derived", "timeline_provenance_curated": "curated", "timeline_letter_glyph_label": "Letter", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index a1712d41..5d3c6aaa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1063,6 +1063,8 @@ "timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.", "timeline_bucket_other_letters": "Más cartas", "timeline_bucket_no_topic": "Sin tema", + "timeline_bucket_show_more": "+ {count} cartas más", + "timeline_bucket_show_less": "Mostrar menos", "timeline_provenance_derived": "derivado", "timeline_provenance_curated": "curado", "timeline_letter_glyph_label": "Carta", diff --git a/frontend/src/lib/timeline/LetterBucket.svelte b/frontend/src/lib/timeline/LetterBucket.svelte index 58b96595..3353e497 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte +++ b/frontend/src/lib/timeline/LetterBucket.svelte @@ -2,9 +2,8 @@ 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 { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping'; +import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping'; /** * One cluster of loose letters, bound together by a coloured left rail so the group reads as a @@ -17,18 +16,21 @@ import { isBucketDense, tagColorVar, type LetterBucket } from './timelineGroupin * 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. + * A cluster shows its first `CLUSTER_PREVIEW` letters, then a show-more toggle reveals the rest + * instead of flooding the timeline with every card (#827 redesign). */ let { bucket, mode, + // `year` is the band's year — accepted for the cross-year label card seam (#827) but no + // longer consumed here now the in-bucket month-density strip is gone (the year frames the + // time from the band heading). Kept in the prop contract for callers/tests. + // eslint-disable-next-line @typescript-eslint/no-unused-vars 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() ); @@ -38,6 +40,12 @@ const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : n const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : ''); const isEventCluster = $derived(nested || bucket.kind === 'event'); const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain'); + +// First-5 preview + show-more (#827 redesign): a large cluster stays readable instead of +// dumping every card into the timeline. +let expanded = $state(false); +const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW)); +const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
{/if} - {#if dense} - - - {:else} - + + {#if hiddenCount > 0} + {/if}
diff --git a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts index 22b6b137..6fe2b996 100644 --- a/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts +++ b/frontend/src/lib/timeline/LetterBucket.svelte.spec.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; import * as m from '$lib/paraglide/messages.js'; import LetterBucket from './LetterBucket.svelte'; import { makeEntry } from './test-factories'; @@ -78,47 +79,49 @@ const manyLetters = (n: number) => 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', () => { +describe('LetterBucket — preview cap + show-more (#827 redesign)', () => { + it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => { const bucket: Bucket = { key: 'tag:t1', kind: 'tag', - title: 'Sonstiges', - color: null, - letters: manyLetters(10) + title: 'Krieg', + color: 'sienna', + letters: manyLetters(8) }; 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); + expect(document.querySelectorAll('a.lcard')).toHaveLength(5); + expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull(); // sparkline gone }); - it('renders compact cards for a small bucket (no strip)', () => { + it('expands to all letters and collapses back on toggle', async () => { + const bucket: Bucket = { + key: 'tag:t1', + kind: 'tag', + title: 'Krieg', + color: 'sienna', + letters: manyLetters(8) + }; + render(LetterBucket, { bucket, mode: 'thema', year: 1916 }); + (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click(); + await tick(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(8); + (document.querySelector('[data-testid="bucket-show-more"]') as HTMLButtonElement).click(); + await tick(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(5); + }); + + it('shows all letters and no toggle for a small cluster (<= 5)', () => { const bucket: Bucket = { key: 'tag:t1', kind: 'tag', title: 'Tod', color: null, - letters: manyLetters(2) + letters: manyLetters(3) }; 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(); + expect(document.querySelectorAll('a.lcard')).toHaveLength(3); + expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull(); }); it('binds a tag bucket together with a coloured left rail from its token', () => { diff --git a/frontend/src/lib/timeline/timelineGrouping.ts b/frontend/src/lib/timeline/timelineGrouping.ts index c2d745c2..6b487213 100644 --- a/frontend/src/lib/timeline/timelineGrouping.ts +++ b/frontend/src/lib/timeline/timelineGrouping.ts @@ -14,17 +14,8 @@ 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; -} +/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */ +export const CLUSTER_PREVIEW = 5; /** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */ const TAG_COLOR_TOKENS = new Set([