Compare commits

..

5 Commits

Author SHA1 Message Date
Marcel
be4bf8edc0 docs(rtm): trace the grouped-view contained-card layout (#827)
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 7m39s
CI / OCR Service Tests (pull_request) Successful in 47s
CI / Backend Unit Tests (pull_request) Failing after 15m5s
CI / fail2ban Regex (pull_request) Failing after 2m6s
CI / Semgrep Security Scan (pull_request) Successful in 44s
CI / Compose Bucket Idempotency (pull_request) Successful in 2m8s
SDD Gate / RTM Check (pull_request) Successful in 33s
SDD Gate / Contract Validate (pull_request) Successful in 43s
SDD Gate / Constitution Impact (pull_request) Successful in 34s
Refs #827
2026-06-15 16:35:03 +02:00
Marcel
70794616d2 feat(timeline): render a same-year curated event as its cluster card header
A curated event with letters in its own band now becomes the contained card header
(glyph, title, date, provenance, edit pencil) instead of a separate floating pill —
the title reads once. Derived life-events, world-bands, and letterless event pills
are unchanged (REQ-001 amended for curated-with-letters; the identity fixture now
links its letter to the curated event so the letterless world band stays a band).

Refs #827
2026-06-15 16:35:03 +02:00
Marcel
e100213760 feat(timeline): make a grouped cluster one contained card
Wraps each cluster in a bordered, rounded surface card (keeping the colour rail)
so the header and its letters read as a single unit.

Refs #827
2026-06-15 16:35:03 +02:00
Marcel
bea9acfe63 feat(timeline): collapse the leftover Weitere-Briefe/Ohne-Thema bin to a drawer
The catch-all bucket renders count-only by default behind a reveal control, then
expands to the first-5 + show-more body. Keeps the junk drawer quiet instead of
flooding the timeline.

Refs #827
2026-06-15 16:35:03 +02:00
Marcel
5a8bee3970 feat(timeline): cap grouped clusters at 5 letters with a show-more toggle
Replaces the in-bucket month-density sparkline with a first-5 preview + show-more
/ show-less toggle, the agreed grouped-view pattern. Datum mode keeps the >12
YearLetterStrip.

Refs #827
2026-06-15 16:35:03 +02:00

View File

@@ -1,374 +0,0 @@
# 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.