refactor(timeline): extract shared EventHeader for pill + event-card
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 8m52s
CI / OCR Service Tests (pull_request) Successful in 2m58s
CI / Backend Unit Tests (pull_request) Failing after 13m32s
CI / fail2ban Regex (pull_request) Successful in 1m52s
CI / Semgrep Security Scan (pull_request) Successful in 43s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m51s
SDD Gate / RTM Check (pull_request) Successful in 26s
SDD Gate / Contract Validate (pull_request) Successful in 40s
SDD Gate / Constitution Impact (pull_request) Successful in 31s
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 8m52s
CI / OCR Service Tests (pull_request) Successful in 2m58s
CI / Backend Unit Tests (pull_request) Failing after 13m32s
CI / fail2ban Regex (pull_request) Successful in 1m52s
CI / Semgrep Security Scan (pull_request) Successful in 43s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m51s
SDD Gate / RTM Check (pull_request) Successful in 26s
SDD Gate / Contract Validate (pull_request) Successful in 40s
SDD Gate / Constitution Impact (pull_request) Successful in 31s
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:
@@ -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
|
||||||
|
|||||||
69
frontend/src/lib/timeline/EventHeader.svelte
Normal file
69
frontend/src/lib/timeline/EventHeader.svelte
Normal 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}
|
||||||
66
frontend/src/lib/timeline/EventHeader.svelte.spec.ts
Normal file
66
frontend/src/lib/timeline/EventHeader.svelte.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user