Compare commits
5 Commits
093c942f67
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4bf8edc0 | ||
|
|
70794616d2 | ||
|
|
e100213760 | ||
|
|
bea9acfe63 | ||
|
|
5a8bee3970 |
@@ -209,10 +209,10 @@
|
|||||||
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
|
| REQ-011 | ≤320px: control overflow-free + tap ≥44px, each abbreviation carries its full word as aria-label | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/GroupingControl.svelte` | `GroupingControl.svelte.spec.ts#exposes each segment full word as an aria-label`, `e2e/zeitstrahl-grouping.spec.ts#the control stays overflow-free and operable at 320px` | Done |
|
||||||
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
|
| REQ-012 | new grouping/bucket Paraglide keys in de/en/es; no collision with existing timeline*_ keys | #827 | timeline-grouping-modes | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl grouping + bucket keys are present in all locales (#827 REQ-012)`, `messages.spec.ts#de, en, and es have identical key sets` | Done |
|
||||||
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
|
| REQ-013 | failed timeline fetch → existing localized error via getErrorMessage; grouping has no independent failure mode | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.server.ts` (#779, unchanged) | `zeitstrahl/page.server` error path (#779 — getErrorMessage(extractErrorCode)) | Done |
|
||||||
| REQ-014 | Ereignis event-clustered letter renders as the `.lcard.ev` variant, **nested directly under its event pill** with no duplicate title (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders its letters as .lcard.ev event cards`, `YearBand.svelte.spec.ts#nests an event cluster under its pill in the same year without repeating the title` | Done |
|
| REQ-014 | Ereignis event-clustered letters live inside a **contained card whose header is the same-year curated event** (glyph, title, date, provenance, edit pencil) — the title reads once, no separate floating pill; letters render as the compact `.lcard.ev` variant, first 5 + show-more (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/YearBand.svelte` | `LetterCard.svelte.spec.ts#carries the .lcard.ev class in the event variant`, `LetterBucket.svelte.spec.ts#renders the curated event as the card header when given an `event` (no separate pill)`, `LetterBucket.svelte.spec.ts#shows no edit affordance in the header when canWrite is false`, `YearBand.svelte.spec.ts#renders a same-year curated event as one card header, with no separate pill and no duplicate title` | Done |
|
||||||
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
|
| REQ-015 | Thema bucket-header chip tinted via rootTagColor `var(--c-tag-*)`, neutral fallback when null/unknown; **label kept in a fixed ink for ≥4.5:1 contrast** (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/BucketHeaderChip.svelte` | `BucketHeaderChip.svelte.spec.ts#tints the chip with var(--c-tag-{token})`, `#renders a neutral chip with no --c-tag- binding when colour is null`, `#falls back to neutral for an unknown colour token`, `#paints the label in a fixed ink colour, never the saturated tag token` | Done |
|
||||||
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
|
| REQ-016 | header meta-line grouping segment tracks the active mode (date/event/thema keys) | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte` | `zeitstrahl/page.svelte.spec.ts#updates the meta-line grouping label when a mode is chosen` | Done |
|
||||||
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
|
| REQ-017 | Thema: per-letter TagChip suppressed inside its own bucket; still shown in Datum/Ereignis | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterCard.svelte`, `frontend/src/lib/timeline/LetterBucket.svelte` | `LetterCard.svelte.spec.ts#suppresses the per-letter tag chip when asked`, `#still shows the per-letter tag chip when not suppressed`, `LetterBucket.svelte.spec.ts#suppresses the per-letter tag chip inside its own root-tag bucket` | Done |
|
||||||
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
|
| REQ-018 | Letters layer off → grouping control disabled (kept in place), mode retained | #827 | timeline-grouping-modes | `frontend/src/routes/zeitstrahl/+page.svelte`, `frontend/src/lib/timeline/GroupingControl.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#hasLooseLetters` | `zeitstrahl/page.svelte.spec.ts#disables the grouping control when the Letters layer is off`, `GroupingControl.svelte.spec.ts#retains the active mode while disabled`, `timelineGrouping.spec.ts#hasLooseLetters` | Done |
|
||||||
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
|
| REQ-019 | Ereignis: letter whose only linking event was filtered off → "Weitere Briefe" (never re-introduced) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/timelineGrouping.ts#buildEventLookup`, `frontend/src/lib/timeline/TimelineView.svelte` | `timelineGrouping.spec.ts#drops a letter whose linked event is absent from the lookup into fallback` | Done |
|
||||||
| REQ-020 | Grouped buckets are bound by a colour rail and carry compact cards; a bucket over `BUCKET_DENSE_THRESHOLD` (6) collapses to the month-density `YearLetterStrip` instead of flooding the timeline (redesign #847) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#isBucketDense`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#collapses an oversized bucket to the density strip instead of flooding cards`, `#binds a tag bucket together with a coloured left rail from its token`, `#renders compact cards for a small bucket`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
| REQ-020 | Grouped clusters are **contained colour-railed cards** (bordered, rounded, surface) carrying compact cards; a cluster shows the first `CLUSTER_PREVIEW` (5) letters behind a show-more toggle, and the leftover bin is a **collapsed count-only drawer** revealed on demand — the month-density `YearLetterStrip` is no longer used in grouped mode (still used in Datum dense years) (redesign #847 → #827 grouped-card layout) | #827 | timeline-grouping-modes | `frontend/src/lib/timeline/LetterBucket.svelte`, `frontend/src/lib/timeline/timelineGrouping.ts#CLUSTER_PREVIEW`, `frontend/src/lib/timeline/LetterCard.svelte` | `LetterBucket.svelte.spec.ts#renders the cluster as a contained card (bordered, rounded, surface)`, `#binds a tag bucket together with a coloured left rail from its token`, `#shows only the first 5 letters with a show-more toggle when the cluster is larger`, `#expands to all letters and collapses back on toggle`, `#renders collapsed — count + reveal, no letter cards — until opened`, `#reveals the first 5 letters when opened`, `LetterCard.svelte.spec.ts#renders the compact variant on a single tighter row` | Done |
|
||||||
|
|||||||
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
"timeline_grouping_multitag_hint": "Brief mit mehreren Tags erscheint unter seinem primären Tag.",
|
||||||
"timeline_bucket_other_letters": "Weitere Briefe",
|
"timeline_bucket_other_letters": "Weitere Briefe",
|
||||||
"timeline_bucket_no_topic": "Ohne Thema",
|
"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_derived": "abgeleitet",
|
||||||
"timeline_provenance_curated": "kuratiert",
|
"timeline_provenance_curated": "kuratiert",
|
||||||
"timeline_letter_glyph_label": "Brief",
|
"timeline_letter_glyph_label": "Brief",
|
||||||
|
|||||||
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
"timeline_grouping_multitag_hint": "A letter with several tags appears under its primary tag.",
|
||||||
"timeline_bucket_other_letters": "More letters",
|
"timeline_bucket_other_letters": "More letters",
|
||||||
"timeline_bucket_no_topic": "No topic",
|
"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_derived": "derived",
|
||||||
"timeline_provenance_curated": "curated",
|
"timeline_provenance_curated": "curated",
|
||||||
"timeline_letter_glyph_label": "Letter",
|
"timeline_letter_glyph_label": "Letter",
|
||||||
|
|||||||
@@ -1063,6 +1063,8 @@
|
|||||||
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
"timeline_grouping_multitag_hint": "Una carta con varias etiquetas aparece bajo su etiqueta principal.",
|
||||||
"timeline_bucket_other_letters": "Más cartas",
|
"timeline_bucket_other_letters": "Más cartas",
|
||||||
"timeline_bucket_no_topic": "Sin tema",
|
"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_derived": "derivado",
|
||||||
"timeline_provenance_curated": "curado",
|
"timeline_provenance_curated": "curado",
|
||||||
"timeline_letter_glyph_label": "Carta",
|
"timeline_letter_glyph_label": "Carta",
|
||||||
|
|||||||
@@ -2,83 +2,194 @@
|
|||||||
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 { isBucketDense, tagColorVar, type LetterBucket } from './timelineGrouping';
|
import { getAccentConfig } from './eventCardConfig';
|
||||||
|
import { timelineDateLabel } from './dateLabel';
|
||||||
|
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One cluster of loose letters, bound together by a coloured left rail so the group reads as a
|
* 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
|
* unit (#827). The axis-fixed world-band layer is rendered elsewhere — this is only the
|
||||||
* loose-letter bundling.
|
* loose-letter bundling.
|
||||||
*
|
*
|
||||||
* - Thema: a tinted root-tag header chip + a rail in the tag colour, over compact cards whose own
|
* - 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).
|
* tag chip is suppressed (REQ-004/015/017).
|
||||||
* - Ereignis: rendered `nested` directly beneath its event pill — no header (the pill is the
|
* - Ereignis: a same-year curated `event` becomes the card header (glyph, title, date,
|
||||||
* header), a mint rail, `.lcard.ev` cards (REQ-003/014). The standalone "Weitere Briefe" /
|
* provenance, edit pencil) so its title reads once — no separate floating pill (#827 redesign,
|
||||||
* "Ohne Thema" fallback keeps its label and a neutral rail (REQ-006/007).
|
* REQ-001/014). A cross-year cluster keeps a plain text header. The standalone "Weitere Briefe"
|
||||||
|
* / "Ohne Thema" fallback keeps its label and a neutral dashed rail (REQ-006/007).
|
||||||
*
|
*
|
||||||
* A bucket larger than the density threshold collapses to the month-density `YearLetterStrip`
|
* 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) — the catch-all buckets are the biggest.
|
* instead of flooding the timeline with every card (#827 redesign).
|
||||||
*/
|
*/
|
||||||
let {
|
let {
|
||||||
bucket,
|
bucket,
|
||||||
mode,
|
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,
|
year = 0,
|
||||||
nested = false
|
nested = false,
|
||||||
}: { bucket: LetterBucket; mode: 'event' | 'thema'; year?: number; nested?: boolean } = $props();
|
event = undefined,
|
||||||
|
canWrite = false
|
||||||
|
}: {
|
||||||
|
bucket: LetterBucket;
|
||||||
|
mode: 'event' | 'thema';
|
||||||
|
year?: number;
|
||||||
|
nested?: boolean;
|
||||||
|
/** The same-year curated event whose letters this card holds — renders as the header. */
|
||||||
|
event?: TimelineEntryDTO;
|
||||||
|
canWrite?: 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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Event-as-header (#827 redesign): a same-year curated event renders as this card's header,
|
||||||
|
// mirroring EventPill — glyph + title + date · provenance + an edit pencil for a curator. The
|
||||||
|
// title is never repeated as a separate floating pill.
|
||||||
|
const accent = $derived(event ? getAccentConfig(event) : null);
|
||||||
|
const eventDateLabel = $derived(
|
||||||
|
event ? timelineDateLabel(event.eventDate, event.precision, event.eventDateEnd) : null
|
||||||
|
);
|
||||||
|
const provenance = $derived(
|
||||||
|
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||||
|
);
|
||||||
|
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||||
|
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||||
// The left rail binds the cluster: the tag colour in Thema, mint for an Ereignis cluster,
|
// 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).
|
// neutral for the fallback (and for a colourless/unknown tag token).
|
||||||
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
const railColor = $derived(bucket.kind === 'tag' ? tagColorVar(bucket.color) : null);
|
||||||
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
const railStyle = $derived(railColor ? `border-left-color: ${railColor}` : '');
|
||||||
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
const isEventCluster = $derived(nested || bucket.kind === 'event');
|
||||||
const cardVariant = $derived(mode === 'event' && isEventCluster ? 'event' : 'plain');
|
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);
|
||||||
|
|
||||||
|
// The catch-all "Weitere Briefe" / "Ohne Thema" bin is a junk drawer: render it count-only
|
||||||
|
// behind a reveal control so it never floods the timeline; every other cluster starts open
|
||||||
|
// (#827 redesign). The view re-creates a bucket per `{#each}` key, so the initial capture is
|
||||||
|
// the right lifetime — `revealed` belongs to this bucket instance.
|
||||||
|
const isDrawer = $derived(bucket.kind === 'fallback');
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let revealed = $state(bucket.kind !== 'fallback');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="my-3 border-l-2 pl-3"
|
class="my-3 overflow-hidden rounded-md border border-l-2 border-line bg-surface shadow-sm"
|
||||||
class:border-l-brand-mint={isEventCluster}
|
class:border-l-brand-mint={isEventCluster}
|
||||||
class:border-line={!railColor && !isEventCluster}
|
class:border-dashed={isDrawer}
|
||||||
style={railStyle}
|
style={railStyle}
|
||||||
data-testid="letter-bucket"
|
data-testid="letter-bucket"
|
||||||
data-bucket-kind={bucket.kind}
|
data-bucket-kind={bucket.kind}
|
||||||
>
|
>
|
||||||
{#if !nested}
|
{#if !nested}
|
||||||
<header class="mb-2 flex items-center gap-2">
|
{#if event && accent}
|
||||||
{#if mode === 'thema' && bucket.kind === 'tag'}
|
<!-- A same-year curated event IS the card header — its title reads once here, never
|
||||||
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
also as a floating pill (#827 redesign, REQ-001/014). Glyph is aria-hidden with an
|
||||||
{:else if mode === 'event' && bucket.kind === 'event'}
|
sr-only label sibling (REQ-018); the edit pencil mirrors EventPill's gate. -->
|
||||||
<span class="font-serif text-sm font-bold text-ink">
|
<header
|
||||||
<span aria-hidden="true">✉</span>
|
data-testid="bucket-event-header"
|
||||||
{bucket.title}
|
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
|
||||||
|
'curated'
|
||||||
|
? 'bg-brand-mint text-brand-navy'
|
||||||
|
: 'bg-brand-navy text-brand-mint'}"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{accent.glyph}</span>
|
||||||
|
<span class="sr-only">{accent.label}</span>
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
<span class="min-w-0 text-left">
|
||||||
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
|
||||||
{/if}
|
>{event.title}</span
|
||||||
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
>
|
||||||
</header>
|
<span class="block font-sans text-xs text-ink-3">
|
||||||
|
{eventSubtitle} <span data-testid="bucket-count">· {count}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{#if canEdit}
|
||||||
|
<a
|
||||||
|
data-testid="event-edit"
|
||||||
|
href="/zeitstrahl/events/{event.eventId}/edit"
|
||||||
|
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✎</span>
|
||||||
|
<span class="sr-only">{m.btn_edit()}</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{:else}
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-2 px-3 py-2"
|
||||||
|
class:bg-canvas={isEventCluster}
|
||||||
|
class:border-b={!isDrawer}
|
||||||
|
class:border-line={!isDrawer}
|
||||||
|
>
|
||||||
|
{#if mode === 'thema' && bucket.kind === 'tag'}
|
||||||
|
<BucketHeaderChip name={bucket.title ?? ''} color={bucket.color} />
|
||||||
|
{:else if mode === 'event' && bucket.kind === 'event'}
|
||||||
|
<span class="font-serif text-sm font-bold text-ink">
|
||||||
|
<span aria-hidden="true">✉</span>
|
||||||
|
{bucket.title}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans text-xs font-semibold text-ink-3">{fallbackLabel}</span>
|
||||||
|
{/if}
|
||||||
|
<span data-testid="bucket-count" class="font-sans text-xs text-ink-3">· {count}</span>
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if dense}
|
<div class="px-3 py-2">
|
||||||
<!-- Oversized bucket → the month-density strip (count + sparkline + expand), not a flood. -->
|
{#if !revealed}
|
||||||
<YearLetterStrip letters={bucket.letters} year={year} />
|
<button
|
||||||
{:else}
|
type="button"
|
||||||
<ul class="space-y-1.5">
|
data-testid="bucket-reveal"
|
||||||
{#each bucket.letters as letter (entryKey(letter))}
|
onclick={() => (revealed = true)}
|
||||||
<li>
|
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||||
<LetterCard
|
class="px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
entry={letter}
|
>
|
||||||
variant={cardVariant}
|
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
|
||||||
suppressTagChip={mode === 'thema'}
|
</button>
|
||||||
compact={true}
|
{:else}
|
||||||
/>
|
<ul class="space-y-1.5">
|
||||||
</li>
|
{#each visible as letter (entryKey(letter))}
|
||||||
{/each}
|
<li>
|
||||||
</ul>
|
<LetterCard
|
||||||
{/if}
|
entry={letter}
|
||||||
|
variant={cardVariant}
|
||||||
|
suppressTagChip={mode === 'thema'}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if hiddenCount > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="bucket-show-more"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
style="display: inline-flex; align-items: center; min-height: 44px"
|
||||||
|
class="mt-1 px-1 font-sans text-xs font-semibold text-brand-navy hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||||
|
>
|
||||||
|
{expanded
|
||||||
|
? m.timeline_bucket_show_less()
|
||||||
|
: m.timeline_bucket_show_more({ count: hiddenCount })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
import LetterBucket from './LetterBucket.svelte';
|
import LetterBucket from './LetterBucket.svelte';
|
||||||
import { makeEntry } from './test-factories';
|
import { makeEntry } from './test-factories';
|
||||||
@@ -78,47 +79,49 @@ const manyLetters = (n: number) =>
|
|||||||
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` })
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('LetterBucket — density + containment (#827)', () => {
|
describe('LetterBucket — preview cap + show-more (#827 redesign)', () => {
|
||||||
it('collapses an oversized bucket to the density strip instead of flooding cards', () => {
|
it('shows only the first 5 letters with a show-more toggle when the cluster is larger', () => {
|
||||||
const bucket: Bucket = {
|
const bucket: Bucket = {
|
||||||
key: 'tag:t1',
|
key: 'tag:t1',
|
||||||
kind: 'tag',
|
kind: 'tag',
|
||||||
title: 'Sonstiges',
|
title: 'Krieg',
|
||||||
color: null,
|
color: 'sienna',
|
||||||
letters: manyLetters(10)
|
letters: manyLetters(8)
|
||||||
};
|
};
|
||||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
// not ten individual cards dumped into the timeline
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
expect(document.querySelectorAll('a.lcard')).toHaveLength(0);
|
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 = {
|
const bucket: Bucket = {
|
||||||
key: 'tag:t1',
|
key: 'tag:t1',
|
||||||
kind: 'tag',
|
kind: 'tag',
|
||||||
title: 'Tod',
|
title: 'Tod',
|
||||||
color: null,
|
color: null,
|
||||||
letters: manyLetters(2)
|
letters: manyLetters(3)
|
||||||
};
|
};
|
||||||
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
|
||||||
expect(document.querySelectorAll('a.lcard.compact')).toHaveLength(2);
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
|
||||||
});
|
|
||||||
|
|
||||||
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', () => {
|
it('binds a tag bucket together with a coloured left rail from its token', () => {
|
||||||
@@ -134,3 +137,96 @@ describe('LetterBucket — density + containment (#827)', () => {
|
|||||||
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
expect(section.getAttribute('style') ?? '').toContain('var(--c-tag-sienna)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
|
||||||
|
const fb = (n: number): Bucket => ({
|
||||||
|
key: '__fallback__',
|
||||||
|
kind: 'fallback',
|
||||||
|
color: null,
|
||||||
|
letters: Array.from({ length: n }, (_, i) =>
|
||||||
|
makeEntry({ documentId: `f${i}`, eventDate: `1916-01-0${(i % 9) + 1}` })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
it('renders collapsed — count + reveal, no letter cards — until opened', () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||||
|
expect(document.querySelector('a.lcard')).toBeNull();
|
||||||
|
expect(document.body.textContent).toContain(m.timeline_bucket_other_letters());
|
||||||
|
expect(document.querySelector('[data-testid="bucket-reveal"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
it('reveals the first 5 letters when opened', async () => {
|
||||||
|
render(LetterBucket, { bucket: fb(20), mode: 'event', year: 1916 });
|
||||||
|
(document.querySelector('[data-testid="bucket-reveal"]') as HTMLButtonElement).click();
|
||||||
|
await tick();
|
||||||
|
expect(document.querySelectorAll('a.lcard')).toHaveLength(5);
|
||||||
|
expect(document.querySelector('[data-testid="bucket-show-more"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — card chrome (#827 redesign)', () => {
|
||||||
|
it('renders the cluster as a contained card (bordered, rounded, surface)', () => {
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'tag:t1',
|
||||||
|
kind: 'tag',
|
||||||
|
title: 'Krieg',
|
||||||
|
color: 'sienna',
|
||||||
|
letters: [makeEntry({ documentId: 'a' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
|
||||||
|
const card = document.querySelector('[data-testid="letter-bucket"]') as HTMLElement;
|
||||||
|
expect(card.className).toMatch(/\brounded\b|rounded-/);
|
||||||
|
expect(card.className).toContain('border');
|
||||||
|
expect(card.className).toContain('bg-surface');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LetterBucket — event-as-header (#827 redesign)', () => {
|
||||||
|
it('renders the curated event as the card header when given an `event` (no separate pill)', () => {
|
||||||
|
const event = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand',
|
||||||
|
eventDate: '1916-07-06',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'event:e1',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'Ein gewaltiger Stadtbrand',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a', linkedEventId: 'e1' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: true });
|
||||||
|
const header = document.querySelector('[data-testid="bucket-event-header"]') as HTMLElement;
|
||||||
|
expect(header.textContent).toContain('Ein gewaltiger Stadtbrand');
|
||||||
|
expect(header.textContent).toContain(m.timeline_provenance_curated());
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')?.getAttribute('href')).toBe(
|
||||||
|
'/zeitstrahl/events/e1/edit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no edit affordance in the header when canWrite is false', () => {
|
||||||
|
const event = makeEntry({
|
||||||
|
kind: 'EVENT',
|
||||||
|
type: 'PERSONAL',
|
||||||
|
derived: false,
|
||||||
|
eventId: 'e1',
|
||||||
|
title: 'X',
|
||||||
|
senderName: '',
|
||||||
|
receiverName: '',
|
||||||
|
documentId: undefined
|
||||||
|
});
|
||||||
|
const bucket: Bucket = {
|
||||||
|
key: 'event:e1',
|
||||||
|
kind: 'event',
|
||||||
|
title: 'X',
|
||||||
|
color: null,
|
||||||
|
letters: [makeEntry({ documentId: 'a' })]
|
||||||
|
};
|
||||||
|
render(LetterBucket, { bucket, mode: 'event', year: 1916, event, canWrite: false });
|
||||||
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ let {
|
|||||||
|
|
||||||
type Row =
|
type Row =
|
||||||
| { t: 'event'; entry: TimelineEntryDTO }
|
| { t: 'event'; entry: TimelineEntryDTO }
|
||||||
|
| { t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }
|
||||||
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
| { t: 'letter'; entry: TimelineEntryDTO; side: 'left' | 'right' }
|
||||||
| { t: 'strip' }
|
| { t: 'strip' }
|
||||||
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
| { t: 'bucket'; bucket: LetterBucketModel; nested: boolean };
|
||||||
@@ -52,26 +53,30 @@ const bucketMode = $derived(groupingMode === 'thema' ? 'thema' : 'event');
|
|||||||
const rows = $derived.by<Row[]>(() => {
|
const rows = $derived.by<Row[]>(() => {
|
||||||
const out: Row[] = [];
|
const out: Row[] = [];
|
||||||
|
|
||||||
// Ereignis: events stay on the axis (REQ-001); each curated event's letters nest directly
|
// Ereignis: events stay on the axis (REQ-001). A curated event WITH letters in this band
|
||||||
// beneath its pill — the pill IS the header, so the title is never repeated. A cluster whose
|
// becomes the contained card's header (no separate pill — its title reads once, #827
|
||||||
// pill lives in another year band (or was filtered out) keeps its own header here, and the
|
// redesign); a letterless/derived/world event stays a plain pill/band. A cluster whose event
|
||||||
// unlinked letters fall to the single "Weitere Briefe" bucket (REQ-003/006/019).
|
// lives in another year band (or was filtered out) renders as a text-header card here, and
|
||||||
|
// the unlinked letters fall to the single "Weitere Briefe" drawer (REQ-003/006/019).
|
||||||
if (groupingMode === 'event') {
|
if (groupingMode === 'event') {
|
||||||
const buckets = bucketLetters(letters, 'event', eventLookup);
|
const buckets = bucketLetters(letters, 'event', eventLookup);
|
||||||
const hasPill = (bucketKey: string) =>
|
const sameYearBucket = (id: string | undefined) =>
|
||||||
year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucketKey);
|
id ? buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`) : undefined;
|
||||||
// Each pill renders, then its same-year cluster nests directly beneath it (no header).
|
|
||||||
for (const entry of year.entries) {
|
for (const entry of year.entries) {
|
||||||
if (entry.kind !== 'EVENT') continue;
|
if (entry.kind !== 'EVENT') continue;
|
||||||
out.push({ t: 'event', entry });
|
const bucket = sameYearBucket(entry.eventId);
|
||||||
const bucket = entry.eventId
|
// A curated event with same-year letters becomes the card header (card replaces pill);
|
||||||
? buckets.find((b) => b.kind === 'event' && b.key === `event:${entry.eventId}`)
|
// otherwise it stays a plain pill/world-band.
|
||||||
: undefined;
|
if (bucket) out.push({ t: 'eventcard', entry, bucket });
|
||||||
if (bucket) out.push({ t: 'bucket', bucket, nested: true });
|
else out.push({ t: 'event', entry });
|
||||||
}
|
}
|
||||||
// Clusters whose pill is in another band keep their header; then the fallback, last.
|
// Cross-year clusters (no matching event entry in this band) and the fallback drawer
|
||||||
|
// render after the axis entries, with their own text header.
|
||||||
for (const bucket of buckets) {
|
for (const bucket of buckets) {
|
||||||
if (bucket.kind === 'fallback' || !hasPill(bucket.key)) {
|
if (
|
||||||
|
bucket.kind === 'fallback' ||
|
||||||
|
!year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key)
|
||||||
|
) {
|
||||||
out.push({ t: 'bucket', bucket, nested: false });
|
out.push({ t: 'bucket', bucket, nested: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,14 @@ function rowKey(row: Row): string {
|
|||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if row.t === 'eventcard'}
|
||||||
|
<LetterBucket
|
||||||
|
bucket={row.bucket}
|
||||||
|
mode="event"
|
||||||
|
year={year.year}
|
||||||
|
event={row.entry}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
{:else if row.t === 'letter'}
|
{:else if row.t === 'letter'}
|
||||||
<div class="letter-row" data-side={row.side}>
|
<div class="letter-row" data-side={row.side}>
|
||||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ describe('YearBand — grouping modes (#827)', () => {
|
|||||||
expect(chip?.textContent).toContain('Krieg');
|
expect(chip?.textContent).toContain('Krieg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nests an event cluster under its pill in the same year without repeating the title (#827)', () => {
|
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title (#827)', () => {
|
||||||
const pill = makeEntry({
|
const pill = makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
type: 'PERSONAL',
|
type: 'PERSONAL',
|
||||||
@@ -238,14 +238,15 @@ describe('YearBand — grouping modes (#827)', () => {
|
|||||||
render(YearBand, {
|
render(YearBand, {
|
||||||
year: makeYear(1916, [pill, letter]),
|
year: makeYear(1916, [pill, letter]),
|
||||||
groupingMode: 'event',
|
groupingMode: 'event',
|
||||||
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']])
|
eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]),
|
||||||
|
canWrite: true
|
||||||
});
|
});
|
||||||
// the title appears exactly once — on the axis pill, NOT also as a bucket header
|
// the title appears exactly once — in the card header, not also as a separate pill
|
||||||
const occurrences =
|
const occurrences =
|
||||||
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
(document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
|
||||||
expect(occurrences).toBe(1);
|
expect(occurrences).toBe(1);
|
||||||
// the letter is still clustered (nested under the pill) as the event-letter card
|
// the event renders as the card header, with its letter clustered inside
|
||||||
expect(document.querySelector('[data-testid="letter-bucket"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
|
||||||
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
expect(document.querySelector('a.lcard.ev')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,18 @@ function eventLayerSignature(): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brief A links to the curated event p1 (Hochzeit), not the world band — so the world band
|
||||||
|
// stays letterless and renders as a plain band in every mode (REQ-001). Under the #827 redesign
|
||||||
|
// a curated event WITH letters becomes its cluster card's header, so the signature tracks the
|
||||||
|
// stable layer: the letterless world band's marker count and the two titles, which all survive
|
||||||
|
// regardless of whether Hochzeit renders as a pill (Datum) or a card header (grouped).
|
||||||
const mixed = () =>
|
const mixed = () =>
|
||||||
makeTimelineDTO({
|
makeTimelineDTO({
|
||||||
years: [
|
years: [
|
||||||
makeYear(1915, [
|
makeYear(1915, [
|
||||||
worldBand('Erster Weltkrieg'),
|
worldBand('Erster Weltkrieg'),
|
||||||
eventPill('Hochzeit'),
|
eventPill('Hochzeit'),
|
||||||
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'h1' }),
|
makeEntry({ documentId: 'a', title: 'Brief A', linkedEventId: 'p1' }),
|
||||||
makeEntry({
|
makeEntry({
|
||||||
documentId: 'b',
|
documentId: 'b',
|
||||||
title: 'Brief B',
|
title: 'Brief B',
|
||||||
|
|||||||
@@ -14,17 +14,8 @@ 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';
|
||||||
|
|
||||||
/**
|
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
|
||||||
* A bucket larger than this collapses to a month-density strip instead of flooding the
|
export const CLUSTER_PREVIEW = 5;
|
||||||
* 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. */
|
/** The `--c-tag-*` colour-name tokens (sage/sienna/…); shared by the chip and the rail. */
|
||||||
const TAG_COLOR_TOKENS = new Set([
|
const TAG_COLOR_TOKENS = new Set([
|
||||||
|
|||||||
Reference in New Issue
Block a user