Restore /zeitstrahl Datum-mode visual fidelity to the Concept-A spec (#833) #836
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user