Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion skills/context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
104 changes: 99 additions & 5 deletions src/components/hash-scroll-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
114 changes: 76 additions & 38 deletions src/components/sections/history/history-scroll-motion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ 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";
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,
Expand All @@ -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";

Expand Down Expand Up @@ -150,6 +149,79 @@ function setupMobileStemDraw(scope: HTMLElement): () => void {
};
}

function setupHistoryContentReveal(scope: HTMLElement): () => void {
const milestones = Array.from(
scope.querySelectorAll<HTMLElement>(HISTORY_MILESTONE_SELECTOR),
);
const summary = scope.querySelector<HTMLElement>(HISTORY_SUMMARY_SELECTOR);
const lastMilestoneIndex = milestones.length - 1;
const revealedMilestones = new Set<number>();
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<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
Expand All @@ -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<HTMLElement>(HISTORY_SUMMARY_SELECTOR);
const milestones = scope?.querySelectorAll<HTMLElement>(
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] },
Expand Down
3 changes: 0 additions & 3 deletions src/components/sections/history/history-scroll.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
5 changes: 5 additions & 0 deletions src/components/sections/mission/mission-cards-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
10 changes: 8 additions & 2 deletions src/components/sections/mission/mission-pin-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading