feat(route): drop progress dots, move legend below, vary path amplitude

Three coupled changes that all serve the same goal — less furniture
above the route, more honest information below it.

Progress dots gone. At 5 pills × ~400px per pill the strip was too
coarse to feel meaningful; the arrows + edge fades already
communicate scroll position. .rr-progress markup, the script logic
that updated the .active class, and the .rr-progress / .rr-progress-dot
styles are all deleted.

Legend moves from beside 'The route' in the section header to below
the track, centred. Reading order is now title → walk the path → key,
which is the order it makes sense in. The header collapses to just
the title on the left and the two arrow buttons on the right.

Path amplitude is no longer constant. computeRouteLayout multiplies
the base amplitude (120) by a per-item factor that ramps 0.78 (first
off-axis item) → 1.18 (last item), so closer-in items swing tighter
and further-out items swing wider. The visual effect is subtle but
the path now feels hand-planned instead of strictly sinusoidal.

Test updated to verify the multiplier — |itemY[2] - midY| now exceeds
|itemY[1] - midY| in the 3-item case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:58:59 +02:00
parent f8d88ed760
commit ac52e97c28
3 changed files with 47 additions and 78 deletions

View file

@ -46,26 +46,15 @@ function trailingLine(item: RoadmapItemWithAttribution): string | null {
return null;
}
// Progress dots — between 2 and 6, scaling with item count.
const progressDots = Math.max(2, Math.min(6, Math.ceil(items.length / 2)));
// JSON-stringified ids for the nav script's initial-scroll logic.
const itemXByIndex = layout.itemX;
const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex] : 0;
// Stringified x position of the 'you are here' milestone for the
// initial-scroll logic in the nav script. -1 → 0 (no scroll offset).
const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex] : 0;
---
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}>
<!-- Section header (legend + arrows) -->
<!-- Section header (title + arrows). Legend moves below the track. -->
<header class="route-header">
<div class="route-header-left">
<h2 class="route-title">The route</h2>
<ul class="route-legend" aria-label="Status legend">
<li><span class="lg-dot" style="background:#6d8c7c"></span>Shipping</li>
<li><span class="lg-dot" style="background:#b96b58"></span>In beta</li>
<li><span class="lg-dot" style="background:#b4b2a9"></span>Exploring</li>
<li><span class="lg-dot" style="background:#d4d2c8"></span>Considering</li>
</ul>
</div>
<div class="route-arrows" role="group" aria-label="Scroll the route">
<button type="button" class="route-arrow" id="rr-prev" data-dir="prev" aria-label="Previous" disabled>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
@ -131,11 +120,13 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
</div>
<!-- Progress dots — JS in step 6 toggles .active. -->
<div class="rr-progress rr-desktop" id="rr-progress" aria-hidden="true">
{Array.from({ length: progressDots }, (_, i) => (
<span class:list={['rr-progress-dot', { active: i === 0 }]}></span>
))}
<!-- Legend — sits under the track, centred. Reading order is now
"The route" → walk the path → "ah, the dots mean these things." -->
<div class="rr-legend rr-desktop" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
</div>
<!-- Mobile vertical timeline — built in step 7 -->
@ -171,7 +162,6 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
const next = section.querySelector<HTMLButtonElement>('#rr-next');
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
const dots = Array.from(section.querySelectorAll<HTMLElement>('#rr-progress .rr-progress-dot'));
if (!scroll) return;
const step = () => scroll.clientWidth * 0.72;
@ -187,11 +177,6 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
if (next) next.disabled = atEnd;
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (dots.length > 0) {
const pct = max > 0 ? scroll!.scrollLeft / max : 0;
const activeIdx = Math.min(dots.length - 1, Math.floor(pct * dots.length));
dots.forEach((d, i) => d.classList.toggle('active', i === activeIdx));
}
}
scroll.addEventListener('scroll', update, { passive: true });
@ -219,13 +204,6 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
align-items: baseline;
gap: 24px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.route-header-left {
display: flex;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
}
.route-title {
font-family: var(--font-serif);
@ -235,30 +213,6 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
color: var(--on-surface);
margin: 0;
}
.route-legend {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.route-legend li {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.lg-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.route-arrows {
display: flex;
@ -440,22 +394,29 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
background: linear-gradient(to right, transparent, var(--background));
}
/* Progress dots */
.rr-progress {
/* Legend below the track — reading order: title → track → key. */
.rr-legend {
display: flex;
justify-content: center;
gap: 6px;
gap: 24px;
margin-top: 28px;
flex-wrap: wrap;
}
.rr-progress-dot {
width: 28px;
height: 2px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.15);
transition: background var(--duration-fast) var(--ease-standard);
.rr-legend span {
display: inline-flex;
align-items: center;
gap: 7px;
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.rr-progress-dot.active {
background: var(--on-surface);
.rr-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Mobile vertical timeline ──────────────────────────────────── */

View file

@ -63,13 +63,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
: padding + (i / (itemCount - 1)) * usableWidth,
);
// First item on the centreline; subsequent items alternate up/down.
// Odd indices rise above; even indices (>0) drop below.
const itemY: number[] = itemX.map((_, i) =>
i === 0
? midY
: (i % 2 === 1 ? midY - amplitude : midY + amplitude),
);
// First item on the centreline; subsequent items alternate up/down with
// a varying amplitude so the path feels hand-planned rather than purely
// sinusoidal. Multiplier ramps 0.78 (first off-axis) → ~1.18 (last item)
// — closer items swing less, further items swing more.
const denom = Math.max(1, itemCount - 1);
const itemY: number[] = itemX.map((_, i) => {
if (i === 0) return midY;
const direction = i % 2 === 1 ? -1 : 1;
const multiplier = 0.78 + (i / denom) * 0.4;
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.

View file

@ -27,14 +27,18 @@ describe('computeRouteLayout', () => {
expect((out.pathD.match(/C /g) ?? []).length).toBe(1);
});
it('3 items — first card below, then above, then below', () => {
it('3 items — first card below, then above, then below; amplitude multiplier ramps', () => {
const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
expect(out.cardSide).toEqual(['below', 'above', 'below']);
expect(isStrictlyIncreasing(out.itemX)).toBe(true);
// First item on centreline, second above (lower y in svg coords), third below
// 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', () => {