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:
@@ -46,10 +46,13 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="py-2">
|
<section class="py-2">
|
||||||
<h2
|
<h2 class="year-heading">
|
||||||
class="year-heading w-fit rounded-full bg-brand-navy px-4 py-1 font-serif text-sm font-bold text-white"
|
<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
|
||||||
>
|
>
|
||||||
{year.year}
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="mt-3 space-y-3">
|
<div class="mt-3 space-y-3">
|
||||||
@@ -62,6 +65,7 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if row.t === 'letter'}
|
{:else if row.t === 'letter'}
|
||||||
<div class="letter-row" data-side={row.side}>
|
<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} />
|
<LetterCard entry={row.entry} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -73,19 +77,69 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Sticky offset in scoped CSS so it holds in unit tests too (the global
|
/* 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 {
|
.year-heading {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 4rem;
|
top: 4rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phone (< 1024px): single left-anchored column, all letters on one side
|
/* Spine geometry below mirrors TimelineView's .timeline-axis::before
|
||||||
(REQ-005). Desktop (≥ 1024px): centered axis, letters alternate left/right
|
(left: 0.5rem phone / left: 50% desktop) — keep the two in sync so the
|
||||||
so consecutive cards sit on opposite sides of the spine (REQ-004). */
|
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) {
|
@media (min-width: 1024px) {
|
||||||
|
.year-label {
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.year-node {
|
||||||
|
left: 50%;
|
||||||
|
}
|
||||||
.letter-row {
|
.letter-row {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
.letter-row[data-side='left'] {
|
.letter-row[data-side='left'] {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
@@ -95,5 +149,15 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding-left: 1.75rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
import YearBand from './YearBand.svelte';
|
import YearBand from './YearBand.svelte';
|
||||||
import { makeEntry, makeYear } from './test-factories';
|
import { makeEntry, makeYear } from './test-factories';
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(async () => {
|
||||||
|
cleanup();
|
||||||
|
await page.viewport(1280, 720);
|
||||||
|
});
|
||||||
|
|
||||||
function manyLetters(year: number, count: number) {
|
function manyLetters(year: number, count: number) {
|
||||||
return Array.from({ length: count }, (_, i) =>
|
return Array.from({ length: count }, (_, i) =>
|
||||||
@@ -81,4 +85,59 @@ describe('YearBand', () => {
|
|||||||
expect(document.body.textContent).toContain('Heirat');
|
expect(document.body.textContent).toContain('Heirat');
|
||||||
expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user