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..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..dbcc876 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, @@ -35,7 +32,7 @@ function renderMissionStatement() { function renderDirectorsPanel() { return (
-
+

{missionContent.directorsMessage.quote}

@@ -44,7 +41,7 @@ function renderDirectorsPanel() {
{Array.from({ length: MISSION_DECORATION_COUNT }).map((_, i) => ( -
+
))}
@@ -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/Navbar.tsx b/app/components/navbar/Navbar.tsx index 4ff346c..2ca22c8 100644 --- a/app/components/navbar/Navbar.tsx +++ b/app/components/navbar/Navbar.tsx @@ -1,10 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useSyncExternalStore } from "react"; import Image from "next/image"; import Link from "next/link"; import AccentButton from "../ui/AccentButton"; import useNavbarTheme from "./useNavbarTheme"; +import ThemeToggle, { + applySiteTheme, + getServerSiteTheme, + readSiteTheme, + subscribeSiteTheme, + type SiteTheme, +} from "./ThemeToggle"; const NAV_LINKS = [ { href: "#hackathons", label: "WHO WE ARE" }, @@ -14,8 +21,22 @@ const NAV_LINKS = [ export default function Navbar() { const [isOpen, setIsOpen] = useState(false); + const siteTheme = useSyncExternalStore( + subscribeSiteTheme, + readSiteTheme, + getServerSiteTheme, + ); const theme = useNavbarTheme(); const isLightTheme = theme === "light"; + // The white/black logo assets track the actual underlying color: the + // "light" navbar phase sits on the surface color, which is dark when the + // user picks the light site theme. + const showBlackLogo = isLightTheme !== (siteTheme === "light"); + + const toggleSiteTheme = () => { + const next: SiteTheme = siteTheme === "dark" ? "light" : "dark"; + applySiteTheme(next); + }; useEffect(() => { document.body.style.overflow = isOpen ? "hidden" : ""; @@ -34,7 +55,7 @@ export default function Navbar() { width={2048} height={585} className={`absolute inset-0 h-6 w-auto transition-opacity duration-300 md:h-8 ${ - isLightTheme ? "opacity-0" : "opacity-100" + showBlackLogo ? "opacity-0" : "opacity-100" }`} priority /> @@ -44,7 +65,7 @@ export default function Navbar() { width={2048} height={585} className={`absolute inset-0 h-6 w-auto transition-opacity duration-300 md:h-8 ${ - isLightTheme ? "opacity-100" : "opacity-0" + showBlackLogo ? "opacity-100" : "opacity-0" }`} priority /> @@ -67,6 +88,11 @@ export default function Navbar() { ))} + Gallery
@@ -116,6 +142,11 @@ export default function Navbar() { {link.label} ))} + void) { + window.addEventListener(THEME_CHANGE_EVENT, onChange); + return () => window.removeEventListener(THEME_CHANGE_EVENT, onChange); +} + +export function readSiteTheme(): SiteTheme { + return document.documentElement.dataset.theme === "light" ? "light" : "dark"; +} + +export function getServerSiteTheme(): SiteTheme { + return "dark"; +} + +export function applySiteTheme(theme: SiteTheme) { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Storage unavailable (private mode) — theme still applies for the session. + } + window.dispatchEvent(new Event(THEME_CHANGE_EVENT)); +} + +/** + * Site-wide light/dark toggle button. The chosen theme swaps the semantic + * color variables (see globals.css) and persists across visits; an inline + * script in the root layout applies it before first paint. + */ +export default function ThemeToggle({ + theme, + onToggle, + isLightNavbar, +}: { + theme: SiteTheme; + onToggle: () => void; + isLightNavbar: boolean; +}) { + const next: SiteTheme = theme === "dark" ? "light" : "dark"; + + return ( + + ); +} 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..0811f6b 100644 --- a/app/components/projects/Projects.tsx +++ b/app/components/projects/Projects.tsx @@ -1,5 +1,15 @@ +"use client"; + +import { useRef } from "react"; +import gsap from "gsap"; +import { useGSAP } from "@gsap/react"; import Image from "next/image"; import { projects } from "@/app/data/projects"; +import { usePrefersReducedMotion } from "@/app/hooks/usePrefersReducedMotion"; +import { configureScrollTrigger } from "@/app/lib/scrollTrigger"; +import { PROJECTS_REVEAL } from "./sceneConfig"; + +configureScrollTrigger(); function pad(n: number) { return String(n).padStart(2, "0"); @@ -10,7 +20,7 @@ function ProjectNumber({ n, small }: { n: number; small?: boolean }) { const size = small ? "text-8xl" : "text-[160px]"; const offset = small ? "mt-1" : "mt-2"; return ( -
+
{a} {b}
@@ -19,11 +29,40 @@ function ProjectNumber({ n, small }: { n: number; small?: boolean }) { export default function Projects() { const [featured, ...rest] = projects; + const sectionRef = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); + + useGSAP( + () => { + const section = sectionRef.current; + if (!section || prefersReducedMotion) { + return; + } + + const cards = gsap.utils.toArray("[data-project-card]"); + + gsap.from(cards, { + ...PROJECTS_REVEAL.from, + duration: PROJECTS_REVEAL.duration, + stagger: PROJECTS_REVEAL.stagger, + ease: PROJECTS_REVEAL.ease, + scrollTrigger: { + trigger: section, + start: PROJECTS_REVEAL.start, + once: true, + }, + }); + }, + { scope: sectionRef, dependencies: [prefersReducedMotion] }, + ); return ( -
+
-
+
{featured.name} @@ -32,19 +71,23 @@ export default function Projects() {

{featured.label}

{featured.name}

{featured.description}

- Learn More → + Learn More →
{rest.map((project, i) => ( -
+

{project.label}

{project.name}

{project.description}

- Learn More → + Learn More →
{project.name}
diff --git a/app/components/projects/sceneConfig.ts b/app/components/projects/sceneConfig.ts new file mode 100644 index 0000000..207d623 --- /dev/null +++ b/app/components/projects/sceneConfig.ts @@ -0,0 +1,13 @@ +/** Scroll-in reveal for the project cards. */ +export const PROJECTS_REVEAL = { + /** Initial offset/state each card animates from */ + from: { + autoAlpha: 0, + y: 40, + }, + duration: 0.8, + stagger: 0.12, + ease: "power2.out", + /** Trigger window on the projects section */ + start: "top 75%", +} as const; 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/teams/NodeTooltip.tsx b/app/components/teams/NodeTooltip.tsx index e10df9c..e373226 100644 --- a/app/components/teams/NodeTooltip.tsx +++ b/app/components/teams/NodeTooltip.tsx @@ -32,7 +32,7 @@ export function NodeTooltip({ }) { return (
{ clearTooltipClose(); setActiveTeamId(teamId); @@ -56,7 +56,7 @@ export function NodeTooltip({
{person.quote ? ( -

+

“{person.quote}”

) : null} @@ -97,7 +97,7 @@ export function NodeTooltip({ href={person.linkedinUrl} target="_blank" rel="noreferrer" - className="inline-flex items-center gap-1.5 rounded-lg bg-white/6 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/12 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink/70" + className="inline-flex items-center gap-1.5 rounded-lg bg-foreground/6 px-3 py-1.5 text-xs font-medium text-foreground/70 transition-colors hover:bg-foreground/12 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pink/70" >
-

+

{layout.template.name}

{layout.team.label}

-

+

{memberCountLabel}

diff --git a/app/components/teams/Teams.tsx b/app/components/teams/Teams.tsx index 5ab7631..5e06ab2 100644 --- a/app/components/teams/Teams.tsx +++ b/app/components/teams/Teams.tsx @@ -406,7 +406,7 @@ export default function Teams() { opacity: star.opacity, }; return ( - + ); })}
@@ -421,7 +421,7 @@ export default function Teams() { > {mobileStars}
-

+

{TEAMS_COPY.eyebrow}

@@ -455,7 +455,7 @@ export default function Teams() {
@@ -467,7 +467,7 @@ export default function Teams() { className="shrink-0 overflow-y-auto px-5 pt-16 pb-3" style={{ maxHeight: isAndroid ? "52%" : "50%" }} > -

+

{TEAMS_COPY.eyebrow}

@@ -481,7 +481,7 @@ export default function Teams() { style={{ opacity: descVisible ? 1 : 0 }} > {ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.description ? ( -

+

{ORDERED_OFFICER_TEAMS[displayedTeamIndex]!.description}

) : null} @@ -493,15 +493,15 @@ export default function Teams() { alt={`${ORDERED_OFFICER_TEAMS[displayedTeamIndex]?.label} team`} width={400} height={96} - className="w-full rounded-xl object-cover border border-white/10" + className="w-full rounded-xl object-cover border border-foreground/10" style={{ maxHeight: "96px" }} /> ) : (
-

+

Group photo coming soon

@@ -541,8 +541,8 @@ export default function Teams() { key={i} className={`h-1 rounded-full transition-all duration-300 ${ i === displayedTeamIndex - ? "w-4 bg-white/60" - : "w-1 bg-white/20" + ? "w-4 bg-foreground/60" + : "w-1 bg-foreground/20" }`} /> ))} @@ -572,7 +572,7 @@ export default function Teams() { return ( ); @@ -580,7 +580,7 @@ export default function Teams() {

-

+

{TEAMS_COPY.eyebrow}

@@ -593,7 +593,7 @@ export default function Teams() { {desktopLayouts.map((layout) => (