The previous .rr-current was a 1.15× scale on top of an animated pulsing ::after ring — subtle, and the pulse was easy to miss against the cream ground. Replaced with a static box-shadow ring at 6px offset in 45% terracotta, plus a 1.3× scale on the dot itself. The pulse is gone; the ring is now visible at rest, which is what the marker needs to do. Hover/focus on a milestone card now scales its sibling dot via :has(): - any card hover/focus → its dot 1.15 - the current-shipping card hover/focus → its dot 1.4 The dot acknowledges that you've engaged with its card. Cleaner than tying scroll position or click state. :has() ships in every evergreen browser since 2023; older Firefox just won't grow the dot, which degrades to no harm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
539 lines
17 KiB
Text
539 lines
17 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;
|
|
}
|
|
|
|
// 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: 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>
|
|
|
|
<!-- 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>
|
|
|
|
<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');
|
|
const dots = Array.from(section.querySelectorAll<HTMLElement>('#rr-progress .rr-progress-dot'));
|
|
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';
|
|
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 });
|
|
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;
|
|
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;
|
|
/* 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));
|
|
}
|
|
|
|
/* 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 ──────────────────────────────────── */
|
|
.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>
|