From 3f8d135060dcbba4a102646e2641b26b0128d1d1 Mon Sep 17 00:00:00 2001 From: Clement Santacreu Date: Tue, 28 Apr 2026 10:11:17 +0200 Subject: [PATCH 1/3] feat(intro): auto-play intro animation in under 5s Replaces the scroll-driven 260vh intro with a time-based animation that auto-plays in 3.5s on first session visit. Adds a Skip button and locks scroll until done; the parent unmounts the overlay via onComplete. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/page.tsx | 3 +- components/blockchain-intro.tsx | 462 ++++++++++++++------------------ 2 files changed, 204 insertions(+), 261 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index f940f7a..ae4d3e1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,7 +17,6 @@ export default function Home() { const [showIntro, setShowIntro] = useState(false) useEffect(() => { - // Only show intro on first visit per session const hasPlayed = sessionStorage.getItem('intro-played') if (!hasPlayed) { setShowIntro(true) @@ -27,7 +26,7 @@ export default function Home() { return (
- {showIntro && } + {showIntro && setShowIntro(false)} />} diff --git a/components/blockchain-intro.tsx b/components/blockchain-intro.tsx index f4e568c..4a049ea 100644 --- a/components/blockchain-intro.tsx +++ b/components/blockchain-intro.tsx @@ -1,7 +1,6 @@ "use client" -import { useEffect, useRef, useCallback } from "react" -import { animate, createTimeline, stagger } from "animejs" +import { useEffect, useRef, useState } from "react" import { BSA_LOGO_PATH } from "./bsa-logo-path" const COLORS = [ @@ -10,6 +9,7 @@ const COLORS = [ ] const BLOCK_COUNT = 10 +const TOTAL_DURATION = 3500 function genHash(i: number): string { const c = '0123456789abcdef' @@ -18,7 +18,6 @@ function genHash(i: number): string { return h } -// Position blocks in a semicircle arc (top half, opening downward) function getArcPosition(index: number, total: number, radius: number) { const startAngle = Math.PI * 1.15 const endAngle = Math.PI * -0.15 @@ -29,75 +28,45 @@ function getArcPosition(index: number, total: number, radius: number) { } } -export default function BlockchainIntro() { - const sectionRef = useRef(null) - const hasInit = useRef(false) - const isDone = useRef(false) - const rafId = useRef(0) - - const getProgress = useCallback(() => { - if (!sectionRef.current) return 0 - const rect = sectionRef.current.getBoundingClientRect() - const scrollable = sectionRef.current.offsetHeight - window.innerHeight - if (scrollable <= 0) return 0 - return Math.max(0, Math.min(1, -rect.top / scrollable)) - }, []) - - // Entrance animation - useEffect(() => { - if (hasInit.current) return - hasInit.current = true - document.documentElement.classList.add('intro-active') - - const tl = createTimeline({ defaults: { easing: 'easeOutCubic' } }) - - tl.add('[data-intro-word]', { - opacity: [0, 1], - translateY: [60, 0], - scale: [0.85, 1], - duration: 800, - delay: stagger(120), - }, 200) - - tl.add('[data-intro-sub]', { - opacity: [0, 0.4], - translateY: [10, 0], - duration: 1000, - }, 700) - - animate('[data-scroll-cue]', { - opacity: [0.3, 1, 0.3], - translateY: [0, 6, 0], - duration: 2000, - loop: true, - easing: 'easeInOutSine', - delay: 1200, - }) - }, []) - - // Scroll-driven updates +const smoothstep = (t: number) => t * t * (3 - 2 * t) +const clamp01 = (v: number) => Math.max(0, Math.min(1, v)) + +export default function BlockchainIntro({ onComplete }: { onComplete?: () => void }) { + const rafId = useRef(0) + const [done, setDone] = useState(false) + useEffect(() => { - const update = () => { - const p = getProgress() + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + window.scrollTo(0, 0) - if (p >= 0.85) { - document.documentElement.classList.remove('intro-active') - } else { - document.documentElement.classList.add('intro-active') - } + const start = performance.now() + + const update = () => { + const elapsed = performance.now() - start + const p = Math.min(1, elapsed / TOTAL_DURATION) + + // Title words: 0 -> 0.18 with stagger + document.querySelectorAll('[data-intro-word]').forEach((el, i) => { + const start = i * 0.04 + const wp = clamp01((p - start) / 0.14) + const ease = smoothstep(wp) + el.style.opacity = String(ease) + el.style.transform = `translateY(${(1 - ease) * 50}px) scale(${0.85 + ease * 0.15})` + }) - // Phases: animation fills 0-75%, fade is 80-95%, minimal dead space - const blockPhase = Math.min(1, p / 0.55) - const logoPhase = Math.max(0, Math.min(1, (p - 0.58) / 0.17)) - const fadePhase = Math.max(0, Math.min(1, (p - 0.8) / 0.12)) + const subPhase = clamp01((p - 0.1) / 0.18) + document.querySelectorAll('[data-intro-sub]').forEach((el) => { + el.style.opacity = String(subPhase * 0.4) + el.style.transform = `translateY(${(1 - subPhase) * 8}px)` + }) - // Blocks + // Blocks: 0.08 -> 0.55 document.querySelectorAll('[data-block-i]').forEach((el) => { const i = parseInt(el.dataset.blockI || '0') - const start = (i / BLOCK_COUNT) * 0.7 - const bp = Math.max(0, Math.min(1, (blockPhase - start) / 0.18)) - const ease = bp * bp * (3 - 2 * bp) // smoothstep - + const bStart = 0.08 + (i / BLOCK_COUNT) * 0.42 + const bp = clamp01((p - bStart) / 0.18) + const ease = smoothstep(bp) el.style.opacity = String(Math.min(1, bp * 2)) el.style.transform = `scale(${0.2 + ease * 0.8}) rotate(${(1 - ease) * (i % 2 === 0 ? -20 : 20)}deg)` @@ -112,71 +81,58 @@ export default function BlockchainIntro() { } }) - // Connection lines (more visible) document.querySelectorAll('[data-conn-i]').forEach((el) => { const i = parseInt(el.dataset.connI || '0') - const start = ((i + 0.8) / BLOCK_COUNT) * 0.7 - const cp = Math.max(0, Math.min(1, (blockPhase - start) / 0.12)) + const cStart = 0.08 + ((i + 0.8) / BLOCK_COUNT) * 0.42 + const cp = clamp01((p - cStart) / 0.12) el.style.opacity = String(cp * 0.9) }) - // Hashes document.querySelectorAll('[data-hash-i]').forEach((el) => { const i = parseInt(el.dataset.hashI || '0') - const start = (i / BLOCK_COUNT) * 0.7 + 0.06 - const hp = Math.max(0, Math.min(1, (blockPhase - start) / 0.1)) + const hStart = 0.08 + (i / BLOCK_COUNT) * 0.42 + 0.04 + const hp = clamp01((p - hStart) / 0.1) el.style.opacity = String(hp) }) - // Nonce dots document.querySelectorAll('[data-dot-i]').forEach((el) => { const i = parseInt(el.dataset.dotI || '0') - const start = (i / BLOCK_COUNT) * 0.7 + 0.1 - const dp = Math.max(0, Math.min(1, (blockPhase - start) / 0.08)) + const dStart = 0.08 + (i / BLOCK_COUNT) * 0.42 + 0.06 + const dp = clamp01((p - dStart) / 0.08) el.style.opacity = String(dp) el.style.transform = `scale(${dp * dp})` }) - // BSA Logo (animate ALL instances for desktop/mobile) - document.querySelectorAll('[data-bsa-logo]').forEach((logoEl) => { - const ease = logoPhase * logoPhase * (3 - 2 * logoPhase) - logoEl.style.opacity = String(logoPhase) - logoEl.style.transform = `translate(-50%, -50%) scale(${0.1 + ease * 0.9})` + // Logo: 0.5 -> 0.78 + const logoPhase = clamp01((p - 0.5) / 0.28) + const logoEase = smoothstep(logoPhase) + document.querySelectorAll('[data-bsa-logo]').forEach((el) => { + el.style.opacity = String(logoPhase) + el.style.transform = `translate(-50%, -50%) scale(${0.1 + logoEase * 0.9})` }) - - document.querySelectorAll('[data-bsa-path]').forEach((logoPath) => { - if (logoPhase > 0) { - logoPath.style.filter = `drop-shadow(0 0 ${logoPhase * 25}px rgba(0,255,170,0.3)) drop-shadow(0 0 ${logoPhase * 50}px rgba(77,156,255,0.15))` - } + document.querySelectorAll('[data-bsa-path]').forEach((el) => { + el.style.filter = `drop-shadow(0 0 ${logoPhase * 25}px rgba(0,255,170,0.3)) drop-shadow(0 0 ${logoPhase * 50}px rgba(77,156,255,0.15))` }) - // Title fade + // Title fade out: starts at 0.65 + const titleFade = clamp01((p - 0.65) / 0.2) const titleEl = document.querySelector('[data-intro-title]') if (titleEl) { - const fade = Math.max(0, 1 - p * 3) - titleEl.style.opacity = String(fade) - titleEl.style.transform = `translateY(${-p * 120}px) scale(${1 - p * 0.15})` + titleEl.style.opacity = String(1 - titleFade) + titleEl.style.transform = `translateY(${-titleFade * 40}px)` } - // Overall fade + collapse when done + // Whole overlay fade: 0.82 -> 1.0 + const fadePhase = clamp01((p - 0.82) / 0.18) const sticky = document.querySelector('[data-intro-sticky]') if (sticky) { - const op = 1 - fadePhase - sticky.style.opacity = String(op) - sticky.style.pointerEvents = op < 0.1 ? 'none' : 'auto' + sticky.style.opacity = String(1 - fadePhase) } - // Once fully faded, collapse the section so scrolling up doesn't replay - if (fadePhase >= 1 && !isDone.current && sectionRef.current) { - isDone.current = true - const sectionHeight = sectionRef.current.offsetHeight - const currentScroll = window.scrollY - sectionRef.current.style.height = '0px' - sectionRef.current.style.overflow = 'hidden' - // Adjust scroll position so the page doesn't jump - window.scrollTo(0, Math.max(0, currentScroll - sectionHeight + window.innerHeight)) - document.documentElement.classList.remove('intro-active') - cancelAnimationFrame(rafId.current) + if (p >= 1) { + document.body.style.overflow = prevOverflow + setDone(true) + onComplete?.() return } @@ -184,178 +140,166 @@ export default function BlockchainIntro() { } rafId.current = requestAnimationFrame(update) - return () => cancelAnimationFrame(rafId.current) - }, [getProgress]) - useEffect(() => { - return () => { document.documentElement.classList.remove('intro-active') } - }, []) + return () => { + cancelAnimationFrame(rafId.current) + document.body.style.overflow = prevOverflow + } + }, [onComplete]) + + const skip = () => { + cancelAnimationFrame(rafId.current) + document.body.style.overflow = '' + setDone(true) + onComplete?.() + } + + if (done) return null const titleWords = ["Building", "the", "chain"] - // Use a safe default, actual sizing handled by CSS const arcRadius = 240 const blockSize = 80 const mobileArcRadius = 130 const mobileBlockSize = 48 return ( -
- {/* Grid */} -
- - {/* Ambient glow */} -
- - {/* Title - positioned above the arc */} -
-

- Block by block -

-

- {titleWords.map((word, i) => ( - - {word} - - ))} -

-
+ className="absolute inset-0 pointer-events-none" + style={{ + backgroundImage: 'linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px)', + backgroundSize: '50px 50px', + }} + /> + +
+ +
+

+ Block by block +

+

+ {titleWords.map((word, i) => ( + + {word} + + ))} +

+
- {/* Central container for arc + logo */} -
- {/* Desktop arc */} -
- {/* BSA Logo center */} -
- - - -
- - {/* Desktop blocks */} - {Array.from({ length: BLOCK_COUNT }).map((_, i) => { - const pos = getArcPosition(i, BLOCK_COUNT, arcRadius) - const color = COLORS[i % COLORS.length] - const nextPos = i < BLOCK_COUNT - 1 ? getArcPosition(i + 1, BLOCK_COUNT, arcRadius) : null - const half = blockSize / 2 - const cx = arcRadius + half + 20 - const cy = arcRadius + half + 20 - - return ( -
-
- - {genHash(i)} - -
-
- {nextPos && ( - - - - )} -
- ) - })} +
+
+
+ + +
- {/* Mobile arc */} -
-
- - - -
- - {Array.from({ length: BLOCK_COUNT }).map((_, i) => { - const pos = getArcPosition(i, BLOCK_COUNT, mobileArcRadius) - const color = COLORS[i % COLORS.length] - const nextPos = i < BLOCK_COUNT - 1 ? getArcPosition(i + 1, BLOCK_COUNT, mobileArcRadius) : null - const half = mobileBlockSize / 2 - const cx = mobileArcRadius + half + 12 - const cy = mobileArcRadius + half + 12 - - return ( -
-
- - {genHash(i)} - -
-
- {nextPos && ( - - - - )} + {Array.from({ length: BLOCK_COUNT }).map((_, i) => { + const pos = getArcPosition(i, BLOCK_COUNT, arcRadius) + const color = COLORS[i % COLORS.length] + const nextPos = i < BLOCK_COUNT - 1 ? getArcPosition(i + 1, BLOCK_COUNT, arcRadius) : null + const half = blockSize / 2 + const cx = arcRadius + half + 20 + const cy = arcRadius + half + 20 + + return ( +
+
+ + {genHash(i)} + +
- ) - })} -
+ {nextPos && ( + + + + )} +
+ ) + })}
- {/* Scroll cue */} - +
+
+ + + +
+ + {Array.from({ length: BLOCK_COUNT }).map((_, i) => { + const pos = getArcPosition(i, BLOCK_COUNT, mobileArcRadius) + const color = COLORS[i % COLORS.length] + const nextPos = i < BLOCK_COUNT - 1 ? getArcPosition(i + 1, BLOCK_COUNT, mobileArcRadius) : null + const half = mobileBlockSize / 2 + const cx = mobileArcRadius + half + 12 + const cy = mobileArcRadius + half + 12 + + return ( +
+
+ + {genHash(i)} + +
+
+ {nextPos && ( + + + + )} +
+ ) + })} +
-
+ + + ) } From d49aeeb817bcbc47a1758f9afddf2d943536c664 Mon Sep 17 00:00:00 2001 From: Clement Santacreu Date: Tue, 28 Apr 2026 10:11:17 +0200 Subject: [PATCH 2/3] feat(typography): use Google Sans Text for body, keep Instrument Serif for titles Adds Google Sans Text via Google Fonts as the new default body font (with Switzer kept as fallback). Titles, hero, and section headings keep Instrument Serif. Eyebrows and technical accents keep Monaspace Neon. A new font-serif utility (--font-serif) is also exposed for explicit usage on lead/description paragraphs. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/about/client.tsx | 6 +++--- app/contact/client.tsx | 2 +- app/events/client.tsx | 4 ++-- app/globals.css | 6 ++++-- app/layout.tsx | 8 +++++++- app/startups/client.tsx | 4 ++-- components/about-section.tsx | 4 ++-- components/about/RoleFAQ.tsx | 4 ++-- components/footer.tsx | 2 +- components/hero.tsx | 2 +- tailwind.config.ts | 1 + 11 files changed, 26 insertions(+), 17 deletions(-) diff --git a/app/about/client.tsx b/app/about/client.tsx index c9e06a6..c5f3e7a 100644 --- a/app/about/client.tsx +++ b/app/about/client.tsx @@ -16,17 +16,17 @@ export default function AboutClient() {
-

+

The Blockchain Student Association at EPFL is dedicated to fostering blockchain education, innovation, and community among students passionate about decentralized technologies.

-

+

We believe in the transformative potential of blockchain technology to reshape industries, create new economic models, and build a more transparent and equitable digital future.

-

+

Through workshops, hackathons, networking events, and collaborative projects, we provide students with the knowledge, skills, and connections needed to become leaders in the blockchain space. diff --git a/app/contact/client.tsx b/app/contact/client.tsx index 257a5aa..3b62f99 100644 --- a/app/contact/client.tsx +++ b/app/contact/client.tsx @@ -45,7 +45,7 @@ export default function ContactClient() {

Get in touch

-

+

Questions about our events, interested in collaborating, or just want to say hello? We'd love to hear from you.

diff --git a/app/events/client.tsx b/app/events/client.tsx index 180142a..ff2c874 100644 --- a/app/events/client.tsx +++ b/app/events/client.tsx @@ -78,7 +78,7 @@ export default function EventsClient() {

Our events

-

+

Join us for workshops, hackathons, networking events, and more.

@@ -130,7 +130,7 @@ export default function EventsClient() {

{event.title}

-

+

{event.description}

diff --git a/app/globals.css b/app/globals.css index fff5c5c..88fd95f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -57,6 +57,8 @@ html { --text-small: 0.875rem; --text-eyebrow: 0.75rem; --text-micro: 0.6875rem; + + --font-serif: "Google Sans Text", "Switzer", system-ui, sans-serif; } * { @@ -65,7 +67,7 @@ html { body { @apply bg-background text-foreground; - font-family: var(--font-sans); + font-family: "Google Sans Text", var(--font-sans), system-ui, sans-serif; background-color: #152237; background-image: linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px), @@ -78,7 +80,7 @@ html { } h4, h5, h6 { - font-family: var(--font-sans); + font-family: "Google Sans Text", var(--font-sans), system-ui, sans-serif; font-weight: 500; } } diff --git a/app/layout.tsx b/app/layout.tsx index e76d1a5..f1f86cc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -93,12 +93,18 @@ export default function RootLayout({ return ( + + +