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
25 KiB
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 #827on 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>, thengit add <files>+git diff --cached --statto verify the staged set, then commit (the pre-commit hook runsprettier --check+eslint). - No
new Set()/new Map()inside a.sveltefile —svelte/prefer-svelte-reactivityerrors even on transient locals. Use plain arrays (find/some/filter) inside$derived. (Pure.tsmodules are fine.) - Prettier rewrites
class:fooshorthand toclass:foo={foo}— expect that. - Factories in
frontend/src/lib/timeline/test-factories.ts:makeEntry,makeYear,makeTimelineDTO.
File Structure
frontend/src/lib/timeline/timelineGrouping.ts— addCLUSTER_PREVIEW = 5; removeBUCKET_DENSE_THRESHOLD/isBucketDense(no longer used). KeepbucketLetters,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 theYearLetterStripbranch.frontend/src/lib/timeline/YearBand.svelte— Ereignis branch: a same-year curated event renders as aLetterBucketcard withevent={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 themanyLetters-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.tsremoveBUCKET_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.svelteadd arevealedstate defaulting tobucket.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, nobg-surface/rounded/fullborder). -
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(addevent+canWriteprops + 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
LetterBucketheader. Add propsevent?: TimelineEntryDTOandcanWrite = false. Derive (mirroringEventPill.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
YearBandEreignis branch. When a curated event entry has a matching same-year bucket, render the card instead of the pill, passingevent={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. Ifmessages.spec.tsparity 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 1–2; testidsbucket-show-more/bucket-reveal/bucket-event-header/event-editconsistent across tasks;Rowvarianteventcarddefined 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.