diff --git a/apps/web/src/components/landing/hero-splash.tsx b/apps/web/src/components/landing/hero-splash.tsx index 4657afa..393c39d 100644 --- a/apps/web/src/components/landing/hero-splash.tsx +++ b/apps/web/src/components/landing/hero-splash.tsx @@ -12,6 +12,9 @@ const navButtons = [ { label: "About", href: "/about", Icon: Info, corner: "right-4 bottom-6 sm:right-6 sm:bottom-24 lg:right-8 lg:bottom-32" }, ]; +// How long the splash holds before it enters the site on its own. +const SPLASH_AUTO_ENTER_MS = 1500; + // The "SUCH" mark is served as /images/branding/splash-such.svg and rendered as an // below: an is a valid Largest-Contentful-Paint candidate, whereas an // inline is not, which is what was producing NO_LCP on mobile (where the @@ -106,6 +109,17 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo }; }, [leaving]); + // Auto-advance: the splash is an intro, not a gate. After a beat it runs the same + // water-ripple enter the visitor would have triggered themselves; a click, scroll, + // or arrow key still fast-forwards it, and handleEnter's `entering` guard dedupes + // the two paths. Reduced-motion visitors never get here (the shell skips the splash + // outright), and the once-per-session flag is unchanged. + useEffect(() => { + if (leaving) return; + const id = window.setTimeout(() => enterRef.current(), SPLASH_AUTO_ENTER_MS); + return () => window.clearTimeout(id); + }, [leaving]); + // Pre-warm on idle (post-LCP): compile the filter graph + promote the logo layer. useEffect(() => { const ric: typeof requestIdleCallback | undefined = (window as any).requestIdleCallback; @@ -117,15 +131,23 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo }; }, []); + // The splash is a FIXED overlay from its very first paint, never a block in document + // flow. It used to be `relative min-h-[100svh]` and only became fixed on the way out, + // which meant removing it pulled a whole viewport of height out of the flow and shoved + // the hero up: a layout shift of almost exactly 1.0. A click hid that from CLS (shifts + // within 500ms of input are excused), but reduced-motion and returning-session + // visitors, who never click, ate it in full and scored 74. As an overlay it is out of + // flow already, so dismissing it cannot reflow anything, and the auto-advance costs no + // CLS either. return (
{/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water filter refracting the logo (driven by handleEnter's rAF loop). */} diff --git a/apps/web/src/components/landing/home-shell.tsx b/apps/web/src/components/landing/home-shell.tsx index 8058644..8447f9b 100644 --- a/apps/web/src/components/landing/home-shell.tsx +++ b/apps/web/src/components/landing/home-shell.tsx @@ -44,27 +44,20 @@ export function HomeShell({ children }: { children: ReactNode }) { } }, []); - // Dismiss the splash once it has fully scrolled out of view. + // The splash is a fixed overlay now, so the hero is already in flow beneath it and + // would otherwise scroll around behind the cover. Freeze the page while it is up. + // (The previous scroll-past-to-dismiss IntersectionObserver is gone with it: an + // overlay never leaves the viewport, so it could never have fired. Scroll intent + // still enters the site, via the wheel/touch/key handlers inside the splash.) useEffect(() => { - if (entered) return; - const el = splashRef.current; - if (!el) return; - // Enter once 40% of the splash has scrolled by: requiring the whole screen to - // leave the viewport made the reveal feel like a toll booth. - const io = new IntersectionObserver( - ([entry]) => { - if (entry.intersectionRatio < 0.6) { - restoreScroll.current = Math.max(0, window.scrollY - el.offsetHeight); - sessionStorage.setItem("such:entered", "1"); - setEntered(true); - setSplashUp(false); // scrolled past: just unmount, no overlay fade needed - } - }, - { threshold: [0, 0.6] }, - ); - io.observe(el); - return () => io.disconnect(); - }, [entered]); + if (entered || !splashUp) return; + const html = document.documentElement; + const prev = html.style.overflow; + html.style.overflow = "hidden"; + return () => { + html.style.overflow = prev; + }; + }, [entered, splashUp]); // After the splash unmounts, keep the visitor's position in the content stable // (no jump). Runs before paint.