Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836
@@ -123,3 +123,19 @@
|
||||
| REQ-015 | absent/empty/non-UUID originPersonId → redirect /zeitstrahl (CWE-601) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/eventFormServer.ts#resolveNavTarget` | `new/page.server.spec.ts#defaults to /zeitstrahl when originPersonId is not a valid UUID`, `#redirects to /persons/{id} when originPersonId is a valid UUID` | Done |
|
||||
| REQ-016 | title/description/chip labels via default `{...}` escaping, never `{@html}` (CWE-79) | #781 | timeline-curator-forms | `frontend/src/lib/timeline/EventForm.svelte` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero | Done |
|
||||
| REQ-017 | labelled pickers, visible empty states, ≥44px chip remove targets | #781 | timeline-curator-forms | `frontend/src/lib/person/PersonMultiSelect.svelte`, `document/DocumentMultiSelect.svelte`, `EventForm.svelte` | `PersonMultiSelect.svelte.spec.ts`, `DocumentMultiSelect.svelte.spec.ts` (green post-44px fix), `EventForm.svelte.spec.ts#preselects a person when initialPersons is provided` | Done |
|
||||
| REQ-001 | `/zeitstrahl` wraps the timeline in a `.tl-canvas` surface (rounded, bg-canvas, padding; outer border dropped in review — page is already bg-canvas) | #833 | zeitstrahl-visual-fidelity | `frontend/src/routes/zeitstrahl/+page.svelte` | `routes/zeitstrahl/page.svelte.spec.ts#wraps the timeline in a padded canvas surface, without an outer border` | Done |
|
||||
| REQ-002 | meta sub-line: range + letter count + event count (years + undated) + "Gruppierung: Datum"; range/line omitted when empty | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/timelineMeta.ts`, `frontend/src/routes/zeitstrahl/+page.svelte` | `timelineMeta.spec.ts` (4 cases), `routes/zeitstrahl/page.svelte.spec.ts#renders the meta sub-line`, `#omits the range segment`, `#omits the entire sub-line` | Done |
|
||||
| REQ-003 | year badge centered on axis ≥1024px, left spine <1024px; sticky top:4rem preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#centers the year badge on the axis at desktop`, `#left-aligns the year badge at phone width`, `#keeps the sticky year heading at top:4rem` | Done |
|
||||
| REQ-004 | year badge node marker on the spine, never overlapping the badge text (desktop + phone) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders a year-badge node marker that clears the badge text on phone` | Done |
|
||||
| REQ-005 | per-letter connector dot (white fill, mint ring) on the spine; phone column indented clear of card | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders one connector dot per letter row, each clearing its card on phone` | Done |
|
||||
| REQ-006 | axis gradient 3-stop mint→navy→slate via `--palette-mint`/`--palette-navy`/`--c-tag-slate` | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#paints the axis with a three-stop gradient` (+ REQ-013 grep) | Done |
|
||||
| REQ-007 | EventPill subtitle `{date} · {provenance}` keyed off `entry.derived` (abgeleitet/kuratiert) | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#appends the "abgeleitet" provenance`, `#appends the "kuratiert" provenance`, `#never shows persönlich/SEASON` | Done |
|
||||
| REQ-008 | LetterCard title prefixed with `aria-hidden` ✉ + sr-only "Brief"; href intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#prefixes a present title with an aria-hidden ✉`, `#renders an HTML-bearing title verbatim` | Done |
|
||||
| REQ-009 | WorldBand inline "· historisch" descriptor (non-RANGE & RANGE); RANGE span pill + aria-label intact | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#appends the inline "· historisch"`, `#follows the RANGE span pill with inline "· historisch"` | Done |
|
||||
| REQ-010 | YearLetterStrip count ✉ + sr-only label + "Monats-Dichte" caption; expand toggle preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#prefixes the count with an aria-hidden ✉`, `#keeps the expand toggle and its label` | Done |
|
||||
| REQ-011 | YearLetterStrip exactly two endpoint month-axis labels (Jan/Dez {year}) ≥10px via formatTickLabel | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/YearLetterStrip.svelte` | `YearLetterStrip.svelte.spec.ts#renders exactly two endpoint month-axis labels` | Done |
|
||||
| REQ-012 | undated "Ohne Datum · {count}" in a dashed frame; empty → absent; kind/type dispatch preserved | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#frames the undated section with a dashed border and shows the count`, `#omits the "Ohne Datum" section when empty` | Done |
|
||||
| REQ-013 | all new styles use semantic tokens; corrected hex grep returns zero hits | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | grep gate (REQ-013 form) → zero | Done |
|
||||
| REQ-014 | no change to DTO order, density threshold (12), gap-folding, or ol/section/h2 structure | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/*` | existing timeline + `zeitstrahl/page.server.test.ts` suites stay green (142 tests) | Done |
|
||||
| REQ-015 | new user-facing strings are Paraglide keys present in de/en/es with matching key sets | #833 | zeitstrahl-visual-fidelity | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#zeitstrahl visual-fidelity keys are present in all locales`, `#identical key sets` | Done |
|
||||
| REQ-016 | LetterCard with no title → no ✉, no sr-only "Brief"; sender→receiver + date still render | #833 | zeitstrahl-visual-fidelity | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders no ✉ glyph and no "Brief" label when the title is empty` | Done |
|
||||
|
||||
@@ -1046,6 +1046,15 @@
|
||||
"timeline_derived_birth": "Geburt",
|
||||
"timeline_derived_death": "Tod",
|
||||
"timeline_derived_marriage": "Heirat",
|
||||
"timeline_grouping_date": "Gruppierung: Datum",
|
||||
"timeline_provenance_derived": "abgeleitet",
|
||||
"timeline_provenance_curated": "kuratiert",
|
||||
"timeline_letter_glyph_label": "Brief",
|
||||
"timeline_layer_historical_suffix": "historisch",
|
||||
"timeline_strip_density_caption": "Monats-Dichte",
|
||||
"timeline_events_count": "{count} Ereignisse",
|
||||
"timeline_letters_count_singular": "1 Brief",
|
||||
"timeline_events_count_singular": "1 Ereignis",
|
||||
"event_editor_new_title": "Neues Ereignis",
|
||||
"event_editor_edit_title": "Ereignis bearbeiten",
|
||||
"event_editor_section_when": "Wann",
|
||||
|
||||
@@ -1046,6 +1046,15 @@
|
||||
"timeline_derived_birth": "Birth",
|
||||
"timeline_derived_death": "Death",
|
||||
"timeline_derived_marriage": "Marriage",
|
||||
"timeline_grouping_date": "Grouping: Date",
|
||||
"timeline_provenance_derived": "derived",
|
||||
"timeline_provenance_curated": "curated",
|
||||
"timeline_letter_glyph_label": "Letter",
|
||||
"timeline_layer_historical_suffix": "historical",
|
||||
"timeline_strip_density_caption": "Monthly density",
|
||||
"timeline_events_count": "{count} events",
|
||||
"timeline_letters_count_singular": "1 letter",
|
||||
"timeline_events_count_singular": "1 event",
|
||||
"event_editor_new_title": "New event",
|
||||
"event_editor_edit_title": "Edit event",
|
||||
"event_editor_section_when": "When",
|
||||
|
||||
@@ -1046,6 +1046,15 @@
|
||||
"timeline_derived_birth": "Nacimiento",
|
||||
"timeline_derived_death": "Fallecimiento",
|
||||
"timeline_derived_marriage": "Matrimonio",
|
||||
"timeline_grouping_date": "Agrupación: Fecha",
|
||||
"timeline_provenance_derived": "derivado",
|
||||
"timeline_provenance_curated": "curado",
|
||||
"timeline_letter_glyph_label": "Carta",
|
||||
"timeline_layer_historical_suffix": "histórico",
|
||||
"timeline_strip_density_caption": "Densidad mensual",
|
||||
"timeline_events_count": "{count} eventos",
|
||||
"timeline_letters_count_singular": "1 carta",
|
||||
"timeline_events_count_singular": "1 evento",
|
||||
"event_editor_new_title": "Nuevo evento",
|
||||
"event_editor_edit_title": "Editar evento",
|
||||
"event_editor_section_when": "Cuándo",
|
||||
|
||||
@@ -68,4 +68,26 @@ describe('message key parity', () => {
|
||||
timeline_derived_marriage: 'Matrimonio'
|
||||
});
|
||||
});
|
||||
|
||||
// #833 REQ-015: the new visual-fidelity strings (meta line, provenance token,
|
||||
// ✉ label, world-band suffix, density caption) are Paraglide keys present in
|
||||
// every locale so no surface ever falls back to a missing translation.
|
||||
it('zeitstrahl visual-fidelity keys are present in all locales (#833 REQ-015)', () => {
|
||||
const requiredKeys = [
|
||||
'timeline_grouping_date',
|
||||
'timeline_provenance_derived',
|
||||
'timeline_provenance_curated',
|
||||
'timeline_letter_glyph_label',
|
||||
'timeline_layer_historical_suffix',
|
||||
'timeline_strip_density_caption',
|
||||
'timeline_events_count',
|
||||
'timeline_letters_count_singular',
|
||||
'timeline_events_count_singular'
|
||||
];
|
||||
for (const key of requiredKeys) {
|
||||
expect(de, `missing key in de: ${key}`).toHaveProperty(key);
|
||||
expect(en, `missing key in en: ${key}`).toHaveProperty(key);
|
||||
expect(es, `missing key in es: ${key}`).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,14 @@ let { entry }: { entry: TimelineEntryDTO } = $props();
|
||||
|
||||
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(!entry.derived && entry.eventId != null);
|
||||
</script>
|
||||
|
||||
@@ -41,9 +49,7 @@ const canEdit = $derived(!entry.derived && entry.eventId != null);
|
||||
>{entry.title}</span
|
||||
>
|
||||
{/if}
|
||||
{#if dateLabel}
|
||||
<span class="block font-sans text-xs text-ink-3">{dateLabel}</span>
|
||||
{/if}
|
||||
<span class="block font-sans text-xs text-ink-3">{subtitle}</span>
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import EventPill from './EventPill.svelte';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
@@ -87,4 +89,54 @@ describe('EventPill', () => {
|
||||
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('appends the "abgeleitet" provenance token to a derived pill subtitle (REQ-007)', () => {
|
||||
const entry = derived('BIRTH', 'Geburt: Hans');
|
||||
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
|
||||
render(EventPill, { entry });
|
||||
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_derived()}`);
|
||||
});
|
||||
|
||||
it('appends the "kuratiert" provenance token to a curated PERSONAL pill subtitle (REQ-007)', () => {
|
||||
const entry = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: false,
|
||||
type: 'PERSONAL',
|
||||
eventId: EVENT_ID,
|
||||
title: 'Auswanderung',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
precision: 'YEAR',
|
||||
eventDate: '1924-01-01',
|
||||
documentId: undefined
|
||||
});
|
||||
const date = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
|
||||
render(EventPill, { entry });
|
||||
expect(document.body.textContent).toContain(`${date} · ${m.timeline_provenance_curated()}`);
|
||||
});
|
||||
|
||||
it('never shows the spec-sheet-only "persönlich"/"SEASON" tokens (REQ-007)', () => {
|
||||
render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
|
||||
expect(document.body.textContent).not.toContain('persönlich');
|
||||
expect(document.body.textContent).not.toContain('SEASON');
|
||||
});
|
||||
|
||||
it('still shows the provenance token when the event has no date label (REQ-007)', () => {
|
||||
// An undated / UNKNOWN-precision event (e.g. in the undated bucket) yields a
|
||||
// null dateLabel; provenance must not be gated behind the date.
|
||||
const entry = makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title: 'Geburt: Hans',
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
precision: 'UNKNOWN',
|
||||
eventDate: undefined,
|
||||
documentId: undefined
|
||||
});
|
||||
expect(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)).toBeNull();
|
||||
render(EventPill, { entry });
|
||||
expect(document.body.textContent).toContain(m.timeline_provenance_derived());
|
||||
});
|
||||
});
|
||||
|
||||
12
frontend/src/lib/timeline/GlyphLabel.svelte
Normal file
12
frontend/src/lib/timeline/GlyphLabel.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A decorative glyph paired with a screen-reader-only text label — the
|
||||
* non-color accessibility cue used across timeline cards (e.g. the ✉ on a
|
||||
* letter title or letter-count). The glyph is `aria-hidden`; the label carries
|
||||
* the meaning for assistive tech.
|
||||
*/
|
||||
let { glyph, label }: { glyph: string; label: string } = $props();
|
||||
</script>
|
||||
|
||||
<span aria-hidden="true">{glyph}</span>
|
||||
<span class="sr-only">{label}</span>
|
||||
15
frontend/src/lib/timeline/GlyphLabel.svelte.spec.ts
Normal file
15
frontend/src/lib/timeline/GlyphLabel.svelte.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GlyphLabel', () => {
|
||||
it('renders the glyph aria-hidden with an sr-only label sibling', () => {
|
||||
render(GlyphLabel, { glyph: '✉', label: 'Brief' });
|
||||
const hidden = document.querySelector('[aria-hidden="true"]');
|
||||
expect(hidden?.textContent).toBe('✉');
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe('Brief');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
@@ -29,9 +30,13 @@ const receiver = $derived(
|
||||
class="rounded-sm border border-l-[3px] border-line border-l-brand-mint bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
{#if entry.title}
|
||||
<span class="font-serif text-sm font-bold break-words whitespace-pre-line text-ink"
|
||||
>{entry.title}</span
|
||||
>
|
||||
<!-- ✉ + sr-only label are static chrome rendered as sibling nodes, NEVER
|
||||
interpolated into the escaped user title; the title keeps its own
|
||||
pre-line span for multi-line OCR text (REQ-008/016/021). -->
|
||||
<span class="font-serif text-sm font-bold break-words text-ink">
|
||||
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
||||
<span class="whitespace-pre-line">{entry.title}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="mt-0.5 font-sans text-xs break-words text-ink-3">
|
||||
<span class="font-serif whitespace-pre-line">{sender}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { makeEntry } from './test-factories';
|
||||
@@ -55,4 +56,34 @@ describe('LetterCard', () => {
|
||||
const link = document.querySelector('a') as HTMLAnchorElement;
|
||||
expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('prefixes a present title with an aria-hidden ✉ and an sr-only "Brief" label (REQ-008)', () => {
|
||||
render(LetterCard, { entry: makeEntry({ title: 'Brief aus Stettin', documentId: DOC_ID }) });
|
||||
const hidden = document.querySelector('[aria-hidden="true"]');
|
||||
expect(hidden?.textContent).toContain('✉');
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
|
||||
// The glyph is decorative chrome — the document link is unchanged.
|
||||
const link = document.querySelector('a') as HTMLAnchorElement;
|
||||
expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
|
||||
});
|
||||
|
||||
it('renders no ✉ glyph and no "Brief" label when the title is empty (REQ-016)', () => {
|
||||
render(LetterCard, {
|
||||
entry: makeEntry({ title: '', senderName: 'Karl', receiverName: 'Elfriede' })
|
||||
});
|
||||
expect(document.body.textContent).not.toContain('✉');
|
||||
expect(document.querySelector('.sr-only')).toBeNull();
|
||||
// The row still shows sender → receiver and the date.
|
||||
expect(document.body.textContent).toContain('Karl');
|
||||
expect(document.body.textContent).toContain('Elfriede');
|
||||
expect(document.querySelector('[data-testid="letter-date"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders an HTML-bearing title verbatim as text, never as markup (security, REQ-021)', () => {
|
||||
const evil = '<script>alert(1)</script>';
|
||||
render(LetterCard, { entry: makeEntry({ title: evil }) });
|
||||
expect(document.body.textContent).toContain(evil);
|
||||
expect(document.querySelector('a script')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,8 +59,13 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
||||
</ol>
|
||||
|
||||
{#if timeline.undated.length > 0}
|
||||
<section data-testid="undated-section" class="mx-auto mt-8 max-w-3xl">
|
||||
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">{m.timeline_undated_section()}</h2>
|
||||
<section
|
||||
data-testid="undated-section"
|
||||
class="mx-auto mt-8 max-w-3xl rounded-sm border border-dashed border-line bg-surface p-4"
|
||||
>
|
||||
<h2 class="mb-3 font-serif text-sm font-bold text-ink-2">
|
||||
{m.timeline_undated_section()} · {timeline.undated.length}
|
||||
</h2>
|
||||
<ul class="space-y-2">
|
||||
<!-- The undated bucket is filtered from ALL entries, so it can hold
|
||||
events as well as letters. Dispatch on kind/type exactly like
|
||||
@@ -85,6 +90,18 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Establish a stacking context so the spine (z-index: -1) sits behind the
|
||||
in-flow cards/pills/strips but still in front of the canvas — the line is
|
||||
always background; the badges, dots and markers ride on top of it.
|
||||
--spine-x is the single source of truth for the spine's X position; the
|
||||
year-node and connector dots in YearBand consume it via inheritance so the
|
||||
markers can never desync from the line. */
|
||||
.timeline-axis {
|
||||
--spine-x: 0.5rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Phone (< 1024px): a single left-anchored spine. Desktop (≥ 1024px): a
|
||||
centered spine the bands' alternating cards sit on either side of. The
|
||||
spine is decorative — the chronology lives in the <ol> DOM order. */
|
||||
@@ -93,14 +110,19 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0.5rem;
|
||||
left: var(--spine-x);
|
||||
width: 2px;
|
||||
background: linear-gradient(var(--palette-mint), var(--palette-navy));
|
||||
z-index: -1;
|
||||
/* Three-stop life-thread: mint → navy → slate. Slate lives only as
|
||||
--c-tag-slate (there is no --palette-slate). REQ-006/013. */
|
||||
background: linear-gradient(var(--palette-mint), var(--palette-navy), var(--c-tag-slate));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.timeline-axis {
|
||||
--spine-x: 50%;
|
||||
}
|
||||
.timeline-axis::before {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import TimelineView from './TimelineView.svelte';
|
||||
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||
|
||||
@@ -73,6 +74,19 @@ describe('TimelineView', () => {
|
||||
expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('frames the undated section with a dashed border and shows the count (REQ-012)', () => {
|
||||
const undated = Array.from({ length: 11 }, (_, i) =>
|
||||
makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: `u-${i}` })
|
||||
);
|
||||
render(TimelineView, { timeline: makeTimelineDTO({ undated }) });
|
||||
const section = document.querySelector('[data-testid="undated-section"]') as HTMLElement;
|
||||
expect(section).not.toBeNull();
|
||||
expect(section.classList.contains('border-dashed')).toBe(true);
|
||||
const h2 = section.querySelector('h2');
|
||||
expect(h2?.textContent).toContain(m.timeline_undated_section());
|
||||
expect(h2?.textContent).toContain('11');
|
||||
});
|
||||
|
||||
it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({
|
||||
@@ -226,6 +240,34 @@ describe('TimelineView', () => {
|
||||
expect(document.body.textContent).toContain('Geburt');
|
||||
});
|
||||
|
||||
it('paints the axis with a three-stop mint→navy→slate gradient (REQ-006)', () => {
|
||||
// The palette tokens live in layout.css, which component tests don't load,
|
||||
// so define exactly the three the gradient must reference; an undefined
|
||||
// fourth token would invalidate the declaration and yield "none".
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--palette-mint', '#a1dcd8');
|
||||
root.style.setProperty('--palette-navy', '#012851');
|
||||
root.style.setProperty('--c-tag-slate', '#607080');
|
||||
try {
|
||||
render(TimelineView, {
|
||||
timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry()])] })
|
||||
});
|
||||
const axis = document.querySelector('.timeline-axis') as HTMLElement;
|
||||
expect(axis).not.toBeNull();
|
||||
const before = getComputedStyle(axis, '::before');
|
||||
const bg = before.backgroundImage;
|
||||
expect(bg).toContain('gradient');
|
||||
// three colour stops: mint, navy, slate
|
||||
expect((bg.match(/rgb/g) ?? []).length).toBe(3);
|
||||
// the spine is always background: behind the in-flow cards/pills/strips.
|
||||
expect(before.zIndex).toBe('-1');
|
||||
} finally {
|
||||
root.style.removeProperty('--palette-mint');
|
||||
root.style.removeProperty('--palette-navy');
|
||||
root.style.removeProperty('--c-tag-slate');
|
||||
}
|
||||
});
|
||||
|
||||
it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
|
||||
const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
|
||||
render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
|
||||
|
||||
@@ -21,6 +21,9 @@ const fromYear = $derived(entry.eventDate ? entry.eventDate.slice(0, 4) : null);
|
||||
const toYear = $derived(entry.eventDateEnd ? entry.eventDateEnd.slice(0, 4) : null);
|
||||
const showSpan = $derived(entry.precision === 'RANGE' && fromYear != null && toYear != null);
|
||||
const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYear : dateLabel);
|
||||
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
||||
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
||||
const historical = $derived(m.timeline_layer_historical_suffix());
|
||||
</script>
|
||||
|
||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||
@@ -40,4 +43,7 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
|
||||
{:else if dateText}
|
||||
<span class="ml-2 font-sans text-xs text-ink-3">{dateText}</span>
|
||||
{/if}
|
||||
<!-- Single trailing "· historisch" register, after the title and any
|
||||
span pill / date — one render site, consistent separator (REQ-009). -->
|
||||
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import WorldBand from './WorldBand.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
@@ -45,4 +46,31 @@ describe('WorldBand', () => {
|
||||
expect(document.body.textContent).toContain('Erster Weltkrieg');
|
||||
expect(document.body.textContent).toContain('1914');
|
||||
});
|
||||
|
||||
it('appends the inline "· historisch" descriptor to a non-RANGE band (#833 REQ-009)', () => {
|
||||
render(WorldBand, {
|
||||
entry: historical({ precision: 'APPROX', eventDate: '1923-01-01', eventDateEnd: undefined })
|
||||
});
|
||||
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
|
||||
});
|
||||
|
||||
it('shows "· historisch" with a leading separator even when the band has no date (#833 REQ-009)', () => {
|
||||
render(WorldBand, {
|
||||
entry: historical({ precision: 'UNKNOWN', eventDate: undefined, eventDateEnd: undefined })
|
||||
});
|
||||
expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
|
||||
expect(document.body.textContent).toContain(`· ${m.timeline_layer_historical_suffix()}`);
|
||||
});
|
||||
|
||||
it('follows the RANGE span pill with inline "· historisch" text, not a second pill (#833 REQ-009)', () => {
|
||||
render(WorldBand, { entry: historical() });
|
||||
const pill = document.querySelector('[data-testid="world-range"]');
|
||||
expect(pill).not.toBeNull();
|
||||
// The #779 span pill + its Zeitraum aria-label are unchanged.
|
||||
expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
|
||||
// The descriptor is plain text outside the pill, not a second styled pill.
|
||||
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
|
||||
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,10 +46,13 @@ const rows = $derived.by<Row[]>(() => {
|
||||
</script>
|
||||
|
||||
<section class="py-2">
|
||||
<h2
|
||||
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
||||
>
|
||||
{year.year}
|
||||
<h2 class="year-heading">
|
||||
<span data-testid="year-node" class="year-node bg-brand-navy" aria-hidden="true"></span>
|
||||
<span
|
||||
data-testid="year-label"
|
||||
class="year-label rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
||||
>{year.year}</span
|
||||
>
|
||||
</h2>
|
||||
|
||||
<div class="mt-3 space-y-3">
|
||||
@@ -62,6 +65,7 @@ const rows = $derived.by<Row[]>(() => {
|
||||
{/if}
|
||||
{:else if row.t === 'letter'}
|
||||
<div class="letter-row" data-side={row.side}>
|
||||
<span data-testid="letter-dot" class="letter-dot bg-surface" aria-hidden="true"></span>
|
||||
<LetterCard entry={row.entry} />
|
||||
</div>
|
||||
{:else}
|
||||
@@ -73,19 +77,75 @@ const rows = $derived.by<Row[]>(() => {
|
||||
|
||||
<style>
|
||||
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
|
||||
header is a 64px sticky nav). REQ-006. */
|
||||
header is a 64px sticky nav). #779 REQ-006 / #833 REQ-003. The sticky
|
||||
element is also the positioning context for the year-node marker. */
|
||||
.year-heading {
|
||||
position: sticky;
|
||||
top: 4rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Phone (< 1024px): single left-anchored column, all letters on one side
|
||||
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
|
||||
so consecutive cards sit on opposite sides of the spine (REQ-004). */
|
||||
/* Markers ride on the spine's single source of truth: --spine-x, declared
|
||||
once on TimelineView's .timeline-axis and inherited here (0.5rem phone /
|
||||
50% desktop). The 0.5rem fallback only applies when a YearBand is rendered
|
||||
outside the axis (e.g. component tests). #833 REQ-003/004/005. */
|
||||
|
||||
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker.
|
||||
The badge sits above the node (z-index) so on desktop, where the centered
|
||||
pill covers the centered node, the white year text is never occluded. */
|
||||
.year-label {
|
||||
display: inline-block;
|
||||
margin-left: 1.75rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Navy node marker on the spine. On phone it shows to the left of the badge;
|
||||
on desktop it sits behind the centered pill, which is itself the
|
||||
axis interruption. */
|
||||
.year-node {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--spine-x, 0.5rem);
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 9999px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Per-letter connector dot (white fill via bg-surface, mint ring) on the spine. */
|
||||
.letter-row {
|
||||
position: relative;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.letter-dot {
|
||||
position: absolute;
|
||||
top: 0.9rem;
|
||||
left: var(--spine-x, 0.5rem);
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 9999px;
|
||||
border: 2.5px solid var(--palette-mint);
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Desktop (≥ 1024px): centered axis. The badge centers on the spine, the node
|
||||
sits at the spine centre, and letters alternate left/right with the
|
||||
connector dot on the centred spine between card and axis. #833 REQ-003/004/005. */
|
||||
@media (min-width: 1024px) {
|
||||
.year-label {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
/* .year-node needs no desktop override — it inherits --spine-x: 50% from
|
||||
the axis. */
|
||||
.letter-row {
|
||||
width: 50%;
|
||||
padding-left: 0;
|
||||
}
|
||||
.letter-row[data-side='left'] {
|
||||
margin-right: auto;
|
||||
@@ -95,5 +155,15 @@ const rows = $derived.by<Row[]>(() => {
|
||||
margin-left: auto;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.letter-row[data-side='left'] .letter-dot {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: translate(50%, -50%);
|
||||
}
|
||||
.letter-row[data-side='right'] .letter-dot {
|
||||
left: 0;
|
||||
right: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import YearBand from './YearBand.svelte';
|
||||
import { makeEntry, makeYear } from './test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
await page.viewport(1280, 720);
|
||||
});
|
||||
|
||||
function manyLetters(year: number, count: number) {
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
@@ -81,4 +85,83 @@ describe('YearBand', () => {
|
||||
expect(document.body.textContent).toContain('Heirat');
|
||||
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('centers the year badge on the axis at desktop (REQ-003)', async () => {
|
||||
await page.viewport(1440, 900);
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const section = document.querySelector('section') as HTMLElement;
|
||||
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
||||
const s = section.getBoundingClientRect();
|
||||
const b = badge.getBoundingClientRect();
|
||||
const sectionCenter = s.left + s.width / 2;
|
||||
const badgeCenter = b.left + b.width / 2;
|
||||
expect(Math.abs(badgeCenter - sectionCenter)).toBeLessThan(8);
|
||||
});
|
||||
|
||||
it('left-aligns the year badge at phone width (REQ-003)', async () => {
|
||||
await page.viewport(375, 800);
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const section = document.querySelector('section') as HTMLElement;
|
||||
const badge = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
||||
const s = section.getBoundingClientRect();
|
||||
const b = badge.getBoundingClientRect();
|
||||
// hugs the left spine — clearly not centered
|
||||
expect(b.left - s.left).toBeLessThan(s.width / 3);
|
||||
});
|
||||
|
||||
it('keeps the sticky year heading at top:4rem (REQ-003)', () => {
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const h2 = document.querySelector('h2') as HTMLElement;
|
||||
const cs = getComputedStyle(h2);
|
||||
expect(cs.position).toBe('sticky');
|
||||
expect(cs.top).toBe('64px');
|
||||
});
|
||||
|
||||
it('renders a year-badge node marker that clears the badge text on phone (REQ-004)', async () => {
|
||||
await page.viewport(375, 800);
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
|
||||
const label = document.querySelector('[data-testid="year-label"]') as HTMLElement;
|
||||
expect(node).not.toBeNull();
|
||||
const n = node.getBoundingClientRect();
|
||||
const l = label.getBoundingClientRect();
|
||||
expect(n.right).toBeLessThanOrEqual(l.left + 0.5);
|
||||
// The badge must paint above the node so the centered desktop pill never
|
||||
// occludes the white year text (regression guard).
|
||||
expect(Number(getComputedStyle(label).zIndex)).toBeGreaterThan(
|
||||
Number(getComputedStyle(node).zIndex)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders one connector dot per letter row, each clearing its card on phone (REQ-005)', async () => {
|
||||
await page.viewport(375, 800);
|
||||
render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
|
||||
const dots = document.querySelectorAll('[data-testid="letter-dot"]');
|
||||
expect(dots).toHaveLength(3);
|
||||
const row = document.querySelector('.letter-row') as HTMLElement;
|
||||
const dot = row.querySelector('[data-testid="letter-dot"]') as HTMLElement;
|
||||
const card = row.querySelector('a') as HTMLElement;
|
||||
expect(dot.getBoundingClientRect().right).toBeLessThanOrEqual(
|
||||
card.getBoundingClientRect().left + 0.5
|
||||
);
|
||||
});
|
||||
|
||||
it('positions the year-node from the inherited --spine-x token (REQ-003/004)', async () => {
|
||||
await page.viewport(375, 800);
|
||||
// The spine X lives once on .timeline-axis as --spine-x; the markers must
|
||||
// track that token, not a hard-coded offset, so they never desync.
|
||||
document.documentElement.style.setProperty('--spine-x', '3rem');
|
||||
try {
|
||||
render(YearBand, { year: makeYear(1914, [makeEntry()]) });
|
||||
const node = document.querySelector('[data-testid="year-node"]') as HTMLElement;
|
||||
const section = document.querySelector('section') as HTMLElement;
|
||||
const n = node.getBoundingClientRect();
|
||||
const s = section.getBoundingClientRect();
|
||||
const nodeCenter = n.left + n.width / 2;
|
||||
// --spine-x:3rem = 48px from the band's left edge
|
||||
expect(Math.abs(nodeCenter - s.left - 48)).toBeLessThan(2);
|
||||
} finally {
|
||||
document.documentElement.style.removeProperty('--spine-x');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import Sparkline from '$lib/shared/primitives/Sparkline.svelte';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import { monthHistogram } from './timelineDensity';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -10,20 +13,27 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
/**
|
||||
* Compact density view for a year with many letters (REQ-012): the letter count
|
||||
* plus a 12-month density sparkline, and a ≥44px keyboard-focusable toggle that
|
||||
* expands to that year's individual LetterCards.
|
||||
* expands to that year's individual LetterCards. The count carries a ✉ glyph and
|
||||
* the sparkline is captioned with its month-density meaning and Jan/Dez endpoint
|
||||
* labels (REQ-010/011).
|
||||
*/
|
||||
let { letters, year }: { letters: TimelineEntryDTO[]; year: number } = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
|
||||
// Two endpoint month labels only (not one per bar). Pass the Jan/Dez anchors so
|
||||
// the locale formatter returns a month (a bare "{year}" returns just the year).
|
||||
const janLabel = $derived(formatTickLabel(`${year}-01`, getLocale()));
|
||||
const dezLabel = $derived(formatTickLabel(`${year}-12`, getLocale()));
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-md rounded-sm border border-line bg-surface p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-sans text-sm font-bold text-brand-navy"
|
||||
>{m.timeline_letters_count({ count: letters.length })}</span
|
||||
>
|
||||
<span class="font-sans text-sm font-bold text-brand-navy">
|
||||
<GlyphLabel glyph="✉" label={m.timeline_letter_glyph_label()} />
|
||||
{m.timeline_letters_count({ count: letters.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="strip-expand"
|
||||
@@ -42,6 +52,15 @@ const counts = $derived(monthHistogram(letters, year).map((b) => b.count));
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<!-- Two endpoint month labels + the density caption, beneath the sparkline
|
||||
(REQ-010/011). 10px is the floor for this micro-axis (the spec's 6px is
|
||||
below this project's legibility floor for the 60+ transcriber audience). -->
|
||||
<div class="mt-1 flex items-center justify-between gap-2 font-sans text-[10px] text-ink-3">
|
||||
<span data-testid="strip-axis-label">{janLabel}</span>
|
||||
<span>{m.timeline_strip_density_caption()}</span>
|
||||
<span data-testid="strip-axis-label">{dezLabel}</span>
|
||||
</div>
|
||||
|
||||
{#if expanded}
|
||||
<ul class="mt-3 space-y-2">
|
||||
{#each letters as letter (letter.documentId)}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatTickLabel } from '$lib/shared/utils/monthBuckets';
|
||||
import YearLetterStrip from './YearLetterStrip.svelte';
|
||||
import { makeEntry } from './test-factories';
|
||||
|
||||
@@ -39,4 +42,32 @@ describe('YearLetterStrip', () => {
|
||||
await tick();
|
||||
expect(document.querySelectorAll('a').length).toBe(30);
|
||||
});
|
||||
|
||||
it('prefixes the count with an aria-hidden ✉ + sr-only "Brief" and shows the density caption (REQ-010)', () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
const hidden = document.querySelector('[aria-hidden="true"]');
|
||||
expect(hidden?.textContent).toContain('✉');
|
||||
const srOnly = document.querySelector('.sr-only');
|
||||
expect(srOnly?.textContent).toBe(m.timeline_letter_glyph_label());
|
||||
expect(document.body.textContent).toContain(m.timeline_strip_density_caption());
|
||||
});
|
||||
|
||||
it('keeps the expand toggle and its "Briefe anzeigen" label alongside the new chrome (REQ-010)', () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle.textContent).toContain(m.timeline_strip_expand());
|
||||
expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('renders exactly two endpoint month-axis labels (Jan/Dez {year}) at ≥10px (REQ-011)', () => {
|
||||
render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
|
||||
const labels = document.querySelectorAll('[data-testid="strip-axis-label"]');
|
||||
expect(labels).toHaveLength(2);
|
||||
expect(labels[0].textContent).toContain(formatTickLabel('1915-01', getLocale()));
|
||||
expect(labels[1].textContent).toContain(formatTickLabel('1915-12', getLocale()));
|
||||
for (const label of labels) {
|
||||
expect(parseFloat(getComputedStyle(label).fontSize)).toBeGreaterThanOrEqual(10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
56
frontend/src/lib/timeline/timelineMeta.spec.ts
Normal file
56
frontend/src/lib/timeline/timelineMeta.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { timelineMeta } from './timelineMeta';
|
||||
import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
|
||||
|
||||
const letter = (id: string) => makeEntry({ kind: 'LETTER', documentId: id });
|
||||
const event = (title: string) =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
|
||||
describe('timelineMeta', () => {
|
||||
it('counts letters and events across year bands and the undated bucket (REQ-002)', () => {
|
||||
const dto = makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1909, [letter('a'), event('Geburt'), letter('b')]),
|
||||
makeYear(1924, [event('Tod')])
|
||||
],
|
||||
undated: [letter('c'), event('Heirat')]
|
||||
});
|
||||
const meta = timelineMeta(dto);
|
||||
expect(meta.letterCount).toBe(3);
|
||||
expect(meta.eventCount).toBe(3);
|
||||
});
|
||||
|
||||
it('reads the range from the first and last year band (REQ-002)', () => {
|
||||
const dto = makeTimelineDTO({
|
||||
years: [makeYear(1909, [letter('a')]), makeYear(1924, [letter('b')])]
|
||||
});
|
||||
const meta = timelineMeta(dto);
|
||||
expect(meta.firstYear).toBe(1909);
|
||||
expect(meta.lastYear).toBe(1924);
|
||||
});
|
||||
|
||||
it('has a null range when there are no year bands, but still counts undated (REQ-002)', () => {
|
||||
const dto = makeTimelineDTO({ undated: [letter('a')] });
|
||||
const meta = timelineMeta(dto);
|
||||
expect(meta.firstYear).toBeNull();
|
||||
expect(meta.lastYear).toBeNull();
|
||||
expect(meta.letterCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reports zero counts and a null range for an empty timeline (REQ-002)', () => {
|
||||
expect(timelineMeta(makeTimelineDTO())).toEqual({
|
||||
firstYear: null,
|
||||
lastYear: null,
|
||||
letterCount: 0,
|
||||
eventCount: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/timeline/timelineMeta.ts
Normal file
38
frontend/src/lib/timeline/timelineMeta.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineDTO = components['schemas']['TimelineDTO'];
|
||||
|
||||
export interface TimelineMeta {
|
||||
/** First year band's year, or `null` when there are no bands. */
|
||||
firstYear: number | null;
|
||||
/** Last year band's year, or `null` when there are no bands. */
|
||||
lastYear: number | null;
|
||||
/** Every `LETTER` entry across all year bands plus the undated bucket. */
|
||||
letterCount: number;
|
||||
/** Every `EVENT` entry (derived, curated, and historical) across all bands plus undated. */
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the header meta-line figures from a loaded `TimelineDTO` (REQ-002):
|
||||
* the year range (first/last band) and the letter/event totals across every
|
||||
* year band plus the undated bucket. Pure and the single place these counts
|
||||
* live — the route renders them; `TimelineView` never recomputes them.
|
||||
*/
|
||||
export function timelineMeta(timeline: TimelineDTO): TimelineMeta {
|
||||
const years = timeline.years;
|
||||
let letterCount = 0;
|
||||
let eventCount = 0;
|
||||
const tally = (e: TimelineDTO['undated'][number]) => {
|
||||
if (e.kind === 'LETTER') letterCount += 1;
|
||||
else if (e.kind === 'EVENT') eventCount += 1;
|
||||
};
|
||||
for (const y of years) for (const e of y.entries) tally(e);
|
||||
for (const e of timeline.undated) tally(e);
|
||||
return {
|
||||
firstYear: years.length ? years[0].year : null,
|
||||
lastYear: years.length ? years[years.length - 1].year : null,
|
||||
letterCount,
|
||||
eventCount
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,42 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import TimelineView from '$lib/timeline/TimelineView.svelte';
|
||||
import { timelineMeta } from '$lib/timeline/timelineMeta';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const meta = $derived(timelineMeta(data.timeline));
|
||||
const hasContent = $derived(data.timeline.years.length > 0 || data.timeline.undated.length > 0);
|
||||
|
||||
// Compose the sub-line from segments joined by " · " so the range drops out
|
||||
// cleanly when there are no year bands; the whole line is absent when the
|
||||
// timeline is empty (REQ-002). Counts come from the route alone, never from
|
||||
// TimelineView.
|
||||
const metaLine = $derived.by(() => {
|
||||
const segments: string[] = [];
|
||||
if (meta.firstYear !== null && meta.lastYear !== null) {
|
||||
segments.push(`${meta.firstYear}–${meta.lastYear}`);
|
||||
}
|
||||
// A zero-count segment ("0 Briefe") reads as a data error — drop it; a count
|
||||
// of one takes the singular key ("1 Brief"), per the project plural convention.
|
||||
if (meta.letterCount > 0) {
|
||||
segments.push(
|
||||
meta.letterCount === 1
|
||||
? m.timeline_letters_count_singular()
|
||||
: m.timeline_letters_count({ count: meta.letterCount })
|
||||
);
|
||||
}
|
||||
if (meta.eventCount > 0) {
|
||||
segments.push(
|
||||
meta.eventCount === 1
|
||||
? m.timeline_events_count_singular()
|
||||
: m.timeline_events_count({ count: meta.eventCount })
|
||||
);
|
||||
}
|
||||
segments.push(m.timeline_grouping_date());
|
||||
return segments.join(' · ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -11,6 +44,14 @@ let { data }: { data: PageData } = $props();
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||
<TimelineView timeline={data.timeline} />
|
||||
<!-- The .tl-canvas sheet: a padded canvas surface for the timeline. The outer
|
||||
border is intentionally omitted (the page is already bg-canvas), per the
|
||||
review of REQ-001 — the sheet reads through its padding, not a frame line. -->
|
||||
<div data-testid="timeline-canvas" class="rounded-[10px] bg-canvas p-6">
|
||||
<h1 class="font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||
{#if hasContent}
|
||||
<p data-testid="timeline-meta" class="mt-1 mb-6 font-sans text-xs text-ink-3">{metaLine}</p>
|
||||
{/if}
|
||||
<TimelineView timeline={data.timeline} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
113
frontend/src/routes/zeitstrahl/page.svelte.spec.ts
Normal file
113
frontend/src/routes/zeitstrahl/page.svelte.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import Page from './+page.svelte';
|
||||
import { makeEntry, makeYear, makeTimelineDTO } from '$lib/timeline/test-factories';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const event = (title: string) =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
derived: true,
|
||||
derivedType: 'BIRTH',
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
|
||||
// The route's PageData merges the layout's auth fields with the page load's
|
||||
// timeline; the component only reads `timeline`, but the full shape keeps the
|
||||
// test type-clean.
|
||||
const pageData = (timeline: ReturnType<typeof makeTimelineDTO>) => ({
|
||||
user: undefined,
|
||||
canWrite: false,
|
||||
canAnnotate: false,
|
||||
canBlogWrite: false,
|
||||
timeline
|
||||
});
|
||||
|
||||
describe('/zeitstrahl page', () => {
|
||||
it('wraps the timeline in a padded canvas surface, without an outer border (REQ-001)', () => {
|
||||
render(Page, {
|
||||
data: pageData(makeTimelineDTO({ years: [makeYear(1909, [makeEntry()])] }))
|
||||
});
|
||||
const canvas = document.querySelector('[data-testid="timeline-canvas"]');
|
||||
expect(canvas).not.toBeNull();
|
||||
expect(canvas?.classList.contains('bg-canvas')).toBe(true);
|
||||
// The outer border was dropped in review (the page is already bg-canvas).
|
||||
expect(canvas?.classList.contains('border')).toBe(false);
|
||||
// the timeline renders inside the surface
|
||||
expect(canvas?.querySelector('ol')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the meta sub-line with range, counts, and grouping (REQ-002)', () => {
|
||||
const dto = makeTimelineDTO({
|
||||
years: [
|
||||
makeYear(1909, [
|
||||
makeEntry({ documentId: 'a' }),
|
||||
makeEntry({ documentId: 'b' }),
|
||||
event('Geburt')
|
||||
]),
|
||||
makeYear(1924, [makeEntry({ documentId: 'c' }), event('Tod')])
|
||||
]
|
||||
});
|
||||
render(Page, { data: pageData(dto) });
|
||||
const sub = document.querySelector('[data-testid="timeline-meta"]');
|
||||
expect(sub?.textContent).toContain('1909–1924');
|
||||
expect(sub?.textContent).toContain(m.timeline_letters_count({ count: 3 }));
|
||||
expect(sub?.textContent).toContain(m.timeline_events_count({ count: 2 }));
|
||||
expect(sub?.textContent).toContain(m.timeline_grouping_date());
|
||||
});
|
||||
|
||||
it('omits the range segment when there are no year bands (REQ-002)', () => {
|
||||
render(Page, {
|
||||
data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] }))
|
||||
});
|
||||
const sub = document.querySelector('[data-testid="timeline-meta"]');
|
||||
expect(sub).not.toBeNull();
|
||||
expect(sub?.textContent).not.toContain('–');
|
||||
expect(sub?.textContent).toContain(m.timeline_letters_count_singular());
|
||||
});
|
||||
|
||||
it('omits the entire sub-line for an empty timeline (REQ-002)', () => {
|
||||
render(Page, { data: pageData(makeTimelineDTO()) });
|
||||
expect(document.querySelector('[data-testid="timeline-meta"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the letters segment instead of showing "0 Briefe" (REQ-002)', () => {
|
||||
render(Page, {
|
||||
data: pageData(makeTimelineDTO({ years: [makeYear(1914, [event('Geburt')])] }))
|
||||
});
|
||||
const sub = document.querySelector('[data-testid="timeline-meta"]');
|
||||
expect(sub).not.toBeNull();
|
||||
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 0 }));
|
||||
expect(sub?.textContent).toContain(m.timeline_grouping_date());
|
||||
});
|
||||
|
||||
it('drops the events segment instead of showing "0 Ereignisse" (REQ-002)', () => {
|
||||
render(Page, {
|
||||
data: pageData(makeTimelineDTO({ undated: [makeEntry({ documentId: 'u1' })] }))
|
||||
});
|
||||
const sub = document.querySelector('[data-testid="timeline-meta"]');
|
||||
expect(sub).not.toBeNull();
|
||||
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 0 }));
|
||||
});
|
||||
|
||||
it('uses singular count labels for exactly one letter and one event (REQ-002)', () => {
|
||||
render(Page, {
|
||||
data: pageData(
|
||||
makeTimelineDTO({
|
||||
years: [makeYear(1914, [makeEntry({ documentId: 'a' }), event('Geburt')])]
|
||||
})
|
||||
)
|
||||
});
|
||||
const sub = document.querySelector('[data-testid="timeline-meta"]');
|
||||
expect(sub?.textContent).toContain(m.timeline_letters_count_singular());
|
||||
expect(sub?.textContent).toContain(m.timeline_events_count_singular());
|
||||
// never the "1 Briefe"/"1 Ereignisse" plural forms for a count of one
|
||||
expect(sub?.textContent).not.toContain(m.timeline_letters_count({ count: 1 }));
|
||||
expect(sub?.textContent).not.toContain(m.timeline_events_count({ count: 1 }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user