diff --git a/assets/css/stats.css b/assets/css/stats.css
index c53c989..d0906e4 100644
--- a/assets/css/stats.css
+++ b/assets/css/stats.css
@@ -687,12 +687,9 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
position: sticky;
top: var(--header-h, 64px);
z-index: 20;
- display: flex;
- gap: 0.35rem;
margin: 1.4rem 0;
padding: 0.4rem;
- overflow-x: auto;
- scrollbar-width: none;
+ overflow: hidden; /* clip the inner track to the rounded pill */
background: rgba(8, 20, 33, 0.88);
backdrop-filter: blur(10px) saturate(1.2);
-webkit-backdrop-filter: blur(10px) saturate(1.2);
@@ -700,7 +697,33 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
border-radius: 999px;
}
-.stats-subnav::-webkit-scrollbar { display: none; }
+/* the scrolling chip track — the edge-fade mask goes here, not on the pill, so
+ the pill's background + rounded ends stay crisp while only chips fade out. */
+.stats-subnav-track {
+ position: relative;
+ display: flex;
+ gap: 0.35rem;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.stats-subnav-track::-webkit-scrollbar { display: none; }
+
+/* edge fades — toggled by JS only on a side that still has chips to scroll to */
+.stats-subnav.fade-right .stats-subnav-track {
+ -webkit-mask-image: linear-gradient(to left, transparent 0, #000 28px);
+ mask-image: linear-gradient(to left, transparent 0, #000 28px);
+}
+
+.stats-subnav.fade-left .stats-subnav-track {
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px);
+ mask-image: linear-gradient(to right, transparent 0, #000 28px);
+}
+
+.stats-subnav.fade-left.fade-right .stats-subnav-track {
+ -webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
+ mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
+}
.stats-subnav-chip {
flex: 0 0 auto;
diff --git a/assets/js/stats.js b/assets/js/stats.js
index 4c8180e..2226146 100644
--- a/assets/js/stats.js
+++ b/assets/js/stats.js
@@ -433,13 +433,23 @@ function subNavHTML(sections) {
const chips = sections.map((section, i) => `
${t(section.navKey)}`).join('');
- return ``;
+ // chips live in an inner scroll track so the edge-fade mask never clips the
+ // pill's background/rounded ends (only the chips fade at the edges).
+ return ``;
}
let spyScrollHandler = null;
+let subnavResizeHandler = null;
+// While a chip-click smooth-scroll is in flight, the page-scroll spy must NOT
+// fight it: early in the animation the viewport is still over the old section,
+// so the spy would flip the active chip back and then forward again (a visible
+// jump). Suppress spy updates until the programmatic scroll has settled.
+let suppressSpyUntil = 0;
+
function setupSubNav(root, sections) {
const nav = root.querySelector('.stats-subnav');
if (!nav) return;
+ const track = nav.querySelector('.stats-subnav-track');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
@@ -449,11 +459,16 @@ function setupSubNav(root, sections) {
const chip = event.target.closest('.stats-subnav-chip');
if (!chip) return;
event.preventDefault();
+ suppressSpyUntil = Date.now() + (reduce ? 0 : 700); // hold the spy off during the animated scroll
document.getElementById(`stats-${chip.dataset.section}`)
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
setActiveChip(nav, chip.dataset.section);
});
+ // edge fades while the chip track overflows horizontally (mirrors the header
+ // tabs): a fade shows only on a side that still has chips to scroll toward.
+ track?.addEventListener('scroll', () => updateSubnavFades(nav), { passive: true });
+
// scrollspy: active = the last section whose heading has scrolled under the
// sticky sub-nav line; at the page bottom the last section always wins (a short
// final section may never reach the line — the classic scrollspy edge case an
@@ -461,6 +476,7 @@ function setupSubNav(root, sections) {
// handful of sections per frame is cheap and always correct on short pages.
const ids = sections.map((section) => section.id);
const updateSpy = () => {
+ if (Date.now() < suppressSpyUntil) return; // a chip-click scroll owns the active chip
const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
const line = headerH + 80; // just beneath the sticky sub-nav
let activeId = ids[0];
@@ -480,19 +496,40 @@ function setupSubNav(root, sections) {
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
};
window.addEventListener('scroll', spyScrollHandler, { passive: true });
+
+ // re-evaluate the fades when the viewport width changes the track's overflow
+ if (subnavResizeHandler) window.removeEventListener('resize', subnavResizeHandler);
+ subnavResizeHandler = () => updateSubnavFades(nav);
+ window.addEventListener('resize', subnavResizeHandler, { passive: true });
+
updateSpy();
+ updateSubnavFades(nav);
+}
+
+// Toggle the edge-fade mask classes on the pill based on the inner track's
+// horizontal overflow (the mask lives on the track, so the pill stays crisp).
+function updateSubnavFades(nav) {
+ const track = nav.querySelector('.stats-subnav-track');
+ if (!track) return;
+ const overflowing = track.scrollWidth - track.clientWidth > 1;
+ const atStart = track.scrollLeft <= 1;
+ const atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
+ nav.classList.toggle('fade-left', overflowing && !atStart);
+ nav.classList.toggle('fade-right', overflowing && !atEnd);
}
function setActiveChip(nav, id) {
+ const track = nav.querySelector('.stats-subnav-track');
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
const on = chip.dataset.section === id;
chip.classList.toggle('active', on);
chip.setAttribute('aria-current', on ? 'true' : 'false');
}
- // keep the active chip visible when the nav scrolls horizontally on mobile
- // (only moves the nav's own scroll, never the page).
+ // keep the active chip visible when the track scrolls horizontally on mobile
+ // (only moves the track's own scroll, never the page).
const active = nav.querySelector('.stats-subnav-chip.active');
- if (active) nav.scrollLeft = active.offsetLeft - (nav.clientWidth - active.clientWidth) / 2;
+ if (active && track) track.scrollLeft = active.offsetLeft - (track.clientWidth - active.clientWidth) / 2;
+ updateSubnavFades(nav);
}
// ----------------------------------------------------------- flags