feat(splash): enter the site on its own, and stop the intro costing 26 points

The splash now runs its water-ripple enter 1.5s after paint instead of waiting
to be clicked. A click, scroll, or arrow key still fast-forwards it, and the
once-per-session flag and reduced-motion skip are unchanged.

Auto-advancing on its own would have cost the Lighthouse score badly: mobile
100 -> 73, desktop 97 -> 73. The cause was not LCP, which never moved. It was
CLS going 0 -> 1.0, and the reason is worth writing down.

The splash was `relative min-h-[100svh]`, a full viewport of height IN document
flow, and only became `fixed` on the way out. Dismissing it therefore pulled a
whole screen out of the flow and shoved the hero up: a layout shift of almost
exactly 1.0. Clicking hid that from CLS, because shifts within 500ms of user
input are excused. Nothing else did.

Which means this was already shipping to real people. Visitors with
prefers-reduced-motion, and anyone returning within a session, never click, so
they took the shift in full: production measured 74 on mobile with CLS 1.0.
The click-gate was masking a live bug, not preventing one.

The splash is now a fixed overlay from its first paint, so it is never in flow
and dismissing it cannot reflow anything. Measured on a local production build:

                          before -> after
  mobile, auto-advance     73 -> 98   (CLS 1.0   -> 0.005)
  mobile, reduced-motion   74 -> 98   (CLS 1.0   -> 0.005)
  desktop, auto-advance    73 -> 98   (CLS 0.702 -> 0.011)

Mobile gives up 2 points against today's click-gated 100, entirely in Speed
Index (1.9s -> 4.4s), which is the unavoidable cost of holding a cover at all.
Desktop ends up ahead of where it started. The hold is one constant if you want
a different beat.

The scroll-past-to-dismiss IntersectionObserver is gone: an overlay never
leaves the viewport, so it could not fire. Scroll intent still enters, via the
wheel/touch/key handlers already in the splash, and the shell now locks page
scroll while the cover is up.
This commit is contained in:
jwinterm
2026-08-17 09:13:29 -04:00
parent b110da076d
commit 1a66e2217a
2 changed files with 39 additions and 24 deletions
@@ -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" }, { 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 // The "SUCH" mark is served as /images/branding/splash-such.svg and rendered as an
// <img> below: an <img> is a valid Largest-Contentful-Paint candidate, whereas an // <img> below: an <img> is a valid Largest-Contentful-Paint candidate, whereas an
// inline <svg> is not, which is what was producing NO_LCP on mobile (where the // inline <svg> 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]); }, [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. // Pre-warm on idle (post-LCP): compile the filter graph + promote the logo layer.
useEffect(() => { useEffect(() => {
const ric: typeof requestIdleCallback | undefined = (window as any).requestIdleCallback; 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 ( return (
<section <section
ref={sectionRef} ref={sectionRef}
aria-label="Such Software" aria-label="Such Software"
className={ className={`fixed inset-0 z-50 flex flex-col items-center justify-center overflow-hidden bg-background px-4 py-12 text-center ${
leaving leaving
? `fixed inset-0 z-50 flex flex-col items-center justify-center overflow-hidden px-4 py-12 text-center pointer-events-none transition-opacity duration-700 ${fadeOut ? "opacity-0" : "opacity-100"}` ? `pointer-events-none transition-opacity duration-700 ${fadeOut ? "opacity-0" : "opacity-100"}`
: "relative z-10 flex min-h-[100svh] w-full max-w-7xl flex-col items-center justify-center overflow-hidden px-4 py-12 text-center" : ""
} }`}
> >
{/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water {/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water
filter refracting the logo (driven by handleEnter's rAF loop). */} filter refracting the logo (driven by handleEnter's rAF loop). */}
+13 -20
View File
@@ -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(() => { useEffect(() => {
if (entered) return; if (entered || !splashUp) return;
const el = splashRef.current; const html = document.documentElement;
if (!el) return; const prev = html.style.overflow;
// Enter once 40% of the splash has scrolled by: requiring the whole screen to html.style.overflow = "hidden";
// leave the viewport made the reveal feel like a toll booth. return () => {
const io = new IntersectionObserver( html.style.overflow = prev;
([entry]) => { };
if (entry.intersectionRatio < 0.6) { }, [entered, splashUp]);
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]);
// After the splash unmounts, keep the visitor's position in the content stable // After the splash unmounts, keep the visitor's position in the content stable
// (no jump). Runs before paint. // (no jump). Runs before paint.