project-bifrost-platform/src/components/RoadmapRoute.astro
Jonathan Hvid ac52e97c28 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>
2026-05-12 11:58:59 +02:00

500 lines
16 KiB
Text

---
import type { RoadmapItemWithAttribution, RoadmapStatus } from '../lib/db';
import { computeRouteLayout, travelledStopFor } from '../lib/roadmap-layout';
interface Props {
items: RoadmapItemWithAttribution[];
viewportWidth?: number; // SSR fallback for the layout math
}
const { items, viewportWidth = 1100 } = Astro.props;
const layout = computeRouteLayout({ itemCount: items.length, viewportWidth });
const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
// "You are here" — the most recent shipping item. -1 if nothing has shipped yet.
let lastShippingIndex = -1;
items.forEach((it, i) => { if (it.status === 'shipping') lastShippingIndex = i; });
function trailingLine(item: RoadmapItemWithAttribution): string | null {
if (item.metadata_text && item.metadata_text.trim().length > 0) return item.metadata_text;
if (item.attributed.length > 0) {
const names = item.attributed.map(a => a.name.split(' ')[0]);
if (names.length === 1) return `Shaped by ${names[0]}`;
if (names.length === 2) return `Shaped by ${names[0]} and ${names[1]}`;
return `Shaped by ${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
}
return null;
}
// 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 (title + arrows). Legend moves below the track. -->
<header class="route-header">
<h2 class="route-title">The route</h2>
<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">
<path d="M9 2 L4 7 L9 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button type="button" class="route-arrow" id="rr-next" data-dir="next" aria-label="Next">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
<path d="M5 2 L10 7 L5 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</header>
<!-- The route — desktop horizontal -->
<div class="rr-wrap rr-desktop">
<div class="rr-scroll" id="rr-scroll">
<div class="rr-track" style={`width: ${layout.trackWidth}px; height: 580px;`}>
<svg class="rr-path" width={layout.trackWidth} height="580" aria-hidden="true">
<defs>
<linearGradient id="rr-path-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(travelledStop)} stop-color="#2a2520" stop-opacity="0.55"/>
<stop offset={String(Math.min(1, travelledStop + 0.06))} stop-color="#2a2520" stop-opacity="0.15"/>
<stop offset="1" stop-color="#2a2520" stop-opacity="0.15"/>
</linearGradient>
</defs>
{layout.pathD && (
<path d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
)}
</svg>
{items.map((item, i) => (
<div
class="rr-milestone"
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
>
<div
class:list={['rr-dot', { 'rr-current': i === lastShippingIndex }]}
style={`background:${STATUS_DOT_COLOR[item.status]};`}
aria-hidden="true"
></div>
<div class:list={['rr-attach', `rr-attach-${layout.cardSide[i]}`]}>
<div class="rr-connector" aria-hidden="true"></div>
<a class="rr-card" tabindex="0" href={`#item-${item.id}`} id={`item-${item.id}`}>
<p class="rr-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rr-card-title">{item.title}</p>
<div class="rr-more">
{item.description && <p class="rr-desc">{item.description}</p>}
{trailingLine(item) && <p class="rr-trail">{trailingLine(item)}</p>}
</div>
</a>
</div>
</div>
))}
</div>
</div>
<div class="rr-fade-left" id="rr-fade-l" aria-hidden="true"></div>
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
</div>
<!-- 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 -->
<ol class="rr-mobile" aria-label="Roadmap timeline">
{items.map((item, i) => (
<li class="rrm-row">
<div class="rrm-track-col" aria-hidden="true">
<span class="rrm-dot" style={`background:${STATUS_DOT_COLOR[item.status]};`}></span>
{i < items.length - 1 && <span class="rrm-line"></span>}
</div>
<div class="rrm-body">
<p class="rrm-eyebrow" style={`color:${STATUS_LABEL_COLOR[item.status]};`}>
{item.target ? `${item.target.toUpperCase()} · ` : ''}{STATUS_LABEL[item.status]}
</p>
<p class="rrm-title">{item.title}</p>
{item.description && <p class="rrm-desc">{item.description}</p>}
{trailingLine(item) && <p class="rrm-trail">{trailingLine(item)}</p>}
</div>
</li>
))}
</ol>
</section>
<script>
// Vanilla nav for the desktop horizontal route:
// - arrows scrollBy 72% of the viewport per click
// - edge fades flip on at-start / at-end
// - progress dots track scroll position
// - on mount, scroll the 'you are here' milestone roughly 25% from left
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
const prev = section.querySelector<HTMLButtonElement>('#rr-prev');
const next = section.querySelector<HTMLButtonElement>('#rr-next');
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
if (!scroll) return;
const step = () => scroll.clientWidth * 0.72;
prev?.addEventListener('click', () => scroll.scrollBy({ left: -step(), behavior: 'smooth' }));
next?.addEventListener('click', () => scroll.scrollBy({ left: step(), behavior: 'smooth' }));
function update() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 2;
if (prev) prev.disabled = atStart;
if (next) next.disabled = atEnd;
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
}
scroll.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
// Initial scroll: park the most recent shipping item ~25% from the left.
const initialX = Number(section.dataset.initialX ?? 0);
if (initialX > 0) {
const max = scroll.scrollWidth - scroll.clientWidth;
const target = Math.max(0, Math.min(max, initialX - scroll.clientWidth * 0.25));
scroll.scrollLeft = target;
}
// First paint may happen before layout settles — re-measure shortly after.
setTimeout(update, 50);
update();
});
</script>
<style>
/* ── Section header ─────────────────────────────────────────────── */
.route-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 24px;
margin-bottom: 18px;
}
.route-title {
font-family: var(--font-serif);
font-weight: 400;
font-size: 20px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.route-arrows {
display: flex;
gap: 8px;
}
.route-arrow {
width: 32px;
height: 32px;
border-radius: 50%;
border: 0.5px solid rgba(0, 0, 0, 0.18);
background: var(--background);
color: var(--on-surface);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: opacity var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard);
}
.route-arrow:hover:not(:disabled) { background: var(--surface-container-low); }
.route-arrow:disabled { opacity: 0.25; cursor: default; }
/* ── Desktop route ──────────────────────────────────────────────── */
.rr-wrap { position: relative; }
.rr-scroll {
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
/* Card-half (110px) + breathing buffer (30px) so first/last dots
have a card-width of clear space inside the viewport when scrolled
to the extremes. scroll-padding makes snap-stops land cleanly. */
padding: 0 140px 8px;
scroll-padding-left: 140px;
scroll-padding-right: 140px;
}
.rr-scroll::-webkit-scrollbar { display: none; }
.rr-track { position: relative; }
.rr-path { position: absolute; top: 0; left: 0; pointer-events: none; }
.rr-milestone {
position: absolute;
transform: translate(-50%, -50%);
}
.rr-dot {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 0 5px var(--background); /* halo cuts the path under the dot */
transition: transform .25s ease, box-shadow .25s ease;
scroll-snap-align: center;
}
.rr-dot.rr-current {
transform: scale(1.3);
box-shadow:
0 0 0 5px var(--background), /* cream halo */
0 0 0 6px rgba(185, 107, 88, 0.45); /* terracotta ring outside */
}
/* Hover-on-card animates the sibling dot too. :has() is fine on every
evergreen browser we target; older Firefox just doesn't grow the dot. */
.rr-milestone:has(.rr-card:hover) .rr-dot,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot {
transform: scale(1.15);
}
.rr-milestone:has(.rr-card:hover) .rr-dot.rr-current,
.rr-milestone:has(.rr-card:focus-visible) .rr-dot.rr-current {
transform: scale(1.4);
}
.rr-attach {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.rr-attach-below { top: 7px; } /* hangs down from the dot */
.rr-attach-above { bottom: 7px; flex-direction: column-reverse; }
.rr-connector {
width: 1px;
height: 30px;
background: rgba(0, 0, 0, 0.18);
}
.rr-card {
display: block;
width: 220px;
padding: 12px 14px;
border-radius: 10px;
background: transparent;
color: inherit;
text-decoration: none;
border-bottom: none;
transition:
transform .35s cubic-bezier(.2,.7,.3,1),
box-shadow .35s ease,
background .25s ease;
cursor: pointer;
}
.rr-card:hover,
.rr-card:focus-visible {
background: var(--surface-card);
box-shadow:
0 12px 32px -16px rgba(42, 37, 32, 0.25),
0 0 0 0.5px var(--surface-card-border);
transform: translateY(-2px);
z-index: 10;
border-bottom: none;
outline: none;
}
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0 0 6px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 16px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.rr-more {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height .35s ease,
opacity .25s ease,
margin-top .35s ease;
margin-top: 0;
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 280px;
opacity: 1;
margin-top: 10px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.rr-trail {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
/* Edge fades */
.rr-fade-left, .rr-fade-right {
position: absolute;
top: 0;
bottom: 16px;
pointer-events: none;
transition: opacity .25s ease;
}
.rr-fade-left {
left: 0; width: 60px;
background: linear-gradient(to left, transparent, var(--background));
opacity: 0;
}
.rr-fade-right {
right: 0; width: 90px;
background: linear-gradient(to right, transparent, var(--background));
}
/* Legend below the track — reading order: title → track → key. */
.rr-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 28px;
flex-wrap: wrap;
}
.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-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Mobile vertical timeline ──────────────────────────────────── */
.rr-mobile { display: none; }
@media (max-width: 767px) {
.rr-desktop { display: none; }
.route-arrows { display: none; }
.rr-mobile {
display: block;
list-style: none;
padding: 0;
margin: 0;
}
.rrm-row {
display: grid;
grid-template-columns: 32px 1fr;
gap: 16px;
align-items: start;
}
.rrm-track-col {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
min-height: 100%;
padding-top: 6px;
}
.rrm-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.rrm-line {
width: 1px;
flex: 1;
min-height: 28px;
background: rgba(0, 0, 0, 0.18);
margin-top: 4px;
}
.rrm-body {
display: flex;
flex-direction: column;
gap: 6px;
padding-bottom: 28px;
}
.rrm-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0;
font-weight: 600;
}
.rrm-title {
font-family: var(--font-serif);
font-size: 18px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.rrm-desc {
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.rrm-trail {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
margin: 0;
}
}
</style>