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:
@@ -2,48 +2,83 @@
|
|||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import LetterCard from './LetterCard.svelte';
|
import LetterCard from './LetterCard.svelte';
|
||||||
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
import BucketHeaderChip from './BucketHeaderChip.svelte';
|
||||||
|
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||||
import { entryKey } from './entryKey';
|
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
|
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
||||||
* event/world-band layers are rendered elsewhere — this is only the loose-letter bundling.
|
* unit (#827). The axis-fixed event/world-band layers are rendered elsewhere — this is only the
|
||||||
* Ereignis: a "✉ <event> · <n>" header over `.lcard.ev` cards (REQ-003/014). Thema: a tinted
|
* loose-letter bundling.
|
||||||
* 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
|
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
|
||||||
* keeps its letters as plain cards (REQ-006/007).
|
* 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 count = $derived(bucket.letters.length);
|
||||||
|
const dense = $derived(isBucketDense(count));
|
||||||
const fallbackLabel = $derived(
|
const fallbackLabel = $derived(
|
||||||
mode === 'event' ? m.timeline_bucket_other_letters() : m.timeline_bucket_no_topic()
|
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');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="my-3" data-testid="letter-bucket" data-bucket-kind={bucket.kind}>
|
<section
|
||||||
<header class="mb-2 flex items-center gap-2">
|
class="my-3 border-l-2 pl-3"
|
||||||
{#if mode === 'thema' && bucket.kind === 'tag'}
|
class:border-l-brand-mint={isEventCluster}
|
||||||
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
class:border-line={!railColor && !isEventCluster}
|
||||||
{:else if mode === 'event' && bucket.kind === 'event'}
|
style={railStyle}
|
||||||
<span class="font-serif text-sm font-bold text-ink">
|
data-testid="letter-bucket"
|
||||||
<span aria-hidden="true">✉</span>
|
data-bucket-kind={bucket.kind}
|
||||||
{bucket.title}
|
>
|
||||||
</span>
|
{#if !nested}
|
||||||
{:else}
|
<header class="mb-2 flex items-center gap-2">
|
||||||
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
{#if mode === 'thema' && bucket.kind === 'tag'}
|
||||||
{/if}
|
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
||||||
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
{:else if mode === 'event' && bucket.kind === 'event'}
|
||||||
</header>
|
<span class="font-serif text-sm font-bold text-ink">
|
||||||
<ul class="space-y-2">
|
<span aria-hidden="true">✉</span>
|
||||||
{#each bucket.letters as letter (entryKey(letter))}
|
{bucket.title}
|
||||||
<li>
|
</span>
|
||||||
{#if mode === 'event'}
|
{:else}
|
||||||
<LetterCard entry={letter} variant={bucket.kind === 'event' ? 'event' : 'plain'} />
|
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
||||||
{:else}
|
{/if}
|
||||||
<LetterCard entry={letter} suppressTagChip={true} />
|
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||||
{/if}
|
</header>
|
||||||
</li>
|
{/if}
|
||||||
{/each}
|
|
||||||
</ul>
|
{#if dense}
|
||||||
|
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
|
||||||
|
<YearLetterStrip letters={bucket.letters} year={year} />
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
{#each bucket.letters as letter (entryKey(letter))}
|
||||||
|
<li>
|
||||||
|
<LetterCard
|
||||||
|
entry={letter}
|
||||||
|
variant={cardVariant}
|
||||||
|
suppressTagChip={mode === 'thema'}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -72,3 +72,65 @@ describe('LetterBucket — Thema mode (REQ-004/007/015/017)', () => {
|
|||||||
expect(document.body.textContent).toContain(m.timeline_bucket_no_topic());
|
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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,6 +14,41 @@ export type GroupingMode = 'date' | 'event' | 'thema';
|
|||||||
/** The default mode — chronological, as #779 shipped. */
|
/** The default mode — chronological, as #779 shipped. */
|
||||||
export const DEFAULT_GROUPING: GroupingMode = 'date';
|
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).
|
* 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
|
* `kind` decides the header: a curated-event title, a tinted root-tag chip, or the localized
|
||||||
|
|||||||
Reference in New Issue
Block a user