feat(route): full-bleed escape + client recompute on real viewport width

Two coupled changes so the route actually uses the page width:

.rr-wrap.rr-desktop gains the .rr-fullbleed class which uses the canonical
calc(50% - 50vw) margin trick to break out of the parent .page max-width.
The headline, dispatch banner, route section header, and legend all stay
inside the content column at 72rem; only the route itself widens to the
viewport. Visually reads as a magazine spread — the section header lands
centred, then the path spreads outward beneath.

Viewport-aware layout: SSR still uses the 1100 default (we can't know
the client viewport server-side), but a new mount script on
RoadmapRoute recomputes the layout against window.innerWidth and
updates:
  - .rr-track width (via inline style)
  - .rr-path-svg width attribute
  - .rr-path-d d attribute (rebuilt from the same cubic-bezier
    formula the SSR helper uses, with the live itemX values; itemY
    comes from per-milestone data-y attributes since amplitude
    doesn't change with viewport)
  - .rr-milestone left positions

Resize: 120ms debounced handler runs the same recompute + refreshes
the arrow/fade nav state. Each milestone keeps its same data-y, so
only the horizontal spread changes — the river's vertical shape is
preserved on resize.

Initial-scroll into shipping rewired to read the .rr-current
milestone's live `style.left` after recompute, not the SSR-computed
data-initial-x value (which is now stale once the client redoes the
math).

.rr-scroll horizontal padding 60 → 80 + scroll-padding-{left,right}
60 → 80 so first/last cards have breathing room inside the now-
viewport-wide container.

Smoke as Lars: rr-fullbleed class on the wrap, data-y attributes on
each milestone, rr-path-svg id present. The SVG width and itemX
positions land at viewport-derived values after mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 14:56:41 +02:00
parent 73dc656257
commit d7c13d3c99

View file

@ -69,12 +69,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
</div>
</header>
<!-- The route — desktop horizontal -->
<div class="rr-wrap rr-desktop">
<!-- The route — desktop horizontal. .rr-fullbleed escapes the parent
.page max-width so the route can span the actual viewport while
the header above and legend below stay centred in the content
column. -->
<div class="rr-wrap rr-fullbleed rr-desktop" data-item-count={items.length}>
<div class="rr-scroll" id="rr-scroll">
<div class="rr-scroll-inner">
<div class="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" width={layout.trackWidth} height="420" aria-hidden="true">
<div class="rr-track" id="rr-track" style={`width: ${layout.trackWidth}px; height: 420px;`}>
<svg class="rr-path" id="rr-path-svg" width={layout.trackWidth} height="420" 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"/>
@ -84,13 +87,14 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
</linearGradient>
</defs>
{layout.pathD && (
<path d={layout.pathD} fill="none" stroke="url(#rr-path-gradient)" stroke-width="1.25" stroke-linecap="round"/>
<path id="rr-path-d" 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"
data-y={layout.itemY[i]}
style={`left: ${layout.itemX[i]}px; top: ${layout.itemY[i]}px;`}
>
<div
@ -153,25 +157,73 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
</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
// Vanilla route runtime. Two concerns:
// 1. Nav: arrow buttons, edge fades, initial-scroll into shipping
// 2. Viewport-aware layout — SSR uses a 1100px fallback for the math;
// on the client we know the real viewport, so we recompute itemX
// positions + SVG path d + track width on mount and on (debounced)
// resize. itemY values come from data-y on each milestone (path
// amplitude doesn't change with viewport, only the horizontal spread).
const MIN_SPACING = 320;
const PADDING_X = 60;
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
const wrap = section.querySelector<HTMLElement>('.rr-wrap');
const track = section.querySelector<HTMLElement>('#rr-track');
const svg = section.querySelector<SVGSVGElement>('#rr-path-svg');
const pathD = section.querySelector<SVGPathElement>('#rr-path-d');
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 fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
if (!scroll) return;
if (!scroll || !track || !svg) return;
const step = () => scroll.clientWidth * 0.72;
const itemCount = milestones.length;
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
prev?.addEventListener('click', () => scroll.scrollBy({ left: -step(), behavior: 'smooth' }));
next?.addEventListener('click', () => scroll.scrollBy({ left: step(), behavior: 'smooth' }));
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
function recompute() {
const vw = window.innerWidth;
const targetUsableWidth = vw * 0.80;
const dataDrivenWidth = (itemCount - 1) * MIN_SPACING;
const usableWidth = Math.max(targetUsableWidth, dataDrivenWidth);
const trackWidth = usableWidth + PADDING_X * 2;
function update() {
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
itemX.push(
itemCount === 1
? PADDING_X + usableWidth / 2
: PADDING_X + (i / (itemCount - 1)) * usableWidth,
);
}
// Bezier path: control points at the segment midpoint x with control
// y values matching the prior and next milestone (keeps the tangent
// flat at each dot — the "river" feel from the layout helper).
let d = '';
if (itemCount > 0) {
d = `M ${itemX[0]} ${itemY[0]}`;
for (let i = 1; i < itemCount; i += 1) {
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
}
// Apply.
track!.style.width = `${trackWidth}px`;
svg!.setAttribute('width', String(trackWidth));
if (pathD && d) pathD.setAttribute('d', d);
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
}
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 updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 2;
@ -181,20 +233,28 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
}
scroll.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
scroll.addEventListener('scroll', updateNav, { passive: true });
// Initial scroll: park the most recent shipping item ~25% from the left.
const initialX = Number(section.dataset.initialX ?? 0);
if (initialX > 0) {
// Debounced resize → recompute layout + refresh nav state. 120ms is
// long enough to coalesce drag-resize events without feeling laggy.
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => { recompute(); updateNav(); }, 120);
});
// Initial mount: recompute with the real viewport, then scroll the
// 'you are here' milestone roughly 25% from the left.
recompute();
const initialX = milestones.find(m => m.querySelector('.rr-dot.rr-current'));
if (initialX) {
const x = parseFloat(initialX.style.left) || 0;
const max = scroll.scrollWidth - scroll.clientWidth;
const target = Math.max(0, Math.min(max, initialX - scroll.clientWidth * 0.25));
const target = Math.max(0, Math.min(max, x - scroll.clientWidth * 0.25));
scroll.scrollLeft = target;
}
// First paint may happen before layout settles — re-measure shortly after.
setTimeout(update, 50);
update();
setTimeout(updateNav, 50);
updateNav();
});
</script>
@ -240,6 +300,15 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
/* ── Desktop route ──────────────────────────────────────────────── */
.rr-wrap { position: relative; }
/* Escape the parent .page max-width so the route can use the actual
viewport width. The headline, dispatch banner, section header, and
legend all stay centred at content width — only the route widens. */
.rr-fullbleed {
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
}
.rr-scroll {
/* overflow-x: auto + overflow-y: visible is the only thing that lets
hovered cards expand above/below the track without being clipped.
@ -251,11 +320,13 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: none;
/* Top/bottom give cards room to grow above/below the track. The 60px
sides give the first/last cards room when fully scrolled. */
padding: 60px 60px 80px;
scroll-padding-left: 60px;
scroll-padding-right: 60px;
/* Top/bottom give cards room to grow above/below the track. The 80px
sides give the first/last cards room when fully scrolled inside
the now full-bleed container — small but visible breathing room
between the route and the absolute viewport edges. */
padding: 60px 80px 80px;
scroll-padding-left: 80px;
scroll-padding-right: 80px;
}
.rr-scroll::-webkit-scrollbar { display: none; }
.rr-scroll-inner { /* structural — keeps the track on its own layer */ }