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

View file

@ -63,13 +63,17 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
: padding + (i / (itemCount - 1)) * usableWidth, : padding + (i / (itemCount - 1)) * usableWidth,
); );
// First item on the centreline; subsequent items alternate up/down. // First item on the centreline; subsequent items alternate up/down with
// Odd indices rise above; even indices (>0) drop below. // a varying amplitude so the path feels hand-planned rather than purely
const itemY: number[] = itemX.map((_, i) => // sinusoidal. Multiplier ramps 0.78 (first off-axis) → ~1.18 (last item)
i === 0 // — closer items swing less, further items swing more.
? midY const denom = Math.max(1, itemCount - 1);
: (i % 2 === 1 ? midY - amplitude : midY + amplitude), 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 // Cards alternate: even index → below, odd index → above. First card sits
// below the dot at the centreline so the river starts visibly underlined. // 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); 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 }); const out = computeRouteLayout({ itemCount: 3, viewportWidth: 1000 });
expect(out.cardSide).toEqual(['below', 'above', 'below']); expect(out.cardSide).toEqual(['below', 'above', 'below']);
expect(isStrictlyIncreasing(out.itemX)).toBe(true); 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[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));
}); });
it('7 items — strictly increasing itemX + correct alternation', () => { it('7 items — strictly increasing itemX + correct alternation', () => {