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>
94 lines
4.4 KiB
TypeScript
94 lines
4.4 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { computeRouteLayout, travelledStopFor } from '../src/lib/roadmap-layout.js';
|
|
|
|
function isStrictlyIncreasing(xs: number[]): boolean {
|
|
for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) return false;
|
|
return true;
|
|
}
|
|
|
|
describe('computeRouteLayout', () => {
|
|
it('1 item — produces a valid single-point M path on the centreline', () => {
|
|
const out = computeRouteLayout({ itemCount: 1, viewportWidth: 1000 });
|
|
expect(out.itemX).toHaveLength(1);
|
|
expect(out.midY).toBe(210); // trackHeight 420 / 2
|
|
expect(out.itemY).toEqual([out.midY]);
|
|
expect(out.cardSide).toEqual(['below']);
|
|
expect(out.pathD.startsWith('M ')).toBe(true);
|
|
expect(out.pathD).not.toContain('C ');
|
|
// Target usable width = 1000 * 0.8 = 800; trackWidth = 800 + 60*2 = 920
|
|
expect(out.trackWidth).toBe(920);
|
|
});
|
|
|
|
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);
|
|
// 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 — cards toward centreline; amplitude multiplier ramps', () => {
|
|
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
|
|
// 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);
|
|
expect(Math.abs(out.itemY[2] - out.midY)).toBeGreaterThan(Math.abs(out.itemY[1] - out.midY));
|
|
});
|
|
|
|
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);
|
|
// 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);
|
|
});
|
|
|
|
it('20 items — data-driven width wins over the 80% target', () => {
|
|
const out = computeRouteLayout({ itemCount: 20, viewportWidth: 800 });
|
|
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
|
|
// (20 - 1) * 320 + 60 * 2 = 6200; clearly beats 800 * 0.8 + 120 = 760.
|
|
expect(out.trackWidth).toBe(6200);
|
|
});
|
|
|
|
it('few items on a wide viewport — track ≈ 80% of viewport + padding', () => {
|
|
// 3 items at viewport 1400. Data-driven = 2 * 320 + 120 = 760.
|
|
// 80% target = 1400 * 0.8 + 120 = 1240 — should win.
|
|
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1400 });
|
|
expect(out.trackWidth).toBe(1240);
|
|
});
|
|
});
|
|
|
|
describe('travelledStopFor', () => {
|
|
it('returns 0 when no items have shipped', () => {
|
|
expect(travelledStopFor(['exploring', 'considering'])).toBe(0);
|
|
expect(travelledStopFor([])).toBe(0);
|
|
});
|
|
|
|
it('returns (lastShippingIndex + 0.5) / itemCount', () => {
|
|
// [shipping, shipping, in_beta, exploring] → lastShipping = 1 → (1.5)/4 = 0.375
|
|
expect(travelledStopFor(['shipping', 'shipping', 'in_beta', 'exploring'])).toBeCloseTo(0.375, 5);
|
|
});
|
|
|
|
it('clamps to 0.98 when every item has shipped', () => {
|
|
expect(travelledStopFor(['shipping', 'shipping', 'shipping'])).toBeCloseTo(0.833, 2);
|
|
// even with 100 items all shipping, clamps to 0.98
|
|
const allShipping = Array(100).fill('shipping') as ('shipping')[];
|
|
expect(travelledStopFor(allShipping)).toBeLessThanOrEqual(0.98);
|
|
});
|
|
});
|