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

25 KiB
Raw Blame History

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 filesvelte/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):

"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).
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:

/** 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:

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):

<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.

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.
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:

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:

{#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.

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.
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.

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:

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):

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):

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:
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:

{: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.

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.

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.