diff --git a/app/components/background/PageBackground.tsx b/app/components/background/PageBackground.tsx index ca9e80d..fcb43a3 100644 --- a/app/components/background/PageBackground.tsx +++ b/app/components/background/PageBackground.tsx @@ -2,22 +2,34 @@ import { useRef } from "react"; import gsap from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useGSAP } from "@gsap/react"; import { useIsMobile } from "@/app/hooks/useIsMobile"; import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; import { - HERO_SCENE_DATA_ATTR, - MISSION_STATEMENT_DATA_ATTR, - PAGE_BG_DARKEN, - PAGE_BG_SPONSORS_LIGHTEN, - PAGE_BG_MOBILE_WHITEOUT_SCRUB, - PAGE_BG_WHITEOUT, - SPONSORS_SECTION_DATA_ATTR, + dispatchNavbarThemeOverride, + type NavbarTheme, +} from "../navbar/navbarThemeOverride"; +import { + NAVBAR_LIGHT_THRESHOLD, + PAGE_BG_PHASES, + PAGE_BG_SMOOTHING, + PAGE_BG_SNAP_DELTA, } from "./sceneConfig"; configureScrollTrigger(); +/** + * Owns the page background and the navbar light/dark theme. + * + * The light layer's opacity is a pure function of scroll position: each + * phase gets a plain ScrollTrigger (no tween) and on every update the last + * phase in page order with progress > 0 determines the value. A single + * quickTo writer applies it with a short catch-up for the scrub feel, so + * there are never competing tweens fighting over the same property and the + * background can't get stuck in the wrong state after a fast scroll. + */ export default function PageBackground() { const lightLayerRef = useRef(null); const isMobile = useIsMobile(); @@ -30,71 +42,83 @@ export default function PageBackground() { return; } + gsap.set(lightLayer, { opacity: 0 }); + if (prefersReducedMotion) { - gsap.set(lightLayer, { opacity: 0 }); + // Reduced-motion fallbacks paint their own section backgrounds and + // declare data-navbar-theme, which useNavbarTheme observes directly. return; } - gsap.set(lightLayer, { opacity: 0 }); + const smoothing = isMobile + ? PAGE_BG_SMOOTHING.mobile + : PAGE_BG_SMOOTHING.desktop; + const setOpacity = gsap.quickTo(lightLayer, "opacity", { + duration: smoothing, + ease: "none", + }); + + let navbarTheme: NavbarTheme | null = null; + + const phases: { + from: number; + to: number; + easeFn: gsap.EaseFunction; + trigger: ScrollTrigger; + }[] = []; - const heroScene = document.querySelector( - `[${HERO_SCENE_DATA_ATTR}]`, - ); - const missionStatement = document.querySelector( - `[${MISSION_STATEMENT_DATA_ATTR}]`, - ); - const sponsorsSection = document.querySelector( - `[${SPONSORS_SECTION_DATA_ATTR}]`, - ); - - const whiteoutScrub = isMobile - ? PAGE_BG_MOBILE_WHITEOUT_SCRUB - : PAGE_BG_WHITEOUT.scrub; - - if (heroScene) { - gsap.to(lightLayer, { - opacity: 1, - ease: PAGE_BG_WHITEOUT.ease, - scrollTrigger: { - trigger: heroScene, - start: PAGE_BG_WHITEOUT.start, - end: PAGE_BG_WHITEOUT.end, - scrub: whiteoutScrub, - }, + for (const phase of PAGE_BG_PHASES) { + const el = document.querySelector(`[${phase.attr}]`); + if (!el) { + continue; + } + + phases.push({ + from: phase.from, + to: phase.to, + easeFn: gsap.parseEase(phase.ease), + trigger: ScrollTrigger.create({ + trigger: el, + start: phase.start, + end: phase.end, + onUpdate: () => update(), + onRefresh: () => update(), + }), }); } - if (missionStatement) { - gsap.fromTo( - lightLayer, - { opacity: 1 }, - { - opacity: 0, - ease: PAGE_BG_DARKEN.ease, - immediateRender: false, - scrollTrigger: { - trigger: missionStatement, - start: PAGE_BG_DARKEN.start, - end: PAGE_BG_DARKEN.end, - scrub: PAGE_BG_DARKEN.scrub, - }, - }, - ); - } + function update() { + let value = 0; + for (const phase of phases) { + const { progress } = phase.trigger; + if (progress > 0) { + value = gsap.utils.interpolate( + phase.from, + phase.to, + phase.easeFn(progress), + ); + } + } - if (sponsorsSection) { - gsap.to(lightLayer, { - opacity: 1, - ease: PAGE_BG_SPONSORS_LIGHTEN.ease, - immediateRender: false, - scrollTrigger: { - trigger: sponsorsSection, - start: PAGE_BG_SPONSORS_LIGHTEN.start, - end: PAGE_BG_SPONSORS_LIGHTEN.end, - scrub: PAGE_BG_SPONSORS_LIGHTEN.scrub, - }, - }); + const current = Number(gsap.getProperty(lightLayer, "opacity")); + if (Math.abs(value - current) >= PAGE_BG_SNAP_DELTA) { + gsap.set(lightLayer, { opacity: value }); + } + setOpacity(value); + + const nextTheme: NavbarTheme = + value >= NAVBAR_LIGHT_THRESHOLD ? "light" : "dark"; + if (nextTheme !== navbarTheme) { + navbarTheme = nextTheme; + dispatchNavbarThemeOverride(nextTheme); + } } + + update(); + + return () => { + dispatchNavbarThemeOverride(null); + }; }, { dependencies: [isMobile, prefersReducedMotion], revertOnUpdate: true }, ); diff --git a/app/components/background/sceneConfig.ts b/app/components/background/sceneConfig.ts index 9ba1266..539ed7a 100644 --- a/app/components/background/sceneConfig.ts +++ b/app/components/background/sceneConfig.ts @@ -1,39 +1,81 @@ /** * Page-level background crossfade config. * - * A single fixed background element is painted once and its light layer's - * opacity is driven by scrubbed tweens: - * - WHITEOUT: dark → light, scoped to the Hero section - * - DARKEN: light → dark, scoped to the Mission statement section - * - SPONSORS_LIGHTEN: dark → light, scoped to the Timeline/Sponsors handoff + * A single fixed background element is painted once. Its light layer's + * opacity is computed deterministically from the scroll position by + * PageBackground: the phases below are evaluated in page order and the + * last phase with progress > 0 owns the value. This guarantees the + * background always matches the scroll position, no matter how fast the + * user scrolls in either direction. * - * Timings match what Hero and Mission previously controlled locally, so the - * visual rhythm is unchanged — only the owning DOM node is unified. + * The navbar light/dark theme is derived from the same value, so the + * background and navbar can never disagree. */ -export const PAGE_BG_WHITEOUT = { - start: "70% bottom", - end: "bottom bottom", - ease: "power2.in", - scrub: 0.2, -} as const; +export const HERO_SCENE_DATA_ATTR = "data-bg-hero-scene"; +export const MISSION_STATEMENT_DATA_ATTR = "data-bg-mission-statement"; +export const TIMELINE_SECTION_DATA_ATTR = "data-bg-timeline-section"; -export const PAGE_BG_DARKEN = { - start: "center top", - end: "bottom top", - ease: "power1.in", - scrub: 1, -} as const; +export type PageBgPhase = { + /** Data attribute identifying the section that drives this phase */ + attr: string; + /** Light-layer opacity at phase progress 0 */ + from: number; + /** Light-layer opacity at phase progress 1 */ + to: number; + start: string; + end: string; + ease: string; +}; -export const PAGE_BG_SPONSORS_LIGHTEN = { - start: "top bottom", - end: "top center", +/** + * Dark → light before the Sponsors section arrives. Completing the + * crossfade while the Timeline still fills the viewport (rather than + * while the opaque Sponsors edge is already visible) is what prevents + * the hard seam line at the section boundary. + */ +export const TIMELINE_LIGHTEN_PHASE = { + attr: TIMELINE_SECTION_DATA_ATTR, + from: 0, + to: 1, + start: "72% bottom", + end: "96% bottom", ease: "power1.out", - scrub: 1, +} as const satisfies PageBgPhase; + +/** Phases in page order: hero whiteout → mission darken → timeline lighten */ +export const PAGE_BG_PHASES: readonly PageBgPhase[] = [ + { + attr: HERO_SCENE_DATA_ATTR, + from: 0, + to: 1, + start: "70% bottom", + end: "bottom bottom", + ease: "power2.in", + }, + { + attr: MISSION_STATEMENT_DATA_ATTR, + from: 1, + to: 0, + start: "center top", + end: "bottom top", + ease: "power1.in", + }, + TIMELINE_LIGHTEN_PHASE, +] as const; + +/** Seconds for the light layer to catch up to the computed value (scrub feel) */ +export const PAGE_BG_SMOOTHING = { + desktop: 0.2, + mobile: 0.6, } as const; -export const PAGE_BG_MOBILE_WHITEOUT_SCRUB = 0.6; +/** + * When a scroll jump moves the computed value by more than this in one + * update (e.g. Home/End or a fast fling), the layer snaps instead of + * easing so an opaque section edge is never crossed mid-catch-up. + */ +export const PAGE_BG_SNAP_DELTA = 0.35; -export const HERO_SCENE_DATA_ATTR = "data-bg-hero-scene"; -export const MISSION_STATEMENT_DATA_ATTR = "data-bg-mission-statement"; -export const SPONSORS_SECTION_DATA_ATTR = "data-bg-sponsors-section"; +/** Light-layer opacity at which the navbar switches to its light theme */ +export const NAVBAR_LIGHT_THRESHOLD = 0.5; diff --git a/app/components/footer/FooterReveal.tsx b/app/components/footer/FooterReveal.tsx index fb2f6e0..8dae25b 100644 --- a/app/components/footer/FooterReveal.tsx +++ b/app/components/footer/FooterReveal.tsx @@ -81,14 +81,25 @@ export default function FooterReveal() { onLeaveBack: () => updateFooterState(false, false), }); - const resizeObserver = new ResizeObserver(() => { + let lastFooterHeight = footer.offsetHeight; + const debouncedRefresh = gsap.delayedCall(0.2, () => { syncSpacerHeight(); ScrollTrigger.refresh(); }); + debouncedRefresh.pause(); + + const resizeObserver = new ResizeObserver(() => { + if (footer.offsetHeight === lastFooterHeight) { + return; + } + lastFooterHeight = footer.offsetHeight; + debouncedRefresh.restart(true); + }); resizeObserver.observe(footer); return () => { resizeObserver.disconnect(); + debouncedRefresh.kill(); trigger.kill(); }; }, diff --git a/app/components/hero/CometTrailBackground.tsx b/app/components/hero/CometTrailBackground.tsx index 628964d..7be0a22 100644 --- a/app/components/hero/CometTrailBackground.tsx +++ b/app/components/hero/CometTrailBackground.tsx @@ -1,13 +1,12 @@ "use client"; -import type { ComponentProps } from "react"; import { useRef } from "react"; -import { ShaderGradient, ShaderGradientCanvas } from "@shadergradient/react"; import gsap from "gsap"; import { useGSAP } from "@gsap/react"; import { useIsMobile } from "@/app/hooks/useIsMobile"; import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; +import BrandShaderBackground from "../background/BrandShaderBackground"; import { COMET_TUNING, HERO_SCENE_SCROLL, @@ -22,63 +21,6 @@ import { configureScrollTrigger(); -type ShaderGradientProps = ComponentProps & { - axesHelper?: string; - bgColor1?: string; - bgColor2?: string; - destination?: string; - embedMode?: string; - fov?: number; - format?: string; - frameRate?: number; - gizmoHelper?: string; - pixelDensity?: number; -}; - -const shaderGradientProps: ShaderGradientProps = { - animate: "on", - axesHelper: "off", - bgColor1: "#000000", - bgColor2: "#000000", - brightness: 1.5, - cAzimuthAngle: 110, - cDistance: 7.1, - cPolarAngle: 104, - cameraZoom: 10.5, - color1: "#6C17FE", - color3: "#FFA21F", - color2: "#F31667", - destination: "onCanvas", - embedMode: "off", - envPreset: "dawn", - format: "gif", - fov: 45, - frameRate: 10, - gizmoHelper: "hide", - grain: "off", - lightType: "3d", - pixelDensity: 1, - positionX: 0, - positionY: -0.05, - positionZ: 0, - range: "disabled", - rangeEnd: 40, - rangeStart: 0, - reflection: 0.1, - rotationX: 28, - rotationY: -18, - rotationZ: -32, - shader: "defaults", - type: "sphere", - uAmplitude: 2.2, - uDensity: 1.7, - uFrequency: 5.5, - uSpeed: 0.18, - uStrength: 0.85, - uTime: 0, - wireframe: false, -}; - export default function CometTrailBackground() { const wrapperRef = useRef(null); const spineRef = useRef(null); @@ -201,15 +143,7 @@ export default function CometTrailBackground() { className="h-full w-full" style={{ height: "100%", width: "100%" }} > - - - + diff --git a/app/components/hero/Hero.tsx b/app/components/hero/Hero.tsx index 2a2e5ff..ceb9021 100644 --- a/app/components/hero/Hero.tsx +++ b/app/components/hero/Hero.tsx @@ -3,21 +3,18 @@ import type { CSSProperties } from "react"; import { useRef } from "react"; import gsap from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useGSAP } from "@gsap/react"; import Image from "next/image"; import { useIsMobile } from "@/app/hooks/useIsMobile"; import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; import AccentButton from "../ui/AccentButton"; -import { dispatchNavbarThemeOverride } from "../navbar/navbarThemeOverride"; import { HERO_SCENE_DATA_ATTR } from "../background/sceneConfig"; import CometAnimation from "./CometAnimation"; import { HERO_COPY, HERO_COMET_SHADER, HERO_LAYOUT, - HERO_NAVBAR_THEME_TRIGGER, HERO_SCENE_SCROLL, HERO_SKYLINE_PARALLAX, HERO_STARS, @@ -30,6 +27,7 @@ configureScrollTrigger(); export default function Hero() { const sectionRef = useRef(null); + const backdropRef = useRef(null); const starsLayerRef = useRef(null); const cometBackgroundLayerRef = useRef(null); const skylineLayerRef = useRef(null); @@ -97,6 +95,7 @@ export default function Hero() { } const sceneFadeTargets = [ + backdropRef.current, skylineLayer, starsLayer, cometBackgroundLayer, @@ -128,32 +127,6 @@ export default function Hero() { }, }); } - - let navbarThemeOverride: "light" | "dark" | null = null; - const setNavbarThemeOverride = (theme: "light" | "dark" | null) => { - if (navbarThemeOverride === theme) { - return; - } - - navbarThemeOverride = theme; - dispatchNavbarThemeOverride(theme); - }; - - const navbarThemeTrigger = ScrollTrigger.create({ - trigger: section, - start: HERO_NAVBAR_THEME_TRIGGER.start, - end: HERO_NAVBAR_THEME_TRIGGER.end, - onEnter: () => setNavbarThemeOverride(HERO_NAVBAR_THEME_TRIGGER.theme), - onEnterBack: () => - setNavbarThemeOverride(HERO_NAVBAR_THEME_TRIGGER.theme), - onLeave: () => setNavbarThemeOverride(null), - onLeaveBack: () => setNavbarThemeOverride(null), - }); - - return () => { - navbarThemeTrigger.kill(); - setNavbarThemeOverride(null); - }; }, { scope: sectionRef, dependencies: [isMobile, prefersReducedMotion] }, ); @@ -162,11 +135,18 @@ export default function Hero() {
+ {/* Opaque dark backdrop so the hero art direction stays dark in the + light site theme; it fades out with the rest of the scene. */} +