fix(route): cards grow toward centreline — fixes top/bottom clipping at the source

The actual cause of the persistent top/bottom card clipping wasn't the
track height or the padding — it's that the CSS spec forces
overflow-y: visible to compute as auto whenever overflow-x is auto.
Browsers clip the scroll container on both axes regardless of how we
declare overflow-y. Every previous fix was band-aiding the same
underlying problem.

Geometric fix: flip cardSide so cards hang toward the centreline
instead of away from it.

  - i=0 (dot on centreline)         → card below (default, no clip risk)
  - i=1 (dot above-centre, odd)     → card below (grows toward midY)
  - i=2 (dot below-centre, even >0) → card above (grows toward midY)
  - …alternating thereafter

Cards now always grow into the track, never out of it. Both axes are
naturally bounded by the track's height. Hover-expanded cards stay
inside the scroll container's clip box, so the browser-forced clipping
has nothing to remove.

Tests updated to expect the new pattern. The 7-item case carries an
extra spot-check that every card's side is opposite to its dot's
offset from the centreline — i.e. the geometric invariant the fix
relies on.

Visual rhythm: cards still alternate above/below as the path swings
up and down; the wave reads the same. What changes is which milestones
have cards above vs below — and only at the visual top of the page
where it improves things by stopping the clipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 14:43:41 +02:00
parent 8ca5e88618
commit c0592f7ca5
2 changed files with 28 additions and 14 deletions

View file

@ -77,11 +77,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
return midY + direction * amplitude * multiplier; return midY + direction * amplitude * multiplier;
}); });
// Cards alternate: even index → below, odd index → above. First card sits // Cards hang TOWARD the centreline rather than away from it. A dot above
// below the dot at the centreline so the river starts visibly underlined. // centre (odd index) gets a card below; a dot below centre (even index >0)
const cardSide: ('above' | 'below')[] = itemX.map((_, i) => // gets a card above. This keeps every card growing into the track height
i % 2 === 0 ? 'below' : 'above', // rather than out the top or bottom of the scroll container — which the
); // CSS spec clips regardless of overflow-y: visible (browser computes
// overflow-y to auto whenever overflow-x is auto). i=0 sits on the
// centreline, no clipping risk either way; defaulting to below.
const cardSide: ('above' | 'below')[] = itemX.map((_, i) => {
if (i === 0) return 'below';
return i % 2 === 1 ? 'below' : 'above';
});
// Smooth cubic-bezier path. Control points use the midpoint x of each // Smooth cubic-bezier path. Control points use the midpoint x of each
// segment, with control-y values matching the prior and next item // segment, with control-y values matching the prior and next item

View file

@ -19,34 +19,42 @@ describe('computeRouteLayout', () => {
expect(out.trackWidth).toBe(920); expect(out.trackWidth).toBe(920);
}); });
it('2 items — itemX strictly increasing, cardSide alternates below/above', () => { it('2 items — cards hang toward centreline (both below: i=0 centre, i=1 above-centre)', () => {
const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 }); const out = computeRouteLayout({ itemCount: 2, viewportWidth: 1000 });
expect(out.itemX).toHaveLength(2); expect(out.itemX).toHaveLength(2);
expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(isStrictlyIncreasing(out.itemX)).toBe(true);
expect(out.cardSide).toEqual(['below', 'above']); // i=0: centreline (below by convention). i=1: dot above centre → card below.
expect(out.cardSide).toEqual(['below', 'below']);
expect(out.pathD.startsWith('M ')).toBe(true); expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(1); expect((out.pathD.match(/C /g) ?? []).length).toBe(1);
}); });
it('3 items — first card below, then above, then below; amplitude multiplier ramps', () => { it('3 items — cards toward centreline; amplitude multiplier ramps', () => {
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 }); const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
expect(out.cardSide).toEqual(['below', 'above', 'below']); // i=0 centre (below), i=1 above-centre (card below), i=2 below-centre (card above).
expect(out.cardSide).toEqual(['below', 'below', 'above']);
expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// First item on centreline, second above (smaller y), third below. // First item on centreline, second above (smaller y), third below.
expect(out.itemY[0]).toBe(out.midY); expect(out.itemY[0]).toBe(out.midY);
expect(out.itemY[1]).toBeLessThan(out.midY); expect(out.itemY[1]).toBeLessThan(out.midY);
expect(out.itemY[2]).toBeGreaterThan(out.midY); expect(out.itemY[2]).toBeGreaterThan(out.midY);
// Amplitude varies: multiplier at index 1 is 0.78 + (1/2)*0.4 = 0.98;
// at index 2 it's 0.78 + (2/2)*0.4 = 1.18. So |itemY[2] - midY| should
// exceed |itemY[1] - midY| (further items swing wider).
expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY)); expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY));
}); });
it('7 items — strictly increasing itemX + correct alternation', () => { it('7 items — every card grows toward the centreline, never away from it', () => {
const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 }); const out = computeRouteLayout({ itemCount: 7, viewportWidth: 1200 });
expect(out.itemX).toHaveLength(7); expect(out.itemX).toHaveLength(7);
expect(isStrictlyIncreasing(out.itemX)).toBe(true); expect(isStrictlyIncreasing(out.itemX)).toBe(true);
expect(out.cardSide).toEqual(['below', 'above', 'below', 'above', 'below', 'above', 'below']); // i: 0 1 2 3 4 5 6 — odd indices dot-above-centre (card below), even
// indices >0 dot-below-centre (card above). i=0 default below.
expect(out.cardSide).toEqual(['below', 'below', 'above', 'below', 'above', 'below', 'above']);
// Spot-check: every non-i=0 card's side is opposite to its dot's
// offset from centre — i.e. cards always shrink toward midY.
for (let i = 1; i < out.itemX.length; i += 1) {
const dotAbove = out.itemY[i] < out.midY;
if (dotAbove) expect(out.cardSide[i]).toBe('below');
else expect(out.cardSide[i]).toBe('above');
}
expect(out.pathD.startsWith('M ')).toBe(true); expect(out.pathD.startsWith('M ')).toBe(true);
expect((out.pathD.match(/C /g) ?? []).length).toBe(6); expect((out.pathD.match(/C /g) ?? []).length).toBe(6);
}); });