feat(route): single right-edge advance arrow — forward-only affordance

Replaces the prev/next button pair removed last commit with a single
viewport-anchored circular control.

  - 48px diameter, 1px terracotta border, terracotta chevron on cream.
  - Hover / focus-visible fills to terracotta with cream chevron and a
    1.06× scale-up.
  - Anchored absolute inside .rr-wrap (already position: relative):
    right: 32px / top: 50% / translateY(-50%).
  - Toggles .rr-at-end (opacity 0.25, pointer-events: none) when the
    scroll container reaches its right edge.
  - First-load hint: .rr-hint class added 100ms after mount fires a
    rr-advance-pulse keyframe three times (iteration-count: 3) — soft
    8px shadow ring in 15% terracotta pulses out and back. Animation
    stops naturally; no JS cleanup needed.

No left arrow on purpose — the path reads past → future, and the
user's instinct at any milestone is 'what's next?' The right arrow
earns its keep by hinting the existence of more track beyond the
visible window. A symmetric left arrow would just be noise.

Click handler today: scrollBy({behavior: 'smooth'}) by 60% of viewport
width. Step 4 replaces this with a custom-animated glide and adds the
drag + wheel scroll modalities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 15:17:26 +02:00
parent 22a55aa073
commit acbb722a0a

View file

@ -107,6 +107,21 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
<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>
<!-- Single forward-only advance affordance anchored to the right
viewport edge. There's no left arrow on purpose — the path
reads left-to-right and the user's instinct after looking at
a milestone is 'what's next?', not 'what came before?'. -->
<button
type="button"
class="rr-advance"
id="rr-advance"
aria-label="Further along the route"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<!-- Legend lives in /roadmap.astro now so it returns to centred
@ -153,6 +168,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const milestones = Array.from(section.querySelectorAll<HTMLElement>('.rr-milestone'));
const fadeL = section.querySelector<HTMLElement>('#rr-fade-l');
const fadeR = section.querySelector<HTMLElement>('#rr-fade-r');
const advance = section.querySelector<HTMLButtonElement>('#rr-advance');
if (!scroll || !track || !svg) return;
const itemCount = milestones.length;
@ -194,16 +210,22 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
}
/* Arrow handler + edge-state updates are rewritten in the next commit
(drag + wheel + glide). For now only the fades update on scroll. */
/* Edge state — fades + advance disable. The smooth glide animation
on advance click lands in the next commit; for now the click
uses native smooth scrollBy. */
function updateNav() {
const max = scroll!.scrollWidth - scroll!.clientWidth;
const atStart = scroll!.scrollLeft <= 2;
const atEnd = scroll!.scrollLeft >= max - 2;
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (advance) advance.classList.toggle('rr-at-end', atEnd);
}
advance?.addEventListener('click', () => {
scroll!.scrollBy({ left: scroll!.clientWidth * 0.6, behavior: 'smooth' });
});
scroll.addEventListener('scroll', updateNav, { passive: true });
// Debounced resize → recompute layout + refresh nav state. 120ms is
@ -226,6 +248,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
setTimeout(updateNav, 50);
updateNav();
// Three-pulse hint on the advance arrow ~100ms after layout settles
// so the user notices the affordance once and then it sits quietly.
setTimeout(() => advance?.classList.add('rr-hint'), 100);
});
</script>
@ -387,6 +413,50 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
margin: 0;
}
/* ── Advance arrow ─────────────────────────────────────────────── */
.rr-advance {
position: absolute;
right: 32px;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--pigment-terracotta);
background: var(--background);
color: var(--pigment-terracotta);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
z-index: 5;
transition: background .2s ease,
color .2s ease,
opacity .25s ease,
transform .25s ease;
}
.rr-advance:hover,
.rr-advance:focus-visible {
background: var(--pigment-terracotta);
color: var(--background);
outline: none;
transform: translateY(-50%) scale(1.06);
}
.rr-advance[disabled],
.rr-advance.rr-at-end {
opacity: 0.25;
pointer-events: none;
}
/* Three-pulse hint on first load — fires once, then stops. */
@keyframes rr-advance-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(185, 107, 88, 0); }
50% { box-shadow: 0 0 0 8px rgba(185, 107, 88, 0.15); }
}
.rr-advance.rr-hint {
animation: rr-advance-pulse 1.4s ease-in-out 3;
}
/* Edge fades cover only the track itself — the top/bottom padding
zones (60/80) on .rr-scroll exist so hover cards can overflow there
without clipping, so the fades shouldn't paint over them. */