Merge feat/splash-auto-advance: self-entering splash, overlay fixes live CLS 1.0

This commit is contained in:
jwinterm
2026-08-17 09:13:34 -04:00
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" },
];
// 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
// <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
@@ -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 (
<section
ref={sectionRef}
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
? `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"}`
: "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"
}
? `pointer-events-none transition-opacity duration-700 ${fadeOut ? "opacity-0" : "opacity-100"}`
: ""
}`}
>
{/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water
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(() => {
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.