Files
familienarchiv/docs/superpowers/plans/2026-06-15-zeitstrahl-grouped-card-layout.md
Marcel 4162cfa916 docs(timeline): implementation plan for the grouped-view contained-card layout
Five TDD tasks: preview cap + show-more, collapsed leftover drawer, card chrome,
same-year event as card header (kills duplicate), regression + RTM.

Refs #827 #847
2026-06-15 14:42:06 +02:00

375 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Zeitstrahl grouped-view contained-card layout — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the grouped-view's full-width bucket blocks with self-contained cards — a cluster (event or root tag) becomes one bordered card whose header is the event/tag and whose body shows the first 5 letters with a show-more toggle; the leftover bin collapses to a count-only drawer; a same-year curated event renders *as* its card header (no duplicate pill).
**Architecture:** Frontend-only, on branch `feat/issue-827-zeitstrahl-grouping` (worktree `.worktrees/issue-827-zeitstrahl-grouping`). Evolve `LetterBucket.svelte` into the contained card (preview cap + show-more + collapsed drawer + card chrome + optional event header) and rewire `YearBand.svelte`'s Ereignis branch so a same-year curated event becomes the card header instead of a separate pill. Datum mode is untouched. Design doc: `docs/superpowers/specs/2026-06-15-zeitstrahl-grouped-view-layout-design.md`.
**Tech Stack:** Svelte 5 (runes), Tailwind 4, Paraglide i18n, Vitest browser mode (`--project=client` for `*.svelte.spec.ts`, `--project=server` for plain `*.spec.ts`).
**Conventions (read before starting):**
- Red→green TDD, one logical change per commit, `Refs #827` on the last body line.
- Run only the specific spec file(s) — never the full suite. Client: `npx vitest run <file> --project=client`; server: `... --project=server`.
- Before each commit: `npx prettier --write <changed files>`, then `git add <files>` + `git diff --cached --stat` to verify the staged set, then commit (the pre-commit hook runs `prettier --check` + `eslint`).
- **No `new Set()`/`new Map()` inside a `.svelte` file** — `svelte/prefer-svelte-reactivity` errors even on transient locals. Use plain arrays (`find`/`some`/`filter`) inside `$derived`. (Pure `.ts` modules are fine.)
- Prettier rewrites `class:foo` shorthand to `class:foo={foo}` — expect that.
- Factories in `frontend/src/lib/timeline/test-factories.ts`: `makeEntry`, `makeYear`, `makeTimelineDTO`.
---
## File Structure
- `frontend/src/lib/timeline/timelineGrouping.ts` — add `CLUSTER_PREVIEW = 5`; remove `BUCKET_DENSE_THRESHOLD`/`isBucketDense` (no longer used). Keep `bucketLetters`, `buildEventLookup`, `hasLooseLetters`, `tagColorVar`.
- `frontend/src/lib/timeline/LetterBucket.svelte` — the contained card: card chrome + colour rail + header variants (tag chip / event-header / cross-year text label / drawer label) + body (first-5 preview, show-more toggle, drawer collapsed-by-default). Drop the `YearLetterStrip` branch.
- `frontend/src/lib/timeline/YearBand.svelte` — Ereignis branch: a same-year curated event renders as a `LetterBucket` card with `event={entry}` (no separate pill); letterless/derived/world events stay plain; cross-year clusters + the fallback drawer render after the axis entries.
- `frontend/messages/{de,en,es}.json` — two new keys: `timeline_bucket_show_more` (`{count}`), `timeline_bucket_show_less`.
- Specs: `LetterBucket.svelte.spec.ts`, `YearBand.svelte.spec.ts`, `messages.spec.ts` (extend), plus the route spec stays green.
- `.specify/rtm.md` — update REQ-014/REQ-020 rows.
---
## Task 1: Preview cap + show-more toggle (drop the sparkline)
**Files:**
- Modify: `frontend/src/lib/timeline/timelineGrouping.ts`
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
- [ ] **Step 1: Add the i18n keys** in all three locales (so the toggle has a label).
`frontend/messages/de.json` (next to the existing `timeline_bucket_*` keys):
```json
"timeline_bucket_show_more": "+ {count} weitere Briefe anzeigen",
"timeline_bucket_show_less": "Weniger anzeigen",
```
`en.json`: `"+ {count} more letters"`, `"Show fewer"`. `es.json`: `"+ {count} cartas más"`, `"Mostrar menos"`.
Run `npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide` (from `frontend/`) or let the dev/test build compile them.
- [ ] **Step 2: Write the failing tests** in `LetterBucket.svelte.spec.ts` (replace the `manyLetters`-based density tests from PR #847 — the sparkline is going away).
```ts
const manyLetters = (n: number) =>
Array.from({ length: n }, (_, i) => makeEntry({ documentId: `d${i}`, eventDate: `1916-0${(i % 9) + 1}-01` }));
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: 'Krieg', color: 'sienna', letters: manyLetters(8) };
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
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('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(3) };
render(LetterBucket, { bucket, mode: 'thema', year: 1916 });
expect(document.querySelectorAll('a.lcard')).toHaveLength(3);
expect(document.querySelector('[data-testid="bucket-show-more"]')).toBeNull();
});
});
```
Add `import { tick } from 'svelte';` at the top of the spec if absent.
- [ ] **Step 3: Run the tests — verify they fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL (still rendering the strip / all cards).
- [ ] **Step 4: Implement.** In `timelineGrouping.ts` remove `BUCKET_DENSE_THRESHOLD` + `isBucketDense`, add:
```ts
/** Letters shown before a cluster needs a "show more" toggle (#827 redesign). */
export const CLUSTER_PREVIEW = 5;
```
In `LetterBucket.svelte`: remove the `YearLetterStrip` import + the `dense`/strip branch. Add expand state and a visible-slice derived; render `CLUSTER_PREVIEW` compact cards, then a toggle when there are more:
```svelte
import { CLUSTER_PREVIEW, tagColorVar, type LetterBucket } from './timelineGrouping';
import * as m from '$lib/paraglide/messages.js';
// ...
let expanded = $state(false);
const visible = $derived(expanded ? bucket.letters : bucket.letters.slice(0, CLUSTER_PREVIEW));
const hiddenCount = $derived(bucket.letters.length - CLUSTER_PREVIEW);
```
Body markup (replace the `{#if dense}…{:else}…` block):
```svelte
<ul class="space-y-1.5">
{#each visible as letter (entryKey(letter))}
<li><LetterCard 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}
```
- [ ] **Step 5: Run the tests — verify they pass.** Same command → PASS. Also run `src/lib/timeline/timelineGrouping.spec.ts --project=server` (still green; only constants changed).
- [ ] **Step 6: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/timelineGrouping.ts src/lib/timeline/LetterBucket.svelte.spec.ts messages/de.json messages/en.json messages/es.json
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/timelineGrouping.ts frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/messages/{de,en,es}.json
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): cap grouped clusters at 5 letters with a show-more toggle\n\nReplaces the in-bucket month-density sparkline with a first-5 preview + show-more\n/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12\nYearLetterStrip.\n\nRefs #827')"
```
---
## Task 2: Collapsed drawer for the leftover bin
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
The fallback bucket (`kind === 'fallback'` — "Weitere Briefe"/"Ohne Thema") is a junk drawer: render it **collapsed** (count only, no letters) until the user reveals it; revealing shows the same first-5 + show-more body.
- [ ] **Step 1: Write the failing tests.**
```ts
describe('LetterBucket — leftover drawer (#827 redesign)', () => {
const fb = (n: number, mode: 'event' | 'thema'): 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, 'event'), 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, 'event'), 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();
});
});
```
- [ ] **Step 2: Run — verify fail.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts --project=client` → FAIL.
- [ ] **Step 3: Implement.** In `LetterBucket.svelte` add a `revealed` state defaulting to `bucket.kind !== 'fallback'` (non-drawers start open). Gate the body on it; when collapsed, render only the header + a reveal button:
```svelte
let revealed = $state(bucket.kind !== 'fallback');
// header always renders; body only when revealed
```
Collapsed drawer markup (when `!revealed`): the fallback label + count already render in the header; add the reveal control:
```svelte
{#if !revealed}
<button type="button" data-testid="bucket-reveal" onclick={() => (revealed = true)}
style="display:inline-flex;align-items:center;min-height:44px"
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">
{m.timeline_bucket_show_more({ count: bucket.letters.length })}
</button>
{:else}
<!-- the Task-1 body (ul + show-more) -->
{/if}
```
Give the drawer a dashed neutral rail: add `class:border-dashed={bucket.kind === 'fallback'}` to the card.
- [ ] **Step 4: Run — verify pass.** Same command → PASS. Re-run the Task-1 tests too (still green).
- [ ] **Step 5: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer\n\nThe catch-all bucket renders count-only by default behind a reveal control, then\nexpands to the first-5 + show-more body. Keeps the junk drawer quiet instead of\nflooding the timeline.\n\nRefs #827')"
```
---
## Task 3: Card chrome (the cluster is one contained card)
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte`
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`
Turn the `<section>` (rail-only, from PR #847) into a bordered card: `rounded` + `border border-line` + `bg-surface` + `shadow-sm`, keeping the coloured left rail (mint for event cluster, tag colour for tag, dashed neutral for the drawer). Header on a subtle `bg-canvas`/tint bar.
- [ ] **Step 1: Write the failing test.**
```ts
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');
});
```
- [ ] **Step 2: Run — verify fail.** → FAIL (current section is `my-3 border-l-2 pl-3`, no `bg-surface`/`rounded`/full `border`).
- [ ] **Step 3: Implement.** Update the `<section data-testid="letter-bucket">` classes to e.g.:
```
class="my-3 overflow-hidden rounded-md border border-line border-l-2 bg-surface shadow-sm"
```
keep `class:border-l-brand-mint={isEventCluster}`, `class:border-dashed={bucket.kind==='fallback'}`, and the inline `style={railStyle}` for the tag colour. Move the body padding inside (e.g. wrap header + body in a `px-3 py-2`), and give the header a tint bar (`bg-canvas` for events, plain for the drawer). Verify the existing "coloured left rail" test (`expect(section.style).toContain('var(--c-tag-sienna)')`) still holds — keep `railStyle` on the section.
- [ ] **Step 4: Run — verify pass.** Run the whole `LetterBucket.svelte.spec.ts` → all PASS (including the PR #847 rail test).
- [ ] **Step 5: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/LetterBucket.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): make a grouped cluster one contained card\n\nWraps each cluster in a bordered, rounded surface card (keeping the colour rail)\nso the header and its letters read as a single unit.\n\nRefs #827')"
```
---
## Task 4: Same-year curated event becomes the card header (kills the duplicate)
**Files:**
- Modify: `frontend/src/lib/timeline/LetterBucket.svelte` (add `event` + `canWrite` props + event-header rendering)
- Modify: `frontend/src/lib/timeline/YearBand.svelte` (render the card in place of the pill for a same-year curated event)
- Test: `frontend/src/lib/timeline/LetterBucket.svelte.spec.ts`, `frontend/src/lib/timeline/YearBand.svelte.spec.ts`
When a curated event has letters in the same band, the event IS the card header — no separate pill. Reuse `getAccentConfig` (glyph/label) + `timelineDateLabel` + the `kuratiert/abgeleitet` provenance + the `✎` edit affordance (curated + eventId + canWrite), mirroring `EventPill.svelte`.
- [ ] **Step 1: Write the failing tests.**
`LetterBucket.svelte.spec.ts`:
```ts
import { getAccentConfig } from './eventCardConfig';
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();
});
```
`YearBand.svelte.spec.ts` (replace/extend the PR #847 "nests an event cluster under its pill" test — the pill is now the card header):
```ts
it('renders a same-year curated event as one card header, with no separate pill and no duplicate title', () => {
const pill = makeEntry({ kind: 'EVENT', type: 'PERSONAL', derived: false, eventId: 'e1',
title: 'Ein gewaltiger Stadtbrand', eventDate: '1916-07-06', senderName: '', receiverName: '', documentId: undefined });
const letter = makeEntry({ documentId: 'a', linkedEventId: 'e1', eventDate: '1916-07-06' });
render(YearBand, { year: makeYear(1916, [pill, letter]), groupingMode: 'event', eventLookup: new Map([['e1', 'Ein gewaltiger Stadtbrand']]), canWrite: true });
const occurrences = (document.body.textContent ?? '').split('Ein gewaltiger Stadtbrand').length - 1;
expect(occurrences).toBe(1); // once — in the card header
expect(document.querySelector('[data-testid="bucket-event-header"]')).not.toBeNull();
expect(document.querySelector('a.lcard.ev')).not.toBeNull(); // its letter, inside
});
```
- [ ] **Step 2: Run — verify fail.** Both specs → FAIL.
- [ ] **Step 3: Implement `LetterBucket` header.** Add props `event?: TimelineEntryDTO` and `canWrite = false`. Derive (mirroring `EventPill.svelte`):
```svelte
import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
// ...
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);
```
Header branch order: `if (event)` → event header (`data-testid="bucket-event-header"`: glyph from `accent.glyph` aria-hidden + sr-only `accent.label`, `event.title`, `eventSubtitle`, count, and the `✎` link `/zeitstrahl/events/{event.eventId}/edit` with `data-testid="event-edit"` when `canEdit`); else the existing `thema/tag` chip / `fallback` label branches.
- [ ] **Step 4: Implement `YearBand` Ereignis branch.** When a curated event entry has a matching same-year bucket, render the card *instead of* the pill, passing `event={entry}` + `canWrite`; do **not** also push the `{ t: 'event' }` pill row for it. Letterless/derived/world events still push their pill/band row. Sketch:
```svelte
if (groupingMode === 'event') {
const buckets = bucketLetters(letters, 'event', eventLookup);
const sameYear = (id) => buckets.find((b) => b.kind === 'event' && b.key === `event:${id}`);
for (const entry of year.entries) {
if (entry.kind !== 'EVENT') continue;
const bucket = entry.eventId ? sameYear(entry.eventId) : undefined;
if (bucket) out.push({ t: 'eventcard', entry, bucket }); // card replaces pill
else out.push({ t: 'event', entry }); // plain pill/band
}
for (const bucket of buckets) {
if (bucket.kind === 'fallback' || !year.entries.some((e) => e.kind === 'EVENT' && `event:${e.eventId}` === bucket.key))
out.push({ t: 'bucket', bucket, nested: false }); // cross-year cluster / drawer
}
return out;
}
```
Add a `Row` variant `{ t: 'eventcard'; entry: TimelineEntryDTO; bucket: LetterBucketModel }` and template branch:
```svelte
{:else if row.t === 'eventcard'}
<LetterBucket bucket={row.bucket} mode="event" year={year.year} event={row.entry} canWrite={canWrite} />
```
Keep the existing `{ t: 'bucket' }` branch (cross-year clusters + drawer) rendering `<LetterBucket … nested={false} />` with no `event` prop → text header. Remember: **no `new Map`/`Set` in the component** — use `buckets.find` / `year.entries.some` as above.
- [ ] **Step 5: Run — verify pass.** `npx vitest run src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts --project=client` → PASS. The identity spec (REQ-001) still passes because derived/world fixtures are unchanged; if a now-stale assertion expects a pill for a *curated-with-letters* event, update it to expect the card header (REQ-001 amendment) and note it in the commit.
- [ ] **Step 6: Commit.**
```bash
npx prettier --write src/lib/timeline/LetterBucket.svelte src/lib/timeline/YearBand.svelte src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts
cd .. && git add frontend/src/lib/timeline/LetterBucket.svelte frontend/src/lib/timeline/YearBand.svelte frontend/src/lib/timeline/LetterBucket.svelte.spec.ts frontend/src/lib/timeline/YearBand.svelte.spec.ts
git diff --cached --stat
git commit -m "$(printf 'feat(timeline): render a same-year curated event as its cluster card header\n\nA curated event with letters in its own band now becomes the contained card header\n(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —\nthe title reads once. Derived life-events, world-bands, and letterless event pills\nare unchanged (REQ-001 amended for curated-with-letters).\n\nRefs #827')"
```
---
## Task 5: Regression sweep + route view + docs
**Files:**
- Verify: route + cross-year + thema specs
- Modify: `.specify/rtm.md`
- [ ] **Step 1: Run the affected specs (client).**
```
npx vitest run \
src/lib/timeline/BucketHeaderChip.svelte.spec.ts src/lib/timeline/LetterCard.svelte.spec.ts \
src/lib/timeline/LetterBucket.svelte.spec.ts src/lib/timeline/YearBand.svelte.spec.ts \
src/lib/timeline/GroupingControl.svelte.spec.ts src/lib/timeline/grouping-event-layer-identity.svelte.spec.ts \
src/lib/timeline/TimelineView.svelte.spec.ts src/routes/zeitstrahl/page.svelte.spec.ts --project=client
```
Expected: all PASS. Fix any cross-year/thema spec that assumed the old header (it should now find the card; thema card header is still the `BucketHeaderChip`).
- [ ] **Step 2: Run the server specs.** `npx vitest run src/lib/timeline/timelineGrouping.spec.ts src/lib/timeline/timeline-no-raw-html.spec.ts src/lib/messages.spec.ts --project=server` → PASS. If `messages.spec.ts` parity fails, it's the two new keys — they must be in de/en/es.
- [ ] **Step 3: Type-check the changed files.** `npm run check 2>&1 | grep -E "LetterBucket|YearBand|timelineGrouping"` → no ERROR lines (baseline noise elsewhere is fine).
- [ ] **Step 4: Update the RTM.** In `.specify/rtm.md`, edit REQ-014 (event-clustered letters live inside a contained card whose header is the same-year event; first-5 + show-more) and REQ-020 (clusters are contained cards with a 5-letter preview + show-more; the leftover bin is a collapsed drawer; the sparkline is no longer used in grouped mode), citing the new tests.
- [ ] **Step 5: Commit.**
```bash
cd .. && git add .specify/rtm.md
git diff --cached --stat
git commit -m "$(printf 'docs(rtm): trace the grouped-view contained-card layout (#827)\n\nRefs #827')"
```
- [ ] **Step 6: Push.** `git push origin feat/issue-827-zeitstrahl-grouping`
---
## Self-Review notes (author)
- **Spec coverage:** contained card (Task 3) ✓; first-5 + show-more (Task 1) ✓; collapsed drawer (Task 2) ✓; same-year event → card header / no duplicate (Task 4) ✓; derived/world unchanged (Task 4 keeps plain rows) ✓; thema chip header reused (existing, verified Task 5) ✓; cross-year text header (existing `{t:'bucket'}` path, verified Task 5) ✓; sparkline dropped from grouped mode (Task 1) ✓.
- **Naming consistency:** `CLUSTER_PREVIEW` (Task 1) used in Tasks 12; testids `bucket-show-more` / `bucket-reveal` / `bucket-event-header` / `event-edit` consistent across tasks; `Row` variant `eventcard` defined and consumed in Task 4.
- **REQ-001 amendment** is intentional and documented in the spec; Task 4 Step 5 flags fixing any stale identity assertion.