"use client"; import Link from "next/link"; import { ChevronDown, LayoutGrid, Smartphone, Wrench, Info } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { SPLASH_LOGO_SRC } from "./splash-logo-data"; const navButtons = [ { label: "Portfolio", href: "/products", Icon: LayoutGrid, corner: "left-4 top-6 sm:left-6 sm:top-24 lg:left-8 lg:top-32" }, { label: "Apps", href: "/apps", Icon: Smartphone, corner: "right-4 top-6 sm:right-6 sm:top-24 lg:right-8 lg:top-32" }, { label: "Services", href: "/services", Icon: Wrench, corner: "left-4 bottom-6 sm:left-6 sm:bottom-24 lg:left-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" }, ]; // 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 // tagline text is hidden, leaving the logo as the only large element). 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. useEffect(() => { if (!leaving) return; const id = requestAnimationFrame(() => setFadeOut(true)); return () => cancelAnimationFrame(id); }, [leaving]); const handleEnter = () => { if (entering) return; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { onEnter(); return; } setEntering(true); // Begin the cross-fade immediately so the water shimmer plays ON the splash as // it fades into the revealed content (snappier and more blended than shimmer- // then-cut). onEnter(); // Water refraction: ramp the displacement scale up and back down while the // turbulence drifts, so the logo ripples like a disturbed liquid surface. const DURATION = 800; const MAX_SCALE = 40; const start = performance.now(); const tick = (now: number) => { const t = Math.min(1, (now - start) / DURATION); const env = Math.sin(Math.PI * t); // 0 -> 1 -> 0 if (dispRef.current) dispRef.current.setAttribute("scale", String(env * MAX_SCALE)); if (turbRef.current) { const f = 0.008 + t * 0.02; turbRef.current.setAttribute("baseFrequency", `${f.toFixed(4)} ${(f * 1.3).toFixed(4)}`); } if (t < 1) requestAnimationFrame(tick); }; 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 (
{/* Click-to-enter: soft Cherenkov wash. The ripple itself is the SVG water filter refracting the logo (driven by handleEnter's rAF loop). */} {entering && (