From f72e70caf2f951704491dbfdfdda63101a0fb8f7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:17:46 +0000 Subject: [PATCH 1/2] refactor: centralize page background crossfade and navbar theme coordination Co-Authored-By: Caleb Bae --- app/components/background/PageBackground.tsx | 143 +++++++++++-------- app/components/background/sceneConfig.ts | 91 ++++++++---- app/components/footer/FooterReveal.tsx | 13 +- app/components/hero/CometTrailBackground.tsx | 70 +-------- app/components/hero/Hero.tsx | 29 ---- app/components/hero/sceneConfig.ts | 6 - app/components/mission/Mission.tsx | 35 ----- app/components/mission/sceneConfig.ts | 6 - app/components/navbar/navbarThemeOverride.ts | 8 ++ app/components/navbar/useNavbarTheme.ts | 40 +++--- app/components/projects/Projects.tsx | 4 +- app/components/sponsors/Sponsors.tsx | 20 +-- app/components/timeline/Timeline.tsx | 42 +++++- app/components/timeline/sceneConfig.ts | 7 + 14 files changed, 237 insertions(+), 277 deletions(-) diff --git a/app/components/background/PageBackground.tsx b/app/components/background/PageBackground.tsx index ca9e80d..65ed1f9 100644 --- a/app/components/background/PageBackground.tsx +++ b/app/components/background/PageBackground.tsx @@ -2,22 +2,33 @@ 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, } 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 +41,79 @@ 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, - }, - }); + 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..8824e87 100644 --- a/app/components/background/sceneConfig.ts +++ b/app/components/background/sceneConfig.ts @@ -1,39 +1,74 @@ /** * 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; +} as const satisfies PageBgPhase; -export const PAGE_BG_MOBILE_WHITEOUT_SCRUB = 0.6; +/** 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; -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"; +/** 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; + +/** 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..e03b2cd 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, @@ -128,32 +125,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] }, ); diff --git a/app/components/hero/sceneConfig.ts b/app/components/hero/sceneConfig.ts index ca2c181..5e710a9 100644 --- a/app/components/hero/sceneConfig.ts +++ b/app/components/hero/sceneConfig.ts @@ -65,12 +65,6 @@ export const HERO_SKYLINE_PARALLAX = { ease: "none", } as const; -export const HERO_NAVBAR_THEME_TRIGGER = { - start: "84% bottom", - end: "bottom top", - theme: "light", -} as const; - export const COMET_TUNING = { spine: ` M 735,870 diff --git a/app/components/mission/Mission.tsx b/app/components/mission/Mission.tsx index e96cbab..3dcde19 100644 --- a/app/components/mission/Mission.tsx +++ b/app/components/mission/Mission.tsx @@ -2,15 +2,12 @@ import { useRef } from "react"; import gsap from "gsap"; -import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useGSAP } from "@gsap/react"; import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; import { missionContent } from "@/app/data/mission"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; import { MISSION_STATEMENT_DATA_ATTR } from "../background/sceneConfig"; -import { dispatchNavbarThemeOverride } from "../navbar/navbarThemeOverride"; import { - DIRECTORS_NAVBAR_THEME_TRIGGER, DIRECTORS_PIN, MISSION_DECORATION_COUNT, MISSION_LAYOUT, @@ -90,37 +87,6 @@ export default function Mission() { scrub: DIRECTORS_PIN.scrub, }, }); - - // Navbar theme — switch to dark when directors section enters - 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: directorsSection, - start: DIRECTORS_NAVBAR_THEME_TRIGGER.start, - end: DIRECTORS_NAVBAR_THEME_TRIGGER.end, - onEnter: () => - setNavbarThemeOverride( - DIRECTORS_NAVBAR_THEME_TRIGGER.theme as "dark", - ), - onEnterBack: () => - setNavbarThemeOverride( - DIRECTORS_NAVBAR_THEME_TRIGGER.theme as "dark", - ), - onLeave: () => setNavbarThemeOverride(null), - onLeaveBack: () => setNavbarThemeOverride(null), - }); - - return () => { - navbarThemeTrigger.kill(); - setNavbarThemeOverride(null); - }; }, { dependencies: [prefersReducedMotion], @@ -164,7 +130,6 @@ export default function Mission() { sections. */}
diff --git a/app/components/mission/sceneConfig.ts b/app/components/mission/sceneConfig.ts index 8f7891a..bdf9fab 100644 --- a/app/components/mission/sceneConfig.ts +++ b/app/components/mission/sceneConfig.ts @@ -17,10 +17,4 @@ export const DIRECTORS_PIN = { initialYPercent: 12, } as const; -export const DIRECTORS_NAVBAR_THEME_TRIGGER = { - start: "top 60%", - end: "bottom top", - theme: "dark", -} as const; - export const MISSION_DECORATION_COUNT = 6; diff --git a/app/components/navbar/navbarThemeOverride.ts b/app/components/navbar/navbarThemeOverride.ts index 77ba48d..d61dc15 100644 --- a/app/components/navbar/navbarThemeOverride.ts +++ b/app/components/navbar/navbarThemeOverride.ts @@ -5,7 +5,15 @@ export type NavbarThemeOverride = NavbarTheme | null; export const NAVBAR_THEME_OVERRIDE_EVENT = "navbar-theme-override"; +let currentOverride: NavbarThemeOverride = null; + +/** Last dispatched override, so late subscribers can sync on mount. */ +export function getNavbarThemeOverride(): NavbarThemeOverride { + return currentOverride; +} + export function dispatchNavbarThemeOverride(theme: NavbarThemeOverride) { + currentOverride = theme; window.dispatchEvent( new CustomEvent(NAVBAR_THEME_OVERRIDE_EVENT, { detail: { theme }, diff --git a/app/components/navbar/useNavbarTheme.ts b/app/components/navbar/useNavbarTheme.ts index bb6b700..0a24032 100644 --- a/app/components/navbar/useNavbarTheme.ts +++ b/app/components/navbar/useNavbarTheme.ts @@ -1,15 +1,32 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { + getNavbarThemeOverride, NAVBAR_THEME_OVERRIDE_EVENT, type NavbarTheme, type NavbarThemeOverride, } from "./navbarThemeOverride"; +function subscribeToOverride(callback: () => void) { + window.addEventListener(NAVBAR_THEME_OVERRIDE_EVENT, callback); + + return () => { + window.removeEventListener(NAVBAR_THEME_OVERRIDE_EVENT, callback); + }; +} + +function getServerOverride(): NavbarThemeOverride { + return null; +} + export default function useNavbarTheme(): NavbarTheme { const [sectionTheme, setSectionTheme] = useState("dark"); - const [overrideTheme, setOverrideTheme] = useState(null); + const overrideTheme = useSyncExternalStore( + subscribeToOverride, + getNavbarThemeOverride, + getServerOverride, + ); useEffect(() => { const lightSections = document.querySelectorAll( @@ -50,24 +67,5 @@ export default function useNavbarTheme(): NavbarTheme { }; }, []); - useEffect(() => { - const handleOverride = (event: Event) => { - const { detail } = event as CustomEvent<{ theme: NavbarThemeOverride }>; - setOverrideTheme(detail.theme); - }; - - window.addEventListener( - NAVBAR_THEME_OVERRIDE_EVENT, - handleOverride as EventListener, - ); - - return () => { - window.removeEventListener( - NAVBAR_THEME_OVERRIDE_EVENT, - handleOverride as EventListener, - ); - }; - }, []); - return overrideTheme ?? sectionTheme; } diff --git a/app/components/projects/Projects.tsx b/app/components/projects/Projects.tsx index d2a96f7..4b77f9b 100644 --- a/app/components/projects/Projects.tsx +++ b/app/components/projects/Projects.tsx @@ -32,7 +32,7 @@ export default function Projects() {

{featured.label}

{featured.name}

{featured.description}

- Learn More → + Learn More → @@ -44,7 +44,7 @@ export default function Projects() {

{project.label}

{project.name}

{project.description}

- Learn More → + Learn More → {project.name} diff --git a/app/components/sponsors/Sponsors.tsx b/app/components/sponsors/Sponsors.tsx index 8d3934e..9b38266 100644 --- a/app/components/sponsors/Sponsors.tsx +++ b/app/components/sponsors/Sponsors.tsx @@ -6,9 +6,7 @@ import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useGSAP } from "@gsap/react"; import { SPONSORS } from "../../data/sponsors"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; -import { dispatchNavbarThemeOverride } from "../navbar/navbarThemeOverride"; import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; -import { SPONSORS_SECTION_DATA_ATTR } from "@/app/components/background/sceneConfig"; const ReunionTower = lazy(() => import("./ReunionTower")); @@ -85,7 +83,7 @@ export default function Sponsors() { }; }, [reducedMotion]); - // ── GSAP: scroll entrance, scroll progress, navbar + // ── GSAP: scroll entrance, scroll progress useGSAP(() => { const section = sectionRef.current; const towerWrap = towerWrapRef.current; @@ -139,18 +137,6 @@ export default function Sponsors() { }, ); } - - // Navbar theme - const nav = ScrollTrigger.create({ - trigger: section, - start: "top 10%", - end: "bottom 10%", - onEnter: () => dispatchNavbarThemeOverride("light"), - onEnterBack: () => dispatchNavbarThemeOverride("light"), - onLeave: () => dispatchNavbarThemeOverride(null), - onLeaveBack: () => dispatchNavbarThemeOverride(null), - }); - return () => nav.kill(); }); const towerH = "max(100vh, 1000px)"; @@ -163,7 +149,7 @@ export default function Sponsors() { ref={sectionRef} id="sponsors" className="relative bg-surface px-8 py-32 text-surface-foreground" - {...{ [SPONSORS_SECTION_DATA_ATTR]: "" }} + data-navbar-theme="light" >

Our Sponsors

@@ -203,8 +189,6 @@ export default function Sponsors() { ref={sectionRef} id="sponsors" className="relative z-20 bg-surface px-8 py-32 text-surface-foreground" - data-navbar-theme="light" - {...{ [SPONSORS_SECTION_DATA_ATTR]: "" }} > {/* Header */}
diff --git a/app/components/timeline/Timeline.tsx b/app/components/timeline/Timeline.tsx index c733ce0..d98d48a 100644 --- a/app/components/timeline/Timeline.tsx +++ b/app/components/timeline/Timeline.tsx @@ -1,18 +1,58 @@ "use client"; +import { useRef } from "react"; +import gsap from "gsap"; +import { useGSAP } from "@gsap/react"; +import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; +import { + TIMELINE_LIGHTEN_PHASE, + TIMELINE_SECTION_DATA_ATTR, +} from "../background/sceneConfig"; import RocketTrailAnimation from "./RocketTrailAnimation"; -import { TIMELINE_LAYOUT } from "./sceneConfig"; +import { TIMELINE_EXIT_FADE, TIMELINE_LAYOUT } from "./sceneConfig"; configureScrollTrigger(); export default function Timeline() { + const sectionRef = useRef(null); + const stickyRef = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); + + useGSAP( + () => { + const section = sectionRef.current; + const sticky = stickyRef.current; + if (!section || !sticky || prefersReducedMotion) { + return; + } + + // Fade the sticky scene out in step with the page background's + // dark → light crossfade so white trail text never sits on a light + // background. + gsap.to(sticky, { + autoAlpha: 0, + ease: TIMELINE_EXIT_FADE.ease, + scrollTrigger: { + trigger: section, + start: TIMELINE_LIGHTEN_PHASE.start, + end: TIMELINE_LIGHTEN_PHASE.end, + scrub: TIMELINE_EXIT_FADE.scrub, + }, + }); + }, + { scope: sectionRef, dependencies: [prefersReducedMotion] }, + ); + return (
diff --git a/app/components/timeline/sceneConfig.ts b/app/components/timeline/sceneConfig.ts index f71d2a6..55316b8 100644 --- a/app/components/timeline/sceneConfig.ts +++ b/app/components/timeline/sceneConfig.ts @@ -50,6 +50,13 @@ export const TIMELINE_LAYOUT = { export const MOBILE_TIMELINE_SCRUB = 0.6; +// Sticky scene fade-out during the page background's dark → light +// crossfade (scroll range comes from TIMELINE_LIGHTEN_PHASE). +export const TIMELINE_EXIT_FADE = { + ease: "power1.in", + scrub: 0.3, +} as const; + export const TRAIL_WAVE = { numPoints: 80, startX: 202, From eb6ac864ddbbcc03011b04ed5a2bb7c30e472617 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:02:01 +0000 Subject: [PATCH 2/2] feat: add site-wide light/dark toggle, project card reveal, seam fix, themed scrollbars Co-Authored-By: Caleb Bae --- app/components/background/PageBackground.tsx | 5 ++ app/components/background/sceneConfig.ts | 7 ++ app/components/hero/Hero.tsx | 11 ++- app/components/mission/Mission.tsx | 4 +- app/components/navbar/Navbar.tsx | 37 +++++++- app/components/navbar/ThemeToggle.tsx | 89 +++++++++++++++++++ app/components/projects/Projects.tsx | 51 ++++++++++- app/components/projects/sceneConfig.ts | 13 +++ app/components/teams/NodeTooltip.tsx | 14 +-- app/components/teams/TeamConstellation.tsx | 13 +-- app/components/teams/Teams.tsx | 40 ++++----- .../timeline/RocketTrailAnimation.tsx | 6 +- app/globals.css | 70 +++++++++++++-- app/layout.tsx | 15 +++- 14 files changed, 322 insertions(+), 53 deletions(-) create mode 100644 app/components/navbar/ThemeToggle.tsx create mode 100644 app/components/projects/sceneConfig.ts diff --git a/app/components/background/PageBackground.tsx b/app/components/background/PageBackground.tsx index 65ed1f9..fcb43a3 100644 --- a/app/components/background/PageBackground.tsx +++ b/app/components/background/PageBackground.tsx @@ -15,6 +15,7 @@ import { NAVBAR_LIGHT_THRESHOLD, PAGE_BG_PHASES, PAGE_BG_SMOOTHING, + PAGE_BG_SNAP_DELTA, } from "./sceneConfig"; configureScrollTrigger(); @@ -99,6 +100,10 @@ export default function PageBackground() { } } + 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 = diff --git a/app/components/background/sceneConfig.ts b/app/components/background/sceneConfig.ts index 8824e87..539ed7a 100644 --- a/app/components/background/sceneConfig.ts +++ b/app/components/background/sceneConfig.ts @@ -70,5 +70,12 @@ export const PAGE_BG_SMOOTHING = { mobile: 0.6, } as const; +/** + * 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; + /** Light-layer opacity at which the navbar switches to its light theme */ export const NAVBAR_LIGHT_THRESHOLD = 0.5; diff --git a/app/components/hero/Hero.tsx b/app/components/hero/Hero.tsx index e03b2cd..ceb9021 100644 --- a/app/components/hero/Hero.tsx +++ b/app/components/hero/Hero.tsx @@ -27,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); @@ -94,6 +95,7 @@ export default function Hero() { } const sceneFadeTargets = [ + backdropRef.current, skylineLayer, starsLayer, cometBackgroundLayer, @@ -133,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. */} +