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:
parent
f8d88ed760
commit
ac52e97c28
3 changed files with 47 additions and 78 deletions
|
|
@ -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 ──────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue