feat(roadmap): centred 'Roadmap' page header + legend escapes route component

Page-level rebuild. /roadmap.astro renders a centred header block that
reads as one calm vertical stack:

  Roadmap        ← 11px tracked uppercase eyebrow (--on-surface-variant)
  Roadmap        ← 48px serif h1, single word
  A live picture of the work…   ← 14px subtitle, 480px max-width

The eyebrow + h1 read 'Roadmap → Roadmap' on purpose — the tracked
uppercase eyebrow primes the eye for the serif headline, and the
repetition feels confident rather than redundant. If it starts to grate
in practice, the eyebrow's the easy drop.

'What we are building.' is gone. The earlier 'The route' sub-header
inside <RoadmapRoute> is gone. The two prev/next arrow buttons in that
sub-header are gone (the single right-edge advance arrow lands in the
next commit).

Legend moves out of <RoadmapRoute> and into /roadmap.astro as the page's
final block. With the route still .rr-fullbleed, this lets the legend
return to centred content-column width — exactly the spec's
'header-centred / route-wide / legend-centred' rhythm.

Mid-state in this commit: the advance arrow doesn't exist yet, so
there's no in-page scroll affordance beyond the still-active scroll-snap
behaviour. Step 3 adds the arrow; step 4 strips the snap and adds drag
+ wheel + animated glide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 15:15:47 +02:00
parent 1c020f191c
commit 22a55aa073
2 changed files with 69 additions and 126 deletions

View file

@ -52,23 +52,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
--- ---
<section class="route" aria-label="Roadmap route" data-initial-x={initialShippingX}> <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. .rr-fullbleed escapes the parent <!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
.page max-width so the route can span the actual viewport while .page max-width so the route can span the actual viewport while
the header above and legend below stay centred in the content the header above and legend below stay centred in the content
@ -126,16 +109,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div> <div class="rr-fade-right" id="rr-fade-r" aria-hidden="true"></div>
</div> </div>
<!-- Legend — sits under the track, centred. Reading order is now <!-- Legend lives in /roadmap.astro now so it returns to centred
"The route" → walk the path → "ah, the dots mean these things." --> content-column width below the full-bleed route. -->
<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 --> <!-- Mobile vertical timeline -->
<ol class="rr-mobile" aria-label="Roadmap timeline"> <ol class="rr-mobile" aria-label="Roadmap timeline">
{items.map((item, i) => ( {items.map((item, i) => (
<li class="rrm-row"> <li class="rrm-row">
@ -174,8 +151,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg'); const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
const pathD = section.querySelector<SVGPathElement>('#rr-path-d'); const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone')); const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
const prev = section.querySelector<HTMLButtonElement>('#rr-prev');
const next = section.querySelector<HTMLButtonElement>('#rr-next');
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l'); const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r'); const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
if (!scroll || !track || !svg) return; if (!scroll || !track || !svg) return;
@ -219,16 +194,12 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; }); milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
} }
const step = () => scroll!.clientWidth * 0.72; /* Arrow handler + edge-state updates are rewritten in the next commit
prev?.addEventListener('click', () => scroll!.scrollBy({ left: -step(), behavior: 'smooth' })); (drag + wheel + glide). For now only the fades update on scroll. */
next?.addEventListener('click', () => scroll!.scrollBy({ left: step(), behavior: 'smooth' }));
function updateNav() { function updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth; const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2; const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 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 (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1'; if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
} }
@ -259,45 +230,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
</script> </script>
<style> <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 ──────────────────────────────────────────────── */ /* ── Desktop route ──────────────────────────────────────────────── */
.rr-wrap { position: relative; } .rr-wrap { position: relative; }
@ -475,37 +407,11 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
background: linear-gradient(to right, transparent, var(--background)); 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 ──────────────────────────────────── */ /* ── Mobile vertical timeline ──────────────────────────────────── */
.rr-mobile { display: none; } .rr-mobile { display: none; }
@media (max-width: 767px) { @media (max-width: 767px) {
.rr-desktop { display: none; } .rr-desktop { display: none; }
.route-arrows { display: none; }
.rr-mobile { .rr-mobile {
display: block; display: block;
list-style: none; list-style: none;

View file

@ -6,17 +6,16 @@ import { getAllRoadmapItems } from '../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
// Admin orders chronologically nearest-to-furthest via display_order.
const items = getAllRoadmapItems() const items = getAllRoadmapItems()
.sort((a, b) => a.display_order - b.display_order || a.id - b.id); .sort((a, b) => a.display_order - b.display_order || a.id - b.id);
--- ---
<AppLayout title="Roadmap" user={user}> <AppLayout title="Roadmap" user={user}>
<div class="page"> <article class="roadmap-page">
<header class="page-header"> <header class="roadmap-header">
<p class="page-eyebrow">Roadmap</p> <p class="roadmap-eyebrow">Roadmap</p>
<h1 class="page-title">What we are building.</h1> <h1 class="roadmap-title">Roadmap</h1>
<p class="page-sub"> <p class="roadmap-subtitle">
A live picture of the work. What's in motion, what's queued, A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Tap or hover any milestone what we're still thinking about. Tap or hover any milestone
for the full story. for the full story.
@ -26,27 +25,42 @@ const items = getAllRoadmapItems()
<LatestDispatchBanner /> <LatestDispatchBanner />
<RoadmapRoute items={items} /> <RoadmapRoute items={items} />
</div>
<!-- Legend lives outside the route component so it can return to
centred content-column width while the route goes full-bleed. -->
<div class="roadmap-legend" 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>
</article>
</AppLayout> </AppLayout>
<style> <style>
.page { .roadmap-page {
padding: 40px 36px 80px; padding: 0 36px 80px;
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
/* ── Page header ─────────────────────────────────────────────── */ /* ── Centred page header ──────────────────────────────────────── */
.page-header { margin-bottom: 36px; max-width: 540px; } .roadmap-header {
.page-eyebrow { text-align: center;
max-width: 640px;
margin: 0 auto 48px;
padding-top: 32px;
}
.roadmap-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 11px; font-size: 11px;
letter-spacing: var(--tracking-wider); letter-spacing: 1.4px;
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-variant); color: var(--on-surface-variant);
margin: 0 0 12px; margin: 0 0 12px;
} }
.page-title { .roadmap-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
font-size: 48px; font-size: 48px;
@ -55,24 +69,47 @@ const items = getAllRoadmapItems()
color: var(--on-surface); color: var(--on-surface);
margin: 0 0 14px; margin: 0 0 14px;
} }
.page-sub { .roadmap-subtitle {
font-size: 14px; font-size: 14px;
line-height: 1.55; line-height: 1.65;
color: var(--on-surface-variant); color: var(--on-surface-variant);
margin: 0; margin: 0 auto;
max-width: 540px; max-width: 480px;
} }
/* Banner sits directly above the route now — restore the original .roadmap-page :global(.banner),
56px gap so the editorial banner reads as its own beat. */ .roadmap-page :global(.rr-dispatch) { margin-bottom: 56px; }
.page :global(.banner),
.page :global(.rr-dispatch) { margin-bottom: 56px; } /* ── Legend ───────────────────────────────────────────────────── */
.roadmap-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 28px;
flex-wrap: wrap;
}
.roadmap-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);
}
.roadmap-legend i {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.page { padding: 32px 24px 64px; } .roadmap-page { padding: 0 24px 64px; }
.page-title { font-size: 36px; } .roadmap-header { padding-top: 24px; margin-bottom: 32px; }
.page-header { margin-bottom: 28px; } .roadmap-title { font-size: 36px; }
.page :global(.banner), .roadmap-page :global(.banner),
.page :global(.rr-dispatch) { margin-bottom: 40px; } .roadmap-page :global(.rr-dispatch) { margin-bottom: 40px; }
} }
</style> </style>