project-bifrost-platform/src/components/RoadmapRoute.astro
Jonathan Hvid f8d88ed760 fix(route): 'you are here' marker is unmistakable + dot animates with hover
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>
2026-05-12 11:56:49 +02:00

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>