Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836

Merged
marcel merged 23 commits from feat/issue-833-zeitstrahl-fidelity into main 2026-06-14 14:12:42 +02:00
2 changed files with 132 additions and 9 deletions
Showing only changes of commit 18934413bb - Show all commits

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
);
});
});