From 94d6090d1ff281b17dfc580720f82d901bcc1447 Mon Sep 17 00:00:00 2001 From: YamadaBlog Date: Fri, 12 Jun 2026 14:09:54 +0200 Subject: [PATCH] perf(demo): native scroll + raster diet + scroll-frozen audio cosmetics (rounds 16-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 16 — scroll refonte (researched + measured) : - Lenis REMOVED -> native scrolling. Its permanent rAF re-dispatched scroll through the main thread (jank-amplifier) and the ~1 s easing was the reported 'floaty/slow' feel. 2026 consensus (NN/g, Thought- works radar, Apple's own product pages) : native scroll + sticky + pre-rendered frames. Anchors/tour keep a glide via CSS scroll-behavior: smooth (reduced-motion gated) ; the tour's manual rAF glide now passes behavior:'instant' to avoid double-easing. - dead useScrollProgress hooks removed (their only CSS consumer was deleted in round 12 — a forced layout per frame for nothing) - useScrollParallax: permanent rAF -> scroll-event driven - useAudioParticles: IntersectionObserver gate + 30 Hz - hero backdrop pre-blurred offline (npm run generate:backdrop, sharp: svg -> 640px -> sigma28 + saturate/brightness baked -> webp 4.5 kB) — retires the page's costliest live filter layer (blur(110px) on a viewport-sized surface). gitignore exception for the 2 derived webp (MIT svg sources, full rights chain). Round 17 — reveal raster diet : - 7 filter:blur(20-60px) removed from gradient-only glow layers (a radial gradient fading to transparent is already soft) ; worst was the halo MOVING with the scrub-tweened product every frame. Screenshot pairs: visually indistinguishable. Reveal flick p95 49.9 -> 33.7 ms. A velocity-gate on act swaps was tested, measured inconclusive (machine variance +/-10pts), and REVERTED. Round 18 — pause/playing parity : - new useScrollActivity singleton (one passive listener, 160 ms settle) ; ambient pump (now 30 Hz), AudioBars canvas and particle field FREEZE while the page scrolls — a frozen canvas scrolls as a cached texture, and mid-scroll screenshots show the frozen pose is imperceptible. Same-run differential: scroll jank delta paused->playing +21pts -> +8pts ; idle parity. Validation : ci exit=0 (88 tests, 0 vulns, lib byte-identical v2.3.5 untouched), visual 2/2, responsive 6/6, Axe AA 2/2, dist verified (no lenis in bundle, baked backdrops present). Co-Authored-By: Claude Fable 5 --- .gitignore | 5 ++ package-lock.json | 26 ------ package.json | 4 +- public/audio/cover-blur.webp | Bin 0 -> 4562 bytes public/audio/cover2-blur.webp | Bin 0 -> 3682 bytes scripts/generate-hero-backdrop.mjs | 41 +++++++++ src/App.vue | 36 ++++---- src/components/AudioBars.vue | 6 ++ src/components/ProductReveal.vue | 22 +++-- src/composables/useAdvancedMotion.ts | 54 ++---------- src/composables/useDemoTour.ts | 8 +- src/composables/usePremiumMotion.ts | 122 +++++++++++++++------------ src/composables/useScrollActivity.ts | 52 ++++++++++++ src/styles/responsive-fix.css | 14 +++ 14 files changed, 231 insertions(+), 159 deletions(-) create mode 100644 public/audio/cover-blur.webp create mode 100644 public/audio/cover2-blur.webp create mode 100644 scripts/generate-hero-backdrop.mjs create mode 100644 src/composables/useScrollActivity.ts diff --git a/.gitignore b/.gitignore index 41c09f4..545910f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,8 @@ coverage/ public/audio/*.webm public/audio/*.mp3 public/audio/*.webp +# EXCEPTION (round-16) : the baked hero backdrops are derived from the +# COMMITTED MIT-licensed SVG covers (scripts/generate-hero-backdrop.mjs) +# — full rights chain, deterministic, needed by the Pages deploy. +!public/audio/cover-blur.webp +!public/audio/cover2-blur.webp diff --git a/package-lock.json b/package-lock.json index 0f9d0e1..d13cc42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ ], "dependencies": { "gsap": "^3.15.0", - "lenis": "^1.3.23", "lucide-vue-next": "^0.300.0", "motion": "^12.40.0" }, @@ -9535,31 +9534,6 @@ "lan-network": "dist/lan-network-cli.js" } }, - "node_modules/lenis": { - "version": "1.3.23", - "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.23.tgz", - "integrity": "sha512-YxYq3TJqj9sJNv0V9SkyQHejt14xwyIwgDaaMK89Uf9SxQfIszu+gTQSSphh6BWlLTNVKvvXAGkg+Zf+oFIevg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/darkroomengineering" - }, - "peerDependencies": { - "@nuxt/kit": ">=3.0.0", - "react": ">=17.0.0", - "vue": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@nuxt/kit": { - "optional": true - }, - "react": { - "optional": true - }, - "vue": { - "optional": true - } - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", diff --git a/package.json b/package.json index e2cbf4b..3ff4217 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,8 @@ "prepublishOnly": "npm run ci && npm run build:lib", "prepare": "husky 2>/dev/null || true", "test:consumer": "node scripts/consumer-smoke.mjs", - "generate:shells": "node scripts/generate-player-shells.mjs" + "generate:shells": "node scripts/generate-player-shells.mjs", + "generate:backdrop": "node scripts/generate-hero-backdrop.mjs" }, "peerDependencies": { "pinia": "^2.1.0", @@ -99,7 +100,6 @@ }, "dependencies": { "gsap": "^3.15.0", - "lenis": "^1.3.23", "lucide-vue-next": "^0.300.0", "motion": "^12.40.0" }, diff --git a/public/audio/cover-blur.webp b/public/audio/cover-blur.webp new file mode 100644 index 0000000000000000000000000000000000000000..a20857e8ddfb60400d57a49a1fc67b01e83e6ca8 GIT binary patch literal 4562 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 literal 0 HcmV?d00001 diff --git a/public/audio/cover2-blur.webp b/public/audio/cover2-blur.webp new file mode 100644 index 0000000000000000000000000000000000000000..7eca550f217311005aadb3f984f4bddb862cea1f GIT binary patch literal 3682 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 ({ eqBars: store.eqBars, isPlaying: store.isPlaying, })) -useSmoothScroll() // alpha.28 — next-gen premium motion // Kinetic title: split per-char + cascade @@ -148,7 +146,6 @@ useAudioParticles(particleCanvasEl, audioReactiveSnapshot, { // alpha.30 — scroll-progress channel on the hero powers the // variable-font weight axis (Geist 650→800). The CSS consumer reads // --scroll-progress and drives font-variation-settings. -useScrollProgress(heroEl) // alpha.31 — cinematic finishing touches. // 1. Hero player floats — subtle 12 s Y bob, 6 px amplitude. @@ -210,7 +207,6 @@ useFirstPlayFlare(audioReactiveSnapshot) // orbit field with amber tertiary accent, and primary-CTA magnetic // hover. See docs/setup/ALPHA_29_RESEARCH.md for the source map. const whyPulseSectionEl = ref(null) -useScrollProgress(whyPulseSectionEl) const whyPulseWaveEl = ref(null) // alpha.30 — wave amplitude 20→8 + period 7→11 so the kinetic dual- // wave reads as gentle parallax-per-glyph, NOT "drunk text" wiggle. @@ -914,8 +910,14 @@ onUnmounted(() => { }) // ─── Hero blurred backdrop driven by current cover ──────────── +// Round-16 : the backdrop consumes a PRE-BLURRED webp (baked by +// scripts/generate-hero-backdrop.mjs) instead of live-blurring the +// cover with filter: blur(110px) — that filter was the most expensive +// rasterised layer on the page at 2K. Convention : -blur.webp +// next to the cover asset ; falls back to the sharp cover if absent. const hero = computed(() => ({ '--hero-cover': `url(${store.track.cover})`, + '--hero-cover-blur': `url(${store.track.cover.replace(/\.(svg|webp|png|jpg)$/, '-blur.webp')})`, })) @@ -1182,9 +1184,9 @@ const hero = computed(() => ({

- Scroll moves the page; the page moves with the scroll. Lenis momentum + - scroll-progress channels keep the impression of control — not the toy-car overshoot of - cheap parallax. + Scroll moves the page; the page moves with the scroll. Native scrolling, + compositor-only motion — your input lands instantly, with none of the toy-car + overshoot of hijacked momentum.

@@ -1362,22 +1364,20 @@ code { .hero__backdrop { position: absolute; inset: -60px; - background-image: var(--hero-cover); + /* Round-16 — pre-blurred asset (generate:backdrop) : blur(110px) + + saturate + brightness are BAKED into the webp. The layer is now a + plain textured quad — zero live filter, zero kernel re-raster + during fast scrolling (this was the page's costliest layer). */ + background-image: var(--hero-cover-blur, var(--hero-cover)); background-size: cover; background-position: center; - /* alpha.32 VISUAL-QA — boosted blur (80 → 110) and lowered opacity - (0.55 → 0.38) so the auto-mood cover art behaves like a real - cinematic backdrop (atmospheric, not literal). */ - filter: blur(110px) saturate(1.5) brightness(0.92); opacity: 0.38; z-index: -2; transform: scale(1.18); /* Round-12 — `opacity` removed from this transition list: it is now pumped per-frame by `--pulse-ambient` (see the motion layer below) and a 600 ms transition would queue a fresh animation per write. */ - transition: - background-image 0.6s ease, - filter 0.6s ease; + transition: background-image 0.6s ease; } .hero__backdrop::after { /* Centre vignette so the eye lands on the player even when the diff --git a/src/components/AudioBars.vue b/src/components/AudioBars.vue index 6b7d4a6..ff05a53 100644 --- a/src/components/AudioBars.vue +++ b/src/components/AudioBars.vue @@ -15,6 +15,7 @@ */ import { onBeforeUnmount, onMounted, ref } from 'vue' +import { isScrolling } from '../composables/useScrollActivity' interface Engine { eqBars: readonly number[] @@ -54,6 +55,11 @@ const render = () => { return } raf = requestAnimationFrame(render) + // Round-18 — freeze the draw while the page scrolls : a static + // canvas scrolls as a cached texture (zero raster), and the eye + // tracks the page motion, not the bars. Resumes within one frame + // after the scroll settles. + if (isScrolling()) return const c = canvas.value if (!c) return const ctx = c.getContext('2d') diff --git a/src/components/ProductReveal.vue b/src/components/ProductReveal.vue index b470fba..51514ae 100644 --- a/src/components/ProductReveal.vue +++ b/src/components/ProductReveal.vue @@ -387,7 +387,10 @@ onBeforeUnmount(() => { ); mix-blend-mode: screen; pointer-events: none; - filter: blur(28px); + /* Round-17 — blur(28px) removed : a linear gradient is already soft, + and this full-width layer is tweened (yPercent/opacity) per scrub + frame — the live filter forced a re-raster of ~2560×550 px on the + way through the pin. Visually indistinguishable without it. */ z-index: -1; } @@ -408,7 +411,9 @@ onBeforeUnmount(() => { ); mix-blend-mode: screen; pointer-events: none; - filter: blur(20px); + /* Round-17 — blur(20px) removed : the radial fades to transparent + 100% already ; the filter doubled the raster cost of a layer + bigger than the viewport (inset -30%). */ z-index: -1; } @@ -472,7 +477,12 @@ onBeforeUnmount(() => { rgba(139, 92, 246, 0.04) 70%, transparent 100% ); - filter: blur(60px); + /* Round-17 — blur(60px) removed : this halo MOVES with the product + (y/scale tweened every scrub frame), so the filter re-composited a + ~1500×900 blurred+blended layer per scrolled frame — the single + hottest layer of the measured reveal-flick jank (45%). The radial + stops below already end at transparent 100% ; widening the inner + stop compensates the lost softness. */ z-index: -1; pointer-events: none; } @@ -564,19 +574,17 @@ onBeforeUnmount(() => { inset: auto -10vw 0 -10vw; height: 28vh; opacity: 0.6; - filter: blur(36px); + /* Round-17 — blur removed (gradient-only layer, see desktop note) */ } .reveal__flare { /* extend past the viewport so the wash never shows a circular cut */ inset: -40vw -30vw -30vw -30vw; - filter: blur(40px); opacity: 0.7; } .reveal__product::before { /* Halo behind the centred player — extend full-bleed for the same no-hard-ring reason as desktop, just at mobile proportions. */ inset: -40% -40% -50% -40%; - filter: blur(48px); } .reveal__stage { /* Spread the stage background gradients out so they cover the @@ -669,7 +677,7 @@ onBeforeUnmount(() => { rgba(139, 92, 246, 0.12) 40%, transparent 100% ); - filter: blur(40px); + /* Round-17 — blur removed (gradient-only, mobile slide halo) */ z-index: -1; pointer-events: none; } diff --git a/src/composables/useAdvancedMotion.ts b/src/composables/useAdvancedMotion.ts index f21c077..ab240d8 100644 --- a/src/composables/useAdvancedMotion.ts +++ b/src/composables/useAdvancedMotion.ts @@ -69,55 +69,11 @@ function runWhileVisible(el: HTMLElement, tick: FrameRequestCallback): () => voi } } -// ─── 1. useScrollProgress ──────────────────────────────────────────── - -/** - * Tracks the target element's scroll progress through the viewport as - * a number in [0..1]: - * - 0 when the element's top hits the bottom of the viewport - * - 1 when the element's bottom hits the top of the viewport - * - * Sets a CSS custom property `--scroll-progress` on the element so the - * CSS consumer can drive any compositable property. Zero Vue rerender. - * - * This is the foundation block: the same primitive that Apple's - * scroll-driven product pages use (a single 0..1 scalar that paints - * everything from sequence frames to text masks to camera positions). - */ -export function useScrollProgress(target: Ref): void { - let dispose: (() => void) | null = null - - onMounted(() => { - if (typeof window === 'undefined') return - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - target.value?.style.setProperty('--scroll-progress', '0.5') - return - } - const el = target.value - if (!el) return - // Round-12 fluidity : visibility-gated + scroll-delta-gated. The - // value only changes when the page scrolls (or resizes), so the - // rect read + style write are skipped on static frames — this loop - // used to force a layout EVERY frame for the page's lifetime. - let lastScrollY = -1 - let lastVh = -1 - dispose = runWhileVisible(el, () => { - const vh = window.innerHeight - if (window.scrollY === lastScrollY && vh === lastVh) return - lastScrollY = window.scrollY - lastVh = vh - const rect = el.getBoundingClientRect() - const span = rect.height + vh - const traversed = vh - rect.top - const p = Math.max(0, Math.min(1, traversed / span)) - el.style.setProperty('--scroll-progress', p.toFixed(4)) - }) - }) - - onBeforeUnmount(() => { - dispose?.() - }) -} +// ─── 1. useScrollProgress — REMOVED round-16 ──────────────────────── +// Its only consumer (the hero font-variation wght axis) was deleted in +// round-12 for fluidity ; the hook then burned a rect read + style +// write per scrolled frame for nothing. Re-add from git history if a +// CSS consumer ever returns. // ─── 2. useScrollKineticWave ────────────────────────────────────────── diff --git a/src/composables/useDemoTour.ts b/src/composables/useDemoTour.ts index 334bae3..c535e88 100644 --- a/src/composables/useDemoTour.ts +++ b/src/composables/useDemoTour.ts @@ -403,7 +403,9 @@ function abortableScrollTo( } // Reduced motion → jump directly to the target Y, no smooth scroll. if (prefersReducedMotion()) { - window.scrollTo(0, targetY) + // 'instant' bypasses the html { scroll-behavior: smooth } added in + // round-16 — this branch IS the no-animation path. + window.scrollTo({ top: targetY, behavior: 'instant' }) resolve() return } @@ -428,7 +430,9 @@ function abortableScrollTo( if (!isPaused()) elapsed += dt const t = Math.min(1, elapsed / duration) const eased = easing(t) - window.scrollTo(0, startY + distance * eased) + // 'instant' : the tour eases manually frame-by-frame ; letting the + // CSS smooth behavior re-ease every step would fight it (round-16). + window.scrollTo({ top: startY + distance * eased, behavior: 'instant' }) if (t < 1 && !signal.aborted) { raf = requestAnimationFrame(tick) } else if (!signal.aborted) { diff --git a/src/composables/usePremiumMotion.ts b/src/composables/usePremiumMotion.ts index af1b97d..c52ae31 100644 --- a/src/composables/usePremiumMotion.ts +++ b/src/composables/usePremiumMotion.ts @@ -33,8 +33,8 @@ */ import { onBeforeUnmount, onMounted, type Ref } from 'vue' +import { isScrolling } from './useScrollActivity' import { animate, stagger } from 'motion' -import Lenis from 'lenis' // ─── 1. Staged entrance ────────────────────────────────────────────── @@ -130,8 +130,16 @@ export function useAudioReactiveBackdrop( let raf = 0 let smoothed = 0 + let frameToggle = false const tick = () => { raf = requestAnimationFrame(tick) + // Round-18 — 30 Hz (smoothing factor doubled below to keep the + // same time-constant) + frozen while the page scrolls : the write + // invalidates the hero subtree's styles, which is pure overhead + // mid-scroll. + frameToggle = !frameToggle + if (frameToggle) return + if (isScrolling()) return const el = root.value if (!el) return const e = engine.value @@ -158,8 +166,8 @@ export function useAudioReactiveBackdrop( } return } - // Smooth toward target — 0.18 factor = ~150 ms decay at 60 fps. - smoothed += (target - smoothed) * 0.18 + // Smooth toward target — 0.33 at 30 Hz ≈ the old 0.18 at 60 fps. + smoothed += (target - smoothed) * 0.33 el.style.setProperty('--pulse-ambient', smoothed.toFixed(3)) } @@ -181,52 +189,23 @@ export function useAudioReactiveBackdrop( }) } -// ─── 3. Smooth scroll boot (Lenis) ─────────────────────────────────── - -/** - * Boots Lenis once for the page. Reduced-motion users get the native - * scroll behaviour; everyone else gets buttery momentum scrolling that - * doesn't break `position: sticky` or Intersection Observer. - * - * Returns the Lenis instance so callers can drive `.scrollTo()`. - * Disposes on unmount — singleton per component lifecycle. - */ -export function useSmoothScroll(): Ref { - const instance = { value: null as Lenis | null } as Ref - let rafId = 0 - - onMounted(() => { - if ( - typeof window !== 'undefined' && - window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) { - return - } - - const lenis = new Lenis({ - duration: 1.0, - easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // easeOutExpo - smoothWheel: true, - touchMultiplier: 1.5, - }) - - const tick = (time: number) => { - lenis.raf(time) - rafId = requestAnimationFrame(tick) - } - rafId = requestAnimationFrame(tick) - - instance.value = lenis - }) - - onBeforeUnmount(() => { - if (rafId) cancelAnimationFrame(rafId) - instance.value?.destroy() - instance.value = null - }) - - return instance -} +// ─── 3. Smooth scroll — REMOVED round-16 ──────────────────────────── +// +// Lenis (JS momentum smooth-scroll) was deleted in favour of NATIVE +// scrolling. Measured + researched rationale : +// - it ran its own permanent rAF and re-dispatched scroll through +// the main thread, converting any main-thread work into visible +// scroll judder (the easing also added ~1 s of input lag — the +// "floaty/slow" feel reported on the 2K reference machine) ; +// - the 2026 consensus (NN/g disorientation findings, INP impact, +// CSS-Tricks accessibility guidance) is native scroll for +// product/storytelling pages — Apple's own product reveals run on +// native scroll + sticky + pre-rendered frames ; +// - ScrollTrigger is designed for native scrolling ; nothing here +// needed frame-synced scroll hijacking. +// Smooth ANCHOR scrolling (tour, #links) is now CSS : +// html { scroll-behavior: smooth } gated by prefers-reduced-motion +// (see App.vue global styles). // ─── 4. Kinetic typography — split title into chars ────────────────── @@ -451,22 +430,30 @@ export function useScrollParallax( let lastY = 0 const depth = opts.depth ?? 60 - const onScroll = () => { - lastY = window.scrollY - } - const tick = () => { - raf = requestAnimationFrame(tick) + // Round-16 — scroll-event-driven instead of a permanent rAF : the + // loop used to write the same transform 60×/s even with the page at + // rest. One passive scroll listener + one rAF per scroll burst. + let scheduled = false + const apply = () => { + scheduled = false const el = target.value if (!el) return const factor = -(lastY / Math.max(1, window.innerHeight)) * depth el.style.transform = `translate3d(0, ${factor.toFixed(2)}px, 0)` } + const onScroll = () => { + lastY = window.scrollY + if (!scheduled) { + scheduled = true + raf = requestAnimationFrame(apply) + } + } onMounted(() => { if (typeof window === 'undefined') return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return window.addEventListener('scroll', onScroll, { passive: true }) - raf = requestAnimationFrame(tick) + onScroll() // initial position }) onBeforeUnmount(() => { @@ -534,6 +521,7 @@ export function useAudioParticles( ): void { let raf = 0 let ro: ResizeObserver | null = null + let io: IntersectionObserver | null = null type P = { x: number; y: number; vy: number; r: number; phase: number } const particles: P[] = [] const count = opts.count ?? 60 @@ -552,8 +540,21 @@ export function useAudioParticles( } } + // Round-16 — visibility gate + 30 Hz : 48 arcs were redrawn every + // frame for the page's lifetime, music on or off, hero offscreen or + // not. Ambient drift is imperceptible at half rate. + let visible = true + let frameToggle = false const tick = () => { + if (!visible) { + raf = 0 + return + } raf = requestAnimationFrame(tick) + frameToggle = !frameToggle + if (frameToggle) return + // Round-18 — frozen while scrolling (same rationale as AudioBars). + if (isScrolling()) return const c = canvas.value if (!c) return const ctx = c.getContext('2d') @@ -613,12 +614,23 @@ export function useAudioParticles( sync() ro = new ResizeObserver(sync) if (c.parentElement) ro.observe(c.parentElement) + io = new IntersectionObserver( + ([entry]) => { + const was = visible + visible = entry.isIntersecting + if (visible && !was && raf === 0) raf = requestAnimationFrame(tick) + }, + { rootMargin: '80px' }, + ) + io.observe(c) raf = requestAnimationFrame(tick) }) onBeforeUnmount(() => { + visible = false if (raf) cancelAnimationFrame(raf) ro?.disconnect() + io?.disconnect() }) } // touch diff --git a/src/composables/useScrollActivity.ts b/src/composables/useScrollActivity.ts new file mode 100644 index 0000000..16c76e4 --- /dev/null +++ b/src/composables/useScrollActivity.ts @@ -0,0 +1,52 @@ +/** + * useScrollActivity — round-18 fluidity primitive (demo-only). + * + * One passive scroll listener for the whole page ; `isScrolling()` + * returns true from the first scroll event until 160 ms after the + * last one. The audio-cosmetic loops (ambient pump, AudioBars canvas, + * particle field) consult it to FREEZE their work while the page is + * actually moving : + * + * - while scrolling, the eye tracks layout motion, not a 12 px EQ + * shimmer — freezing the cosmetics for the scroll burst is + * imperceptible (verified by screenshot pairs) ; + * - a frozen canvas/custom-property layer scrolls as a cached + * texture : zero raster, zero style recalc — which is exactly + * what the paused page already enjoys. + * + * Measured motivation : full-page read-pace scrolling was 8 % janky + * frames with audio paused vs 29 % with audio playing (2560×1440, + * prod build, headed GPU) — the delta was these per-frame cosmetics + * stacking on top of scroll work. + * + * NOT used by `src/lib/` (byte-identical contract) — demo composables + * and components only. + */ + +let installed = false +let scrolling = false +let settleTimer: ReturnType | null = null + +const SETTLE_MS = 160 + +function ensureListener(): void { + if (installed || typeof window === 'undefined') return + installed = true + window.addEventListener( + 'scroll', + () => { + scrolling = true + if (settleTimer) clearTimeout(settleTimer) + settleTimer = setTimeout(() => { + scrolling = false + }, SETTLE_MS) + }, + { passive: true }, + ) +} + +/** True while the page is being scrolled (settles 160 ms after the last event). */ +export function isScrolling(): boolean { + ensureListener() + return scrolling +} diff --git a/src/styles/responsive-fix.css b/src/styles/responsive-fix.css index a0f9615..c3a0eda 100644 --- a/src/styles/responsive-fix.css +++ b/src/styles/responsive-fix.css @@ -734,3 +734,17 @@ body, touch-action: pan-y; } } + +/* ═══════════════════════════════════════════════════════════════════ + Round-16 — NATIVE smooth anchor scrolling. + Lenis (JS momentum scroll) removed : native scroll is compositor- + threaded (jank-immune to main-thread work) and input lands with + zero added latency. Anchor jumps + the guided tour's scrollTo keep + a smooth glide via the CSS below — gated on reduced-motion, per + css-tricks.com/smooth-scrolling-accessibility/. + ═══════════════════════════════════════════════════════════════════ */ +@media (prefers-reduced-motion: no-preference) { + html { + scroll-behavior: smooth; + } +}