From c0592f7ca5a8e9b0d6d155b3a94e37bb047eaaeb Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 14:43:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(route):=20cards=20grow=20toward=20centrelin?= =?UTF-8?q?e=20=E2=80=94=20fixes=20top/bottom=20clipping=20at=20the=20sour?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/roadmap-layout.ts | 16 +++++++++++----- tests/roadmap-layout.test.ts | 26 +++++++++++++++++--------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/lib/roadmap-layout.ts b/src/lib/roadmap-layout.ts index 0c7bf15..3d98469 100644 --- a/src/lib/roadmap-layout.ts +++ b/src/lib/roadmap-layout.ts @@ -77,11 +77,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult { return midY + direction * amplitude * multiplier; }); - // Cards alternate: even index → below, odd index → above. First card sits - // below the dot at the centreline so the river starts visibly underlined. - const cardSide: ('above' | 'below')[] = itemX.map((_, i) => - i % 2 === 0 ? 'below' : 'above', - ); + // Cards hang TOWARD the centreline rather than away from it. A dot above + // centre (odd index) gets a card below; a dot below centre (even index >0) + // gets a card above. This keeps every card growing into the track height + // 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 // segment, with control-y values matching the prior and next item diff --git a/tests/roadmap-layout.test.ts b/tests/roadmap-layout.test.ts index a4b7d5d..7f60fa6 100644 --- a/tests/roadmap-layout.test.ts +++ b/tests/roadmap-layout.test.ts @@ -19,34 +19,42 @@ describe('computeRouteLayout', () => { 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 }); expect(out.itemX).toHaveLength(2); 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.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 }); - 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); // First item on centreline, second above (smaller y), third below. expect(out.itemY[0]).toBe(out.midY); expect(out.itemY[1]).toBeLessThan(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)); }); - 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 }); expect(out.itemX).toHaveLength(7); 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.match(/C /g) ?? []).length).toBe(6); });