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.