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:
parent
884cca85f1
commit
7bd4902b9d
1 changed files with 411 additions and 0 deletions
411
src/components/RoadmapRoute.astro
Normal file
411
src/components/RoadmapRoute.astro
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue