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:
parent
8ca5e88618
commit
c0592f7ca5
2 changed files with 28 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue