diff --git a/skills/scroll-world/references/index-template.html b/skills/scroll-world/references/index-template.html index 96ecc8a..f55f7f0 100644 --- a/skills/scroll-world/references/index-template.html +++ b/skills/scroll-world/references/index-template.html @@ -27,8 +27,10 @@ hint: 'scroll to fly in', diveScroll: 1.3, connScroll: 0.9, + hashNavigation: true, // #section-id links jump to scenes; set false to disable sections: [ - // One object per scene, in order. `accent` colours the copy/route for that scene. + // One object per scene, in order. `id` becomes a URL hash target such as #finale. + // `accent` colours the copy/route for that scene. // First section is the hero (its copy greets on landing); last carries the CTA. { id: 'sceneA', label: 'Scene A', diff --git a/skills/scroll-world/references/scrub-engine.js b/skills/scroll-world/references/scrub-engine.js index d7d4c3b..b792881 100644 --- a/skills/scroll-world/references/scrub-engine.js +++ b/skills/scroll-world/references/scrub-engine.js @@ -13,6 +13,7 @@ connScroll: 0.9, // ...per connector clip hint: 'scroll to fly in', nav: true, // show the top section nav + hashNavigation: true, // enable #section-id links + nav hash updates atmosphere: true, // subtle gradient + drifting particles behind the clips sections: [ { id, label, still, stillMobile, clip, clipMobile, accent, @@ -77,8 +78,11 @@ function mountScrollWorld(container, config) { const DIVE_W = config.diveScroll || 1.3; const CONN_W = config.connScroll || 0.9; const CROSSFADE = (config.crossfade != null) ? config.crossfade : 0.12; // seam dissolve width (vh) + const HASH_NAV = config.hashNavigation !== false; const N = SECTIONS.length; if (!N) return; + const sectionIds = Object.create(null); + SECTIONS.forEach((s, i) => { if (s.id) sectionIds[String(s.id)] = i; }); injectCSS(); container.classList.add('sw-root'); @@ -161,11 +165,11 @@ function mountScrollWorld(container, config) { const dot = el('button', 'sw-route__dot'); dot.style.setProperty('--sw-accent', s.accent || ''); dot.innerHTML = `${esc(s.label || '')}`; - dot.addEventListener('click', () => jumpTo(i)); route.appendChild(dot); dots.push(dot); + dot.addEventListener('click', () => jumpTo(i, { updateHash: HASH_NAV })); route.appendChild(dot); dots.push(dot); if (config.nav !== false) { const b = el('button', 'sw-nav__item'); b.textContent = s.label || ''; - b.addEventListener('click', () => jumpTo(i)); nav.appendChild(b); + b.addEventListener('click', () => jumpTo(i, { updateHash: HASH_NAV })); nav.appendChild(b); } }); @@ -190,11 +194,32 @@ function mountScrollWorld(container, config) { read(); } - function jumpTo(i) { + function jumpTo(i, opts) { const seg = SECTIONS[i]._seg; + if (!seg) return; + opts = opts || {}; + if (opts.updateHash) updateHash(SECTIONS[i].id); window.scrollTo({ top: seg.start + (seg.end - seg.start) * 0.5, behavior: reduce ? 'auto' : 'smooth' }); } + function sectionIndexFromHash() { + if (!HASH_NAV || !window.location.hash) return -1; + let id = window.location.hash.slice(1); + try { id = decodeURIComponent(id); } catch (e) {} + return Object.prototype.hasOwnProperty.call(sectionIds, id) ? sectionIds[id] : -1; + } + + function updateHash(id) { + if (!id || !window.history || !window.history.pushState) return; + const hash = '#' + encodeURIComponent(String(id)); + if (window.location.hash !== hash) window.history.pushState(null, '', hash); + } + + function jumpToHash() { + const i = sectionIndexFromHash(); + if (i >= 0) jumpTo(i); + } + function loadClip(s) { // Under prefers-reduced-motion we never load the clips at all — the stills stay up // and simply cross-dissolve as you scroll. No scrubbed video motion, no decode cost. @@ -321,7 +346,23 @@ function mountScrollWorld(container, config) { window.addEventListener('resize', onResize); window.addEventListener('orientationchange', layout); window.addEventListener('load', layout); + if (HASH_NAV) { + container.addEventListener('click', e => { + const a = e.target.closest && e.target.closest('a[href^="#"]'); + if (!a || !container.contains(a)) return; + const raw = a.getAttribute('href'); + if (!raw || raw === '#') return; + let id = raw.slice(1); + try { id = decodeURIComponent(id); } catch (err) {} + if (!Object.prototype.hasOwnProperty.call(sectionIds, id)) return; + e.preventDefault(); + updateHash(id); + jumpTo(sectionIds[id]); + }); + window.addEventListener('hashchange', jumpToHash); + } layout(); + jumpToHash(); requestAnimationFrame(raf); // ---- helpers ----