feat(timeline): center the year badge and add spine markers (REQ-003/004/005)

The year badge now centers on the axis at ≥1024px and hugs the left spine
below that (sticky top:4rem preserved), with a navy node marker so it
visibly interrupts the spine. Each letter row gains a connector dot (white
fill, mint ring) on the spine: centered between card and axis on desktop,
on the left spine clear of the indented card on phone. Spine geometry is
commented to track TimelineView's spine so the markers can't silently desync.

Refs #833
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 11:10:05 +02:00
parent e4da28d795
commit 18934413bb
2 changed files with 132 additions and 9 deletions

View File

@@ -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,69 @@ 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). */
/* Spine geometry below mirrors TimelineView's .timeline-axis::before
(left: 0.5rem phone / left: 50% desktop) — keep the two in sync so the
markers never desync from the spine. #833 REQ-003/004/005. */
/* Phone (< 1024px): badge sits at the left spine, clearing the node marker. */
.year-label {
display: inline-block;
margin-left: 1.75rem;
}
/* Navy node marker on the spine so the badge visibly interrupts the axis. */
.year-node {
position: absolute;
top: 50%;
left: 0.5rem;
width: 11px;
height: 11px;
border-radius: 9999px;
transform: translate(-50%, -50%);
z-index: 2;
}
/* 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: 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 {
left: 50%;
}
.letter-row {
width: 50%;
padding-left: 0;
}
.letter-row[data-side='left'] {
margin-right: auto;
@@ -95,5 +149,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>

View File

@@ -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,59 @@ 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);
});
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
);
});
});