From 62fee8b83af6248129cf2b656138f9dcfbed884f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 11 Jul 2026 10:45:50 -0400 Subject: [PATCH] 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. --- apps/web/src/app/page.tsx | 8 ++- .../src/components/landing/hero-splash.tsx | 72 ++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 43e31c8..6401c01 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -39,8 +39,8 @@ export default function Home() { - - + {/* 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.) */}

Selected Works

@@ -51,6 +51,10 @@ export default function Home() { + + + +

Inclusive by Design

diff --git a/apps/web/src/components/landing/hero-splash.tsx b/apps/web/src/components/landing/hero-splash.tsx index ecc1451..160150e 100644 --- a/apps/web/src/components/landing/hero-splash.tsx +++ b/apps/web/src/components/landing/hero-splash.tsx @@ -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(null); const turbRef = useRef(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 (
vo + {/* 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 && ( +