feat(component): RoadmapRoute — SVG path + milestones + hover-reveal cards (static)

The treasure-map. Static render only; nav script lands in the next commit.

Section header: serif 'The route' + tracked-uppercase legend
(Shipping / In beta / Exploring / Considering) on the left; two 32px
round arrow buttons on the right (matching the /pulse RoadmapCarousel
chrome).

Body — desktop layout (.rr-desktop):
- Outer .rr-wrap holds an overflow-x: auto .rr-scroll with snap-x.
- Track is sized to layout.trackWidth × 460. Cubic-bezier SVG path
  rendered behind milestones, stroked with a horizontal gradient that
  fades from #2a2520 / 0.55 alpha through to #2a2520 / 0.15 at the
  travelled-stop position (computed by travelledStopFor in step 3).
- Each milestone is a 14px round dot in its status colour, with a 5px
  cream halo cutting the path beneath. The 'you are here' marker (most
  recent shipping item) gets a 1.15× scale + a quiet 2.4s pulse ring.
- Cards hang from each dot via a 1px / 30px vertical connector, on the
  alternating cardSide returned by layout. .rr-card is the anchor target;
  hover and :focus-visible both reveal the description + trailing line
  via max-height + opacity transitions, so keyboard tab is a first-class
  interaction (no mouse required).
- Trailing line: item.metadata_text if set, else 'Shaped by {first
  names}' if attributed_members non-empty, else nothing.
- Edge fades on both sides for scroll affordance (left fade hidden when
  at scrollLeft 0; right fade hides when at scrollEnd — the JS in step 6
  will toggle their opacity).

Progress dots row underneath — count = max(2, min(6, ceil(items/2))).
First dot starts active; nav script will move it.

Mobile vertical fallback (.rr-mobile) markup is included now but kept
display:none on desktop. Step 7 turns it on at the (max-width: 767px)
breakpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:42:32 +02:00
parent 884cca85f1
commit 7bd4902b9d

View file

@ -0,0 +1,411 @@
---
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;
}
// 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;
---
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}>
<!-- Section header (legend + arrows) -->
<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">
<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: 460px;`}>
<svg class="rr-path" width={layout.trackWidth} height="460" 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>
<!-- 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>
))}
</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>
<style>
/* ── Section header ─────────────────────────────────────────────── */
.route-header {
display: flex;
justify-content: space-between;
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);
font-weight: 400;
font-size: 20px;
line-height: 1.2;
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;
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;
padding-bottom: 8px;
}
.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;
scroll-snap-align: center;
}
.rr-dot.rr-current {
transform: scale(1.15);
}
.rr-dot.rr-current::after {
/* a quiet pulsing ring around the 'you are here' milestone */
content: '';
position: absolute;
inset: -6px;
border-radius: 50%;
border: 1px solid #6d8c7c;
opacity: 0.4;
animation: rr-pulse 2.4s ease-in-out infinite;
}
@keyframes rr-pulse {
0%, 100% { transform: scale(1); opacity: 0.4; }
50% { transform: scale(1.3); opacity: 0; }
}
.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));
}
/* Progress dots */
.rr-progress {
display: flex;
justify-content: center;
gap: 6px;
margin-top: 28px;
}
.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-progress-dot.active {
background: var(--on-surface);
}
/* Mobile vertical timeline lives in step 7; hide it for now on desktop. */
.rr-mobile { display: none; }
@media (max-width: 767px) {
.rr-desktop { display: none; }
.rr-mobile { display: block; }
}
</style>