home: bento above What We Do; splash enters on scroll intent; pre-warm the water filter

The page after the splash was two screens of text before the first image. Selected
Works (the bento, with the live barns vignette) now leads and the services copy
supports it below, wrapped in the same scroll-reveal.

Splash: the first wheel tick, 24px touch drag, or scroll key now plays the SAME
water-ripple enter as a tap (scrolling past used to hard-cut after a full screen).
Listeners are passive, the entering guard dedupes, reduced-motion still gets the
instant dismiss.

Pre-warm: on requestIdleCallback (post-LCP, invisible to Lighthouse) a 1px element
paints through the splash-water filter so the graph compiles before first use (the
first-tap stutter), and the logo layer gets will-change ahead of the zoom.

Verified with Playwright against the production build: wheel/key/touch all enter,
session flag set, bento renders above What We Do.
This commit is contained in:
jwinterm
2026-07-11 10:45:50 -04:00
parent 679e1befdf
commit 62fee8b83a
2 changed files with 77 additions and 3 deletions
+6 -2
View File
@@ -39,8 +39,8 @@ export default function Home() {
</Link>
</div>
<ServicesPreview />
{/* Show before tell: the visual work leads, the services copy supports it below.
(The old order opened with two screens of text before the first image.) */}
<ScrollReveal className="w-full max-w-6xl mx-auto mb-16 px-4">
<div className="text-left mb-8 section-container border-l-4 border-l-blue-500">
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Selected Works</h2>
@@ -51,6 +51,10 @@ export default function Home() {
<PortfolioGrid />
</ScrollReveal>
<ScrollReveal className="w-full">
<ServicesPreview />
</ScrollReveal>
<ScrollReveal className="w-full max-w-6xl mx-auto mb-20 px-4">
<div className="text-left mb-12 section-container border-l-4 border-l-purple-500">
<h2 className="text-3xl font-bold mb-3 tracking-tight text-foreground">Inclusive by Design</h2>
@@ -20,8 +20,16 @@ const navButtons = [
export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => void; sectionRef?: any; leaving?: boolean }) {
const [entering, setEntering] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
// warmed: set on idle, well after LCP. It (a) paints a 1px element through the water
// filter so the browser compiles the filter graph BEFORE the first real use (the
// first-click stutter was the graph compiling mid-transition), and (b) turns on
// will-change for the logo so its layer is promoted ahead of the zoom. Doing this
// on requestIdleCallback keeps it invisible to Lighthouse.
const [warmed, setWarmed] = useState(false);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
const turbRef = useRef<SVGFETurbulenceElement>(null);
// Latest-handler ref so the window listeners below never hold a stale closure.
const enterRef = useRef<() => void>(() => {});
// When the page asks the splash to leave, it first becomes a fixed overlay; one
// frame later we drop opacity so it cross-fades into the revealed content.
@@ -59,6 +67,55 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
};
requestAnimationFrame(tick);
};
enterRef.current = handleEnter;
// Scroll intent = enter. Scrolling PAST the splash already dismissed it (the shell's
// IntersectionObserver), but only after a full screen of scrolling and with a hard
// cut. First wheel tick / touch drag / scroll key now plays the SAME water-ripple
// enter as a tap. Listeners are passive (never block the scroll) and the `entering`
// guard inside handleEnter dedupes against the button path.
useEffect(() => {
if (leaving) return;
let touchY: number | null = null;
const onWheel = (e: WheelEvent) => {
if (e.deltaY > 8) enterRef.current();
};
const onTouchStart = (e: TouchEvent) => {
touchY = e.touches[0]?.clientY ?? null;
};
const onTouchMove = (e: TouchEvent) => {
if (touchY == null) return;
const y = e.touches[0]?.clientY ?? touchY;
if (touchY - y > 24) { // a deliberate upward drag (scroll down), not a resting thumb
touchY = null;
enterRef.current();
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "ArrowDown" || e.key === "PageDown" || e.key === " ") enterRef.current();
};
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("keydown", onKey);
};
}, [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;
const id = ric ? ric(() => setWarmed(true)) : window.setTimeout(() => setWarmed(true), 1500);
return () => {
const cic: typeof cancelIdleCallback | undefined = (window as any).cancelIdleCallback;
if (ric && cic) cic(id as number);
else window.clearTimeout(id as number);
};
}, []);
return (
<section
@@ -88,6 +145,16 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
</filter>
</svg>
{/* Filter pre-warm: a near-invisible 1px paint through the filter (opacity must be
non-zero or the paint is skipped and nothing compiles). Idle-gated via `warmed`. */}
{warmed && !entering && (
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-px w-px opacity-[0.01]"
style={{ filter: "url(#splash-water)" }}
/>
)}
{/* Logo (click to enter) + pulsing Cherenkov glow */}
<button
type="button"
@@ -104,7 +171,10 @@ export function HeroSplash({ onEnter, sectionRef, leaving }: { onEnter: () => vo
statically-painted image. The water filter + zoom only apply on enter. */}
<div
className={entering ? "splash-zoom" : undefined}
style={{ filter: entering ? "url(#splash-water)" : undefined }}
style={{
filter: entering ? "url(#splash-water)" : undefined,
willChange: warmed && !entering ? "transform, filter, opacity" : undefined,
}}
>
{/* Data URI (no network request) so the logo paints with FCP; it is the
mobile LCP element. */}