feat(route): nav script — arrows, fades, progress dots, initial scroll

Vanilla TS script at the bottom of RoadmapRoute.astro. No library.

- Arrows scrollBy ±72% of the scroll-container's clientWidth, smooth
  behaviour. Disabled at scroll start/end.
- Edge fades (.rr-fade-left / -right) flip opacity 0↔1 at scroll start /
  end so the affordance disappears when there's nowhere further to go.
- Progress dots track scrollLeft/(scrollWidth-clientWidth) percentage,
  bucketing into dots.length slots. Active dot gets .active (themed in
  CSS as --on-surface).
- On mount, the script reads section.data-initial-x — the SVG x position
  of the most recent shipping milestone (computed server-side from the
  layout helper) — and scrolls so that x sits ~25% from the left edge
  of the viewport. Clamped to [0, scrollWidth-clientWidth]. Member opens
  /roadmap and immediately sees one shipped item + several ahead-of-them
  items, not the very start of history.
- setTimeout(update, 50) re-measures after first paint settles
  (especially relevant when SVG fonts or other late-arriving assets
  shift the trackWidth by a couple of px).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:43:07 +02:00
parent 7bd4902b9d
commit d49882b3f9

View file

@ -159,6 +159,58 @@ const initialShippingX = lastShippingIndex >= 0 ? itemXByIndex[lastShippingIndex
</ol> </ol>
</section> </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> <style>
/* ── Section header ─────────────────────────────────────────────── */ /* ── Section header ─────────────────────────────────────────────── */
.route-header { .route-header {