From a25c5d95347cfbf7d7254370a307ab58ce2e57c0 Mon Sep 17 00:00:00 2001 From: Jenny Date: Sun, 21 Jun 2026 00:07:22 +0800 Subject: [PATCH] fix(scroll): harden hash spy, history reveals, and mission stack timing --- skills/context/progress-tracker.md | 6 +- src/components/hash-scroll-sync.tsx | 104 +++++++++++++++- .../history/history-scroll-motion.tsx | 114 ++++++++++++------ .../sections/history/history-scroll.ts | 3 - .../sections/mission/mission-cards-client.tsx | 5 + .../sections/mission/mission-pin-stack.ts | 10 +- src/lib/hash-navigation.ts | 84 +++++++++++-- src/lib/scroll-trigger.ts | 3 +- 8 files changed, 266 insertions(+), 63 deletions(-) diff --git a/skills/context/progress-tracker.md b/skills/context/progress-tracker.md index 3626ec2..c61ca93 100644 --- a/skills/context/progress-tracker.md +++ b/skills/context/progress-tracker.md @@ -92,7 +92,11 @@ All seven homepage sections are built, wired, and animated (scroll-driven entran ## Latest Handoff -- **Newsletter form refactor** — Turnstile logic extracted to `use-turnstile.ts`; `NewsletterForm` handles submit UX. Hidden client **submission id** echoed by `handleNewsletterSignup`; UI only shows feedback for the **active submission** (fixes stale success/error on resubmit). Email input **clears on success**; Turnstile **tokens reset after each response** (single-use). +- **Hash navigation + ScrollTrigger hardening** — `HashScrollSync` waits for safe `ScrollTrigger.refresh(true)` and Mission pin readiness before restoring deep links; abandoned restores cancel so hash spy resumes after route changes. `hash-navigation.ts` kills competing scroll tweens, updates spy on focus-in, keeps `#footer` while visible/focused/at max scroll, and resolves active hash via viewport/header intersection with a Services boundary hold (`#services` won't demote to `#monad-links`). +- **History runtime crash fixed** — Replaced `ScrollTrigger.batch()` + separate summary trigger with one trigger per milestone (final milestone reveals summary). Fixes `Cannot read properties of undefined (reading 'end')` during `ScrollTrigger.create()`. +- **Mission click-scroll guard** — `mission-pin-stack.ts` returns `null` when pin trigger bounds are non-finite; `mission-cards-client.tsx` bails and requests refresh instead of tweening invalid state. +- **Verified** — typecheck, lint (Monad `_section` warning only), test, build, and headless Chrome on `#history`, `#team`, `#services`, `#events`, `#footer`: reload lands correctly; footer hash persists while visible and clears when scrolled away. +- **Newsletter form refactor** — Turnstile extracted to `use-turnstile.ts`; `NewsletterForm` owns submit UX. Submission id ties UI feedback to the active request (no stale success/error); email clears on success; Turnstile tokens reset after each response. - **Newsletter pipeline order** — honeypot (silent, before rate limit/Turnstile) → **Upstash** per-IP rate limit → **Cloudflare Siteverify** → **Resend** (`contacts.create` + segments; existing contacts re-subscribed via `contacts.update` when `unsubscribed: true`). Rate limiting runs **before Siteverify** to reduce forged-token abuse. - **Turnstile config** — Client reads `NEXT_PUBLIC_TURNSTILE_SITE_KEY`; server Siteverify gated by `isNewsletterTurnstileEnabled()` (both keys required). Secret **trimmed** before Siteverify. **Hostname validation**: `TURNSTILE_ALLOWED_HOSTNAMES`, `*.vercel.app` wildcards, `VERCEL_URL` merged for previews; action must match `newsletter`. Script loader stores **loading/loaded/error** on shared script element, uses `turnstile.ready()`, handles remount/retry; copies page **CSP nonce** onto `api.js`. - **Rate-limit IP hardening** — Parses `x-vercel-forwarded-for`, `Forwarded`, IPv4-with-port, bracketed IPv6; **skips loopback** in production-like runtimes. Unknown IP returns `null` identifier; **fail closed** in production (no shared `local` bucket). diff --git a/src/components/hash-scroll-sync.tsx b/src/components/hash-scroll-sync.tsx index c4c4591..2bd84eb 100644 --- a/src/components/hash-scroll-sync.tsx +++ b/src/components/hash-scroll-sync.tsx @@ -2,23 +2,117 @@ import { usePathname } from "next/navigation"; import { useOnMount } from "@/hooks/use-on-mount"; +import { ScrollTrigger } from "@/lib/gsap-setup"; import { + cancelRouteHashNavigation, enableHashScrollSpy, + prepareForRouteHashNavigation, scrollToHashFromLocation, } from "@/lib/hash-navigation"; -function runHashScrollAfterScrollTriggersReady(): void { - // Match deferGsapSetup (2 rAF) so enter timelines exist before we scroll. - requestAnimationFrame(() => { - requestAnimationFrame(() => { +const HASH_SCROLL_REFRESH_TIMEOUT_MS = 1500; +const DESKTOP_MQL = "(min-width: 1280px)"; +const REDUCED_MOTION_MQL = "(prefers-reduced-motion: reduce)"; +const MISSION_SECTION_ID = "mission"; +const MISSION_STACK_SCROLL_ID = "mission-stack-scroll"; +const HASH_TARGETS_BEFORE_MISSION = new Set(["hero", MISSION_SECTION_ID]); + +function getHashIdFromLocation(): string { + try { + return decodeURIComponent(window.location.hash.slice(1)); + } catch { + return window.location.hash.slice(1); + } +} + +function shouldWaitForMissionStackBeforeHashScroll(): boolean { + if (!window.matchMedia(DESKTOP_MQL).matches) return false; + if (window.matchMedia(REDUCED_MOTION_MQL).matches) return false; + if (ScrollTrigger.getById(MISSION_STACK_SCROLL_ID)) return false; + + const hashId = getHashIdFromLocation(); + if (!hashId || HASH_TARGETS_BEFORE_MISSION.has(hashId)) return false; + + const target = document.getElementById(hashId); + const mission = document.getElementById(MISSION_SECTION_ID); + if (!target || !mission) return true; + + return Boolean( + mission.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING, + ); +} + +function runHashScrollAfterScrollTriggersReady(): () => void { + if (!window.location.hash) return () => {}; + + prepareForRouteHashNavigation(); + + let cancelled = false; + let outerRafId: number | null = null; + let innerRafId: number | null = null; + let scrollRafId: number | null = null; + let timeoutId: number | undefined; + let settled = false; + + const teardown = () => { + ScrollTrigger.removeEventListener("refresh", handleRefresh); + if (outerRafId !== null) { + window.cancelAnimationFrame(outerRafId); + outerRafId = null; + } + if (innerRafId !== null) { + window.cancelAnimationFrame(innerRafId); + innerRafId = null; + } + if (scrollRafId !== null) { + window.cancelAnimationFrame(scrollRafId); + scrollRafId = null; + } + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + function settle() { + if (settled) return; + settled = true; + teardown(); + + scrollRafId = window.requestAnimationFrame(() => { + scrollRafId = null; + if (cancelled) return; scrollToHashFromLocation(); }); + } + + function handleRefresh() { + if (shouldWaitForMissionStackBeforeHashScroll()) return; + settle(); + } + + ScrollTrigger.addEventListener("refresh", handleRefresh); + timeoutId = window.setTimeout(settle, HASH_SCROLL_REFRESH_TIMEOUT_MS); + + outerRafId = window.requestAnimationFrame(() => { + outerRafId = null; + innerRafId = window.requestAnimationFrame(() => { + innerRafId = null; + if (cancelled) return; + ScrollTrigger.refresh(true); + }); }); + + return () => { + cancelled = true; + teardown(); + cancelRouteHashNavigation(); + }; } function HashScrollOnNavigate() { useOnMount(() => { - runHashScrollAfterScrollTriggersReady(); + return runHashScrollAfterScrollTriggersReady(); }); return null; diff --git a/src/components/sections/history/history-scroll-motion.tsx b/src/components/sections/history/history-scroll-motion.tsx index c1afa05..a4dc07a 100644 --- a/src/components/sections/history/history-scroll-motion.tsx +++ b/src/components/sections/history/history-scroll-motion.tsx @@ -6,7 +6,6 @@ import { HISTORY_LINE_DRAW_EASE, HISTORY_LINE_DRAW_START, HISTORY_LINE_SEGMENT_DRAW_DURATION, - HISTORY_MILESTONE_STAGGER, HISTORY_REVEAL_START, HISTORY_SUMMARY_DELAY, } from "@/components/sections/history/history-scroll"; @@ -14,7 +13,6 @@ import { BELOW_DESKTOP_MQL, DESKTOP_MQL } from "@/hooks/use-is-desktop"; import { useReducedMotion } from "@/hooks/use-reduced-motion"; import { gsap, ScrollTrigger, useGSAP } from "@/lib/gsap-setup"; import { - createBatchReveal, createScrollTriggerConfig, playIfAlreadyInView, SCROLL_REVEAL_FROM, @@ -27,6 +25,7 @@ const HISTORY_MILESTONE_SELECTOR = "[data-history-milestone]"; const HISTORY_SUMMARY_SELECTOR = "[data-history-summary]"; const HISTORY_LINE_SELECTOR = "[data-history-line]"; const HISTORY_STEM_SELECTOR = "[data-history-stem]"; +const HISTORY_MILESTONE_REVEAL_ID_PREFIX = "history-milestone-reveal"; const HISTORY_DESKTOP_LINE_DRAW_ID_PREFIX = "history-line-draw"; const HISTORY_MOBILE_STEM_DRAW_ID_PREFIX = "history-stem-draw"; @@ -150,6 +149,79 @@ function setupMobileStemDraw(scope: HTMLElement): () => void { }; } +function setupHistoryContentReveal(scope: HTMLElement): () => void { + const milestones = Array.from( + scope.querySelectorAll(HISTORY_MILESTONE_SELECTOR), + ); + const summary = scope.querySelector(HISTORY_SUMMARY_SELECTOR); + const lastMilestoneIndex = milestones.length - 1; + const revealedMilestones = new Set(); + let summaryRevealed = false; + + if (milestones.length === 0 && !summary) return () => {}; + + gsap.set(milestones, SCROLL_REVEAL_FROM); + if (summary) { + gsap.set(summary, SCROLL_REVEAL_FROM); + } + + const revealSummary = () => { + if (!summary || summaryRevealed) return; + summaryRevealed = true; + + gsap.to(summary, { + ...SCROLL_REVEAL_TO, + delay: HISTORY_SUMMARY_DELAY, + overwrite: true, + }); + }; + + const revealMilestone = (milestone: HTMLElement, index: number) => { + if (revealedMilestones.has(index)) return; + revealedMilestones.add(index); + + gsap.to(milestone, { + ...SCROLL_REVEAL_TO, + overwrite: true, + }); + + if (index === lastMilestoneIndex) { + revealSummary(); + } + }; + + const triggers = milestones.map((milestone, index) => { + const trigger = ScrollTrigger.create({ + id: `${HISTORY_MILESTONE_REVEAL_ID_PREFIX}-${index + 1}`, + trigger: milestone, + start: HISTORY_REVEAL_START, + once: true, + fastScrollEnd: true, + invalidateOnRefresh: true, + markers: SCROLL_TRIGGER_MARKERS, + onEnter: () => { + revealMilestone(milestone, index); + }, + }); + + if (trigger.progress > 0) { + revealMilestone(milestone, index); + } + + return trigger; + }); + + return () => { + for (const trigger of triggers) { + trigger.kill(); + } + gsap.killTweensOf(milestones); + if (summary) { + gsap.killTweensOf(summary); + } + }; +} + export function HistoryScrollMotion({ children }: HistoryScrollMotionProps) { const scopeRef = useRef(null); const reducedMotion = useReducedMotion(); @@ -167,49 +239,15 @@ export function HistoryScrollMotion({ children }: HistoryScrollMotionProps) { return; } - createBatchReveal({ - selector: HISTORY_MILESTONE_SELECTOR, - start: HISTORY_REVEAL_START, - stagger: HISTORY_MILESTONE_STAGGER, - }); - - const summary = scope?.querySelector(HISTORY_SUMMARY_SELECTOR); - const milestones = scope?.querySelectorAll( - HISTORY_MILESTONE_SELECTOR, - ); - const lastMilestone = milestones?.[milestones.length - 1]; const matchMedia = gsap.matchMedia(); + const cleanupContentReveal = setupHistoryContentReveal(scope); matchMedia.add(DESKTOP_MQL, () => setupDesktopLineDraw(scope)); matchMedia.add(BELOW_DESKTOP_MQL, () => setupMobileStemDraw(scope)); - if (!summary || !lastMilestone) { - return () => { - matchMedia.revert(); - }; - } - - gsap.set(summary, SCROLL_REVEAL_FROM); - - const summaryTrigger = ScrollTrigger.create({ - id: "history-summary", - trigger: lastMilestone, - start: HISTORY_REVEAL_START, - once: true, - fastScrollEnd: true, - markers: SCROLL_TRIGGER_MARKERS, - onEnter: () => { - gsap.to(summary, { - ...SCROLL_REVEAL_TO, - delay: HISTORY_SUMMARY_DELAY, - overwrite: true, - }); - }, - }); - return () => { - summaryTrigger.kill(); matchMedia.revert(); + cleanupContentReveal(); }; }, { scope: scopeRef, dependencies: [reducedMotion] }, diff --git a/src/components/sections/history/history-scroll.ts b/src/components/sections/history/history-scroll.ts index 7740e85..30c5cf9 100644 --- a/src/components/sections/history/history-scroll.ts +++ b/src/components/sections/history/history-scroll.ts @@ -1,8 +1,5 @@ import { MOTION_DURATION, MOTION_EASE, MOTION_START } from "@/lib/motion/tokens"; -/** Stagger between each milestone reveal in the batch (seconds). */ -export const HISTORY_MILESTONE_STAGGER = 0.22; - /** Pause after the last milestone enters before the summary callout fades in (seconds). */ export const HISTORY_SUMMARY_DELAY = 0.5; diff --git a/src/components/sections/mission/mission-cards-client.tsx b/src/components/sections/mission/mission-cards-client.tsx index 8d8b703..cd7f66f 100644 --- a/src/components/sections/mission/mission-cards-client.tsx +++ b/src/components/sections/mission/mission-cards-client.tsx @@ -240,6 +240,11 @@ export function MissionDesktopStack({ cards.length, index, ); + if (targetScrollTop === null) { + clickScrollRef.current = null; + ScrollTrigger.refresh(true); + return; + } gsap.to(window, { scrollTo: { y: targetScrollTop, autoKill: true }, diff --git a/src/components/sections/mission/mission-pin-stack.ts b/src/components/sections/mission/mission-pin-stack.ts index 10230ed..df07322 100644 --- a/src/components/sections/mission/mission-pin-stack.ts +++ b/src/components/sections/mission/mission-pin-stack.ts @@ -74,9 +74,15 @@ export function getMissionStackScrollTop( scrollTrigger: ScrollTrigger, cardCount: number, index: number, -): number { +): number | null { + const { start, end } = scrollTrigger; + + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + const progress = getMissionStackProgress(cardCount, index); - return scrollTrigger.start + progress * (scrollTrigger.end - scrollTrigger.start); + return start + progress * (end - start); } export function createMissionStackScrollTrigger({ diff --git a/src/lib/hash-navigation.ts b/src/lib/hash-navigation.ts index d17b6df..97b34cd 100644 --- a/src/lib/hash-navigation.ts +++ b/src/lib/hash-navigation.ts @@ -73,9 +73,36 @@ function getLocationHashId(): string { } } -function isElementVisibleInViewport(element: Element): boolean { - const rect = element.getBoundingClientRect(); - return rect.bottom > 0 && rect.top < window.innerHeight; +function isElementFocusedWithin(element: Element): boolean { + const activeElement = document.activeElement; + return activeElement instanceof Element && element.contains(activeElement); +} + +function isNearPageBottom(): boolean { + const maxScroll = ScrollTrigger.maxScroll(window); + return Number.isFinite(maxScroll) && maxScroll - window.scrollY <= 4; +} + +function isFooterActive(footer: Element, currentHashId: string): boolean { + const rect = footer.getBoundingClientRect(); + const headerOffset = getHeaderOffset(); + const footerVisibleBelowHeader = + rect.bottom > headerOffset && rect.top < window.innerHeight; + + if (!footerVisibleBelowHeader) { + return false; + } + + if (currentHashId === footerContent.id || isElementFocusedWithin(footer)) { + return true; + } + + if (isNearPageBottom()) { + return true; + } + + const readingLine = Math.max(headerOffset + 1, window.innerHeight * 0.7); + return rect.top <= readingLine; } const FOCUS_AFTER_SCROLL_MAX_MS = 1500; @@ -85,6 +112,21 @@ const HASH_TARGET_MOUNT_MAX_WAIT_MS = 3000; /** Smooth scroll only for short hops; long jumps (e.g. hero → #events) jump instantly. */ const HASH_SMOOTH_SCROLL_MAX_DISTANCE_PX = () => window.innerHeight * 1.25; +const HASH_SPY_PROBE_TOLERANCE_PX = 24; +const SERVICES_HASH_HOLD_RATIO = 0.25; + +function shouldPreferServicesHash(headerOffset: number): boolean { + const target = document.getElementById(servicesSection.id); + if (!target) return false; + + const rect = target.getBoundingClientRect(); + const topHoldLine = Math.max( + headerOffset + HASH_SPY_PROBE_TOLERANCE_PX, + window.innerHeight * SERVICES_HASH_HOLD_RATIO, + ); + + return rect.top <= topHoldLine && rect.bottom > headerOffset; +} class HashNavigationRuntime { private pendingHashFocusId: string | null = null; @@ -181,12 +223,21 @@ class HashNavigationRuntime { this.hashSpyPaused = true; } + cancelRouteHashNavigation(): void { + this.cancelTargetMountWait?.(); + this.cancelPendingScrollFocus(); + gsap.killTweensOf(window); + this.hashSpyPaused = false; + } + private resumeHashSpy(): void { this.hashSpyPaused = false; this.syncHashFromScroll(); } private scrollWindowTo(targetScrollY: number, smooth: boolean): void { + gsap.killTweensOf(window); + if (!smooth) { window.scrollTo({ top: targetScrollY, behavior: "auto" }); ScrollTrigger.update(); @@ -216,27 +267,30 @@ class HashNavigationRuntime { private resolveActiveHashId(): string { const currentHashId = getLocationHashId(); + const headerOffset = getHeaderOffset(); + const footer = document.getElementById(footerContent.id); - if (currentHashId === footerContent.id) { - const footer = document.getElementById(footerContent.id); - if (footer && isElementVisibleInViewport(footer)) { - return footerContent.id; - } + if (footer && isFooterActive(footer, currentHashId)) { + return footerContent.id; } - const probe = window.scrollY + getHeaderOffset() + 2; + if (shouldPreferServicesHash(headerOffset)) { + return servicesSection.id; + } + + const probe = headerOffset + HASH_SPY_PROBE_TOLERANCE_PX; let activeId: string = HASH_SPY_IDS[0]; for (const id of HASH_SPY_IDS) { const target = document.getElementById(id); if (!target) continue; - const top = getScrollTargetY(target, 0); - if (top <= probe) { + const rect = target.getBoundingClientRect(); + if (rect.top <= probe && rect.bottom > headerOffset) { activeId = id; continue; } - break; + if (rect.top > probe) break; } return activeId; @@ -296,10 +350,12 @@ class HashNavigationRuntime { }; window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("focusin", scheduleSync); window.addEventListener("pageshow", scheduleSync); return () => { window.removeEventListener("scroll", onScroll); + window.removeEventListener("focusin", scheduleSync); window.removeEventListener("pageshow", scheduleSync); this.teardownHashScrollSpy(); }; @@ -426,6 +482,10 @@ export function prepareForRouteHashNavigation(): void { hashNavigationRuntime.prepareForRouteHashNavigation(); } +export function cancelRouteHashNavigation(): void { + hashNavigationRuntime.cancelRouteHashNavigation(); +} + export function navigateToTop(): void { hashNavigationRuntime.navigateToTop(); } diff --git a/src/lib/scroll-trigger.ts b/src/lib/scroll-trigger.ts index 30fd7e8..f9a54ac 100644 --- a/src/lib/scroll-trigger.ts +++ b/src/lib/scroll-trigger.ts @@ -13,7 +13,7 @@ export function scheduleScrollTriggerRefresh(): void { } scheduledRefreshRafId = requestAnimationFrame(() => { scheduledRefreshRafId = null; - ScrollTrigger.refresh(); + ScrollTrigger.refresh(true); }); } @@ -150,4 +150,3 @@ export function playIfAlreadyInView(timeline: gsap.core.Timeline): void { timeline.play(0); } } -