refactor(timeline): extract shared EventHeader for pill + event-card
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 47s
CI / OCR Service Tests (pull_request) Successful in 27s
CI / Backend Unit Tests (pull_request) Successful in 6m17s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
SDD Gate / RTM Check (pull_request) Successful in 19s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 18s

EventCluster's same-year header re-implemented EventPill's glyph circle,
serif title, provenance subtitle, and the curator edit anchor near-
verbatim — the third copy of that markup. They now share a single
EventHeader component (glyph via GlyphLabel, title, `{date} · provenance`
subtitle, optional sr-only letter count, and the canEditEvent-gated edit
pencil); EventPill keeps only its pill border, EventCluster only its card
chrome. Second half of review finding #5 (Architect-1). No behavior
change — EventPill/EventCluster/YearBand/TimelineView specs stay green.

Refs #850
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 22:43:55 +02:00
parent d450f97bff
commit bcf95e4399
4 changed files with 152 additions and 98 deletions

View File

@@ -2,9 +2,8 @@
import * as m from '$lib/paraglide/messages.js'; import * as m from '$lib/paraglide/messages.js';
import LetterCard from './LetterCard.svelte'; import LetterCard from './LetterCard.svelte';
import GlyphLabel from './GlyphLabel.svelte'; import GlyphLabel from './GlyphLabel.svelte';
import EventHeader from './EventHeader.svelte';
import { entryKey } from './entryKey'; import { entryKey } from './entryKey';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import { CLUSTER_PREVIEW } from './eventClustering'; import { CLUSTER_PREVIEW } from './eventClustering';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
@@ -15,9 +14,9 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
* header (so its title reads once — never also as a floating pill, #850 REQ-002), and its letters * header (so its title reads once — never also as a floating pill, #850 REQ-002), and its letters
* sit inside as compact `.lcard.ev` cards. * sit inside as compact `.lcard.ev` cards.
* *
* - Same-year event (`event` given): the header carries the accent glyph + sr-only label, the * - Same-year event (`event` given): the shared EventHeader carries the accent glyph + sr-only
* title, a `{date} · {kuratiert|abgeleitet}` subtitle, the letter count, and — for a curator on * label, the title, a `{date} · {kuratiert|abgeleitet}` subtitle, the letter count, and — for a
* a curated event — an edit link to `/zeitstrahl/events/{eventId}/edit` (REQ-002). * curator on a curated event — an edit link to `/zeitstrahl/events/{eventId}/edit` (REQ-002).
* - Cross-year (`title` given, no `event`): a plain `✉ {title}` text header, no edit link, no pill * - Cross-year (`title` given, no `event`): a plain `✉ {title}` text header, no edit link, no pill
* chrome — it holds that other year's linked letters (REQ-004). * chrome — it holds that other year's linked letters (REQ-004).
* *
@@ -40,19 +39,6 @@ let {
const count = $derived(letters.length); const count = $derived(letters.length);
// Event-as-header: a same-year curated event renders as this card's header, mirroring EventPill —
// glyph + title + date · provenance + an edit pencil for a curator. The title is never repeated
// as a separate floating pill (REQ-002).
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(!!event && canEditEvent(event, canWrite));
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every // First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
// card into the timeline. // card into the timeline.
let expanded = $state(false); let expanded = $state(false);
@@ -64,45 +50,15 @@ const hiddenCount = $derived(letters.length - CLUSTER_PREVIEW);
class="my-3 overflow-hidden rounded-md border border-l-2 border-line border-l-brand-mint bg-surface shadow-sm" class="my-3 overflow-hidden rounded-md border border-l-2 border-line border-l-brand-mint bg-surface shadow-sm"
data-testid="event-card" data-testid="event-card"
> >
{#if event && accent} {#if event}
<!-- A same-year curated event IS the card header — its title reads once here, never also <!-- A same-year curated event IS the card header (the shared EventHeader) — its title reads
as a floating pill (REQ-002). Glyph is aria-hidden with an sr-only label sibling; the once here, never also as a floating pill (REQ-002); the edit pencil uses the single
edit pencil mirrors EventPill's gate (REQ-010). --> canEditEvent gate (REQ-010, #850 finding #5). -->
<header <header
data-testid="event-header" data-testid="event-header"
class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2" class="flex items-center gap-2 border-b border-line bg-canvas px-3 py-2"
> >
<span <EventHeader entry={event} canWrite={canWrite} count={count} />
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {accent.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{accent.glyph}</span>
<span class="sr-only">{accent.label}</span>
</span>
<span class="min-w-0 text-left">
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{event.title}</span
>
<span class="block font-sans text-xs text-ink-3">
{eventSubtitle}
<span data-testid="event-count">
<span aria-hidden="true">· {count}</span>
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
</span>
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{event.eventId}/edit"
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</header> </header>
{:else} {:else}
<!-- Cross-year card (REQ-004): the same event's letters in another year band get a plain <!-- Cross-year card (REQ-004): the same event's letters in another year band get a plain

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import GlyphLabel from './GlyphLabel.svelte';
import { getAccentConfig, canEditEvent } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/**
* The shared header for a curated or derived timeline event — the accent glyph circle, the title,
* and the `{date} · {kuratiert|abgeleitet}` subtitle, plus a curator edit pencil gated by the
* single canEditEvent() contract. Rendered by EventPill (inside the floating axis pill) and by
* EventCluster (as a same-year event-card header), so the glyph/title/subtitle markup and the
* security-relevant edit gate live in one place (#850 finding #5). It renders three sibling nodes
* (glyph circle, text block, optional edit pencil) into the parent's flex row — the parent owns
* the wrapper (pill vs card header). An optional letter `count` appends a screen-reader-labeled
* "· {count}" for the event-card case.
*/
let {
entry,
canWrite = false,
count = undefined
}: { entry: TimelineEntryDTO; canWrite?: boolean; count?: number } = $props();
const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived: a derived life-event is "abgeleitet", a curated event
// "kuratiert"; the date is an optional prefix so an undated event still reads the provenance.
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canEditEvent(entry, canWrite));
</script>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent === 'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<GlyphLabel glyph={config.glyph} label={config.label} />
</span>
<span class="min-w-0 text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">
{subtitle}
{#if count !== undefined}
<span data-testid="event-count">
<span aria-hidden="true">· {count}</span>
<span class="sr-only">{m.timeline_cluster_letter_count({ count })}</span>
</span>
{/if}
</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-auto rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import * as m from '$lib/paraglide/messages.js';
import EventHeader from './EventHeader.svelte';
import { makeEntry } from './test-factories';
import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
afterEach(() => cleanup());
const EV_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const curated = (overrides: Partial<TimelineEntryDTO> = {}): TimelineEntryDTO =>
makeEntry({
kind: 'EVENT',
type: 'PERSONAL',
derived: false,
eventId: EV_ID,
eventDate: '1916-07-06',
precision: 'DAY',
title: 'Ein gewaltiger Stadtbrand',
documentId: undefined,
...overrides
});
describe('EventHeader', () => {
it('renders the glyph with an sr-only label, the title, and the provenance subtitle', () => {
render(EventHeader, { entry: curated() });
expect(document.querySelector('.sr-only')?.textContent).toBe(m.timeline_layer_family());
expect(document.body.textContent).toContain('Ein gewaltiger Stadtbrand');
expect(document.body.textContent).toContain(m.timeline_provenance_curated());
});
it('shows the edit pencil for a writer on a curated event (canEditEvent gate)', () => {
render(EventHeader, { entry: curated(), canWrite: true });
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement;
expect(edit).not.toBeNull();
expect(edit.getAttribute('href')).toBe(`/zeitstrahl/events/${EV_ID}/edit`);
});
it('hides the edit pencil without write, for a derived event, and for a null eventId', () => {
render(EventHeader, { entry: curated(), canWrite: false });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
cleanup();
render(EventHeader, { entry: curated({ derived: true }), canWrite: true });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
cleanup();
render(EventHeader, { entry: curated({ eventId: undefined }), canWrite: true });
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
});
it('renders a screen-reader-labeled letter count when a count is given', () => {
render(EventHeader, { entry: curated(), count: 3 });
const count = document.querySelector('[data-testid="event-count"]') as HTMLElement;
expect(count.querySelector('[aria-hidden="true"]')?.textContent).toContain('· 3');
expect(count.querySelector('.sr-only')?.textContent).toBe(
m.timeline_cluster_letter_count({ count: 3 })
);
});
it('omits the letter count when no count is given (the pill case)', () => {
render(EventHeader, { entry: curated() });
expect(document.querySelector('[data-testid="event-count"]')).toBeNull();
});
});

View File

@@ -1,32 +1,21 @@
<script lang="ts"> <script lang="ts">
import * as m from '$lib/paraglide/messages.js'; import EventHeader from './EventHeader.svelte';
import { getAccentConfig, canEditEvent } from './eventCardConfig'; import { getAccentConfig } from './eventCardConfig';
import { timelineDateLabel } from './dateLabel';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
/** /**
* Centered axis pill for a derived life-event or a curated PERSONAL event * Centered axis pill for a derived life-event or a curated PERSONAL event
* (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling * (REQ-007/008). The pill border keys off the accent (curated = mint, derived =
* (REQ-018). An edit affordance shows only for a curated event with an eventId * navy); its glyph, title, subtitle, and curator edit pencil are the shared
* (never derived, never null — REQ-008) and only for a curator who holds * EventHeader, so the edit gate (canEditEvent) lives in one place — #842
* WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The * REQ-005/007/008, #850 finding #5. The gate is UX only; the real boundary is the
* gate is UX only; the real boundary is the #781 route guard + backend permission. * #781 route guard + backend permission.
*/ */
let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props(); let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props();
const config = $derived(getAccentConfig(entry)); const config = $derived(getAccentConfig(entry));
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
// Provenance reads off entry.derived (not the accent): a derived life-event is
// "abgeleitet", a curated PERSONAL event is "kuratiert" (REQ-007).
const provenance = $derived(
entry.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
);
// Provenance always shows; the date is an optional prefix so an undated event
// still reads "abgeleitet"/"kuratiert" (REQ-007).
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
const canEdit = $derived(canEditEvent(entry, canWrite));
</script> </script>
<div class="flex justify-center"> <div class="flex justify-center">
@@ -36,32 +25,6 @@ const canEdit = $derived(canEditEvent(entry, canWrite));
? 'border-2 border-brand-mint' ? 'border-2 border-brand-mint'
: 'border border-brand-navy'}" : 'border border-brand-navy'}"
> >
<span <EventHeader entry={entry} canWrite={canWrite} />
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full {config.accent ===
'curated'
? 'bg-brand-mint text-brand-navy'
: 'bg-brand-navy text-brand-mint'}"
>
<span aria-hidden="true">{config.glyph}</span>
<span class="sr-only">{config.label}</span>
</span>
<span class="text-left">
{#if entry.title}
<span class="block font-serif text-sm font-bold whitespace-pre-line text-brand-navy"
>{entry.title}</span
>
{/if}
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
</span>
{#if canEdit}
<a
data-testid="event-edit"
href="/zeitstrahl/events/{entry.eventId}/edit"
class="ml-1 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
>
<span aria-hidden="true"></span>
<span class="sr-only">{m.btn_edit()}</span>
</a>
{/if}
</div> </div>
</div> </div>