diff --git a/app/globals.css b/app/globals.css index eaaf94e..a05fb5f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,7 +41,13 @@ body { @layer components { .glass-card { - @apply bg-zinc-100/80 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-zinc-100 dark:ring-white/5; + @apply bg-zinc-100/95 dark:bg-zinc-900/85 border border-zinc-200 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-zinc-100 dark:ring-white/5; + } + + @media (min-width: 768px) { + .glass-card { + @apply bg-zinc-100/80 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150; + } } .no-scrollbar { @@ -53,6 +59,58 @@ body { } } +/* Scroll-triggered reveal */ +@keyframes revealFadeUp { + from { opacity: 0; transform: translateY(30px); } + to { opacity: 1; transform: translateY(0); } +} +.reveal-hidden { opacity: 0; transform: translateY(30px); } +.reveal-visible { animation: revealFadeUp 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; } + +/* Skill chip reveal */ +@keyframes skillFadeIn { + from { opacity: 0; transform: scale(0.8); } + to { opacity: 1; transform: scale(1); } +} +.skill-hidden { opacity: 0; transform: scale(0.8); } +.skill-visible { animation: skillFadeIn 0.3s ease both; } + +/* Stat item reveal */ +@keyframes statFadeUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.stat-hidden { opacity: 0; transform: translateY(10px); } +.stat-visible { animation: statFadeUp 0.4s ease both; } + +/* Hero image float */ +@keyframes heroFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} +.hero-float { + animation: heroFloat 4s ease-in-out infinite; +} + +/* Skills marquee */ +@keyframes marqueeScroll { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.marquee-track { + animation: marqueeScroll 30s linear infinite; +} + +/* Scroll-driven progress bar (Safari 18 / iOS 18+) */ +@keyframes growX { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +.scroll-progress { + animation: growX linear both; + animation-timeline: scroll(root); +} + @keyframes shine { 100% { transform: translateX(200%); @@ -107,7 +165,15 @@ body { } @media (prefers-reduced-motion: reduce) { - .fluid-shape { + .fluid-shape, + .hero-float, + .marquee-track { animation: none; } +} + +@media (hover: none) { + .fluid-shape { + display: none; + } } \ No newline at end of file diff --git a/components/sections/about.tsx b/components/sections/about.tsx index 912cf95..5b6ba92 100644 --- a/components/sections/about.tsx +++ b/components/sections/about.tsx @@ -40,7 +40,7 @@ export async function AboutSection() { {/* Background glow */} -
+
) } diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 4f3260f..d50743f 100644 --- a/components/sections/contact.tsx +++ b/components/sections/contact.tsx @@ -14,7 +14,7 @@ export async function ContactSection() {
{/* Location */}
-
+
@@ -25,7 +25,7 @@ export async function ContactSection() { {/* Email */} -
+
{contact.email} @@ -33,7 +33,7 @@ export async function ContactSection() { {/* LinkedIn */}
-
+
LinkedIn @@ -41,7 +41,7 @@ export async function ContactSection() { {/* GitHub */}
-
+
GitHub diff --git a/components/ui/animated-marquee.tsx b/components/ui/animated-marquee.tsx index a0c7086..7b4824b 100644 --- a/components/ui/animated-marquee.tsx +++ b/components/ui/animated-marquee.tsx @@ -1,29 +1,18 @@ "use client" -import { motion } from "framer-motion" - interface AnimatedMarqueeProps { items: string[] } export function AnimatedMarquee({ items }: AnimatedMarqueeProps) { - // Duplicate items to ensure smooth infinite loop const marqueeItems = [...items, ...items, ...items, ...items] return ( -
- +
{marqueeItems.map((item, i) => (
))} - +
) } diff --git a/components/ui/animated-skills.tsx b/components/ui/animated-skills.tsx index 6c39bad..0dc39f0 100644 --- a/components/ui/animated-skills.tsx +++ b/components/ui/animated-skills.tsx @@ -1,39 +1,45 @@ "use client" -import { motion } from "framer-motion" -const container = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.05 - } - } -} - -const item = { - hidden: { opacity: 0, scale: 0.8 }, - visible: { opacity: 1, scale: 1 } -} +import { useRef, useEffect, useState } from "react" export function AnimatedSkills({ skills }: { skills: string[] }) { + const ref = useRef(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { setMounted(true) }, []) + + useEffect(() => { + if (!mounted || !ref.current) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + ref.current!.querySelectorAll("[data-skill]").forEach((el, i) => { + ;(el as HTMLElement).style.animationDelay = `${i * 0.05}s` + el.classList.remove("skill-hidden") + el.classList.add("skill-visible") + }) + observer.disconnect() + } + }, + { rootMargin: "-50px" } + ) + + observer.observe(ref.current) + return () => observer.disconnect() + }, [mounted]) + return ( - +
{skills.map((skill) => ( - - {skill} - + {skill} + ))} - +
) } diff --git a/components/ui/animated-stats.tsx b/components/ui/animated-stats.tsx index ea8dee1..51ba3c7 100644 --- a/components/ui/animated-stats.tsx +++ b/components/ui/animated-stats.tsx @@ -1,37 +1,47 @@ "use client" -import { motion } from "framer-motion" + +import { useRef, useEffect, useState } from "react" import { AboutStatViewModel } from "@/src/interface-adapters/presenters/about-presenter" -const container = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1 - } - } -} +export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) { + const ref = useRef(null) + const [mounted, setMounted] = useState(false) -const item = { - hidden: { opacity: 0, y: 10 }, - visible: { opacity: 1, y: 0 } -} + useEffect(() => { setMounted(true) }, []) + + useEffect(() => { + if (!mounted || !ref.current) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + ref.current!.querySelectorAll("[data-stat]").forEach((el, i) => { + ;(el as HTMLElement).style.animationDelay = `${i * 0.1}s` + el.classList.remove("stat-hidden") + el.classList.add("stat-visible") + }) + observer.disconnect() + } + }, + { rootMargin: "-50px" } + ) + + observer.observe(ref.current) + return () => observer.disconnect() + }, [mounted]) -export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) { return ( - +
{stats.map((stat) => ( - - {stat.label} - {stat.value} - +
+ {stat.label} + {stat.value} +
))} - +
) } diff --git a/components/ui/back-to-top.tsx b/components/ui/back-to-top.tsx index 7cd90c8..dd9e59c 100644 --- a/components/ui/back-to-top.tsx +++ b/components/ui/back-to-top.tsx @@ -1,7 +1,6 @@ "use client" import { useEffect, useState, startTransition } from "react" -import { motion, AnimatePresence } from "framer-motion" import { ArrowUp } from "lucide-react" export function BackToTop() { @@ -14,20 +13,13 @@ export function BackToTop() { }, []) return ( - - {visible && ( - window.scrollTo({ top: 0, behavior: "smooth" })} - className="fixed bottom-8 right-8 z-50 p-3 rounded-full glass-card text-zinc-500 dark:text-zinc-400 hover:text-accent dark:hover:text-accent transition-colors" - aria-label="Back to top" - > - - - )} - + ) } diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx index d4a7e81..cc911aa 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -17,18 +17,19 @@ const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf` export function CvDialog() { const [open, setOpen] = useState(false) const [content, setContent] = useState(null) - const [available, setAvailable] = useState(null) + const [loadFailed, setLoadFailed] = useState(false) - // Fetch from own origin — cv.md is copied to public/ by the update-cv workflow + // Fetch only when the dialog is opened, not on page load useEffect(() => { + if (!open || content !== null || loadFailed) return fetch('/cv.md') .then(res => { if (!res.ok) throw new Error() return res.text() }) - .then(text => { setContent(text); setAvailable(true) }) - .catch(() => setAvailable(false)) - }, []) + .then(text => setContent(text)) + .catch(() => setLoadFailed(true)) + }, [open, content, loadFailed]) useEffect(() => { document.body.style.overflow = open ? 'hidden' : '' @@ -42,8 +43,6 @@ export function CvDialog() { return () => document.removeEventListener('keydown', onKey) }, [open]) - if (!available) return null - return ( <> - {content === null ? ( -

Loading…

- ) : content === '' ? ( + {loadFailed ? (

Could not load CV content.

+ ) : content === null ? ( +

Loading…

) : ( )} diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 3469a86..3f6aba8 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -3,7 +3,6 @@ import Image from "next/image" import { useRef, useEffect } from "react" import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter" -import { motion, AnimatePresence } from "framer-motion" import { ChevronDown } from "lucide-react" import { useGlassTilt } from "@/hooks/use-glass-tilt" @@ -17,7 +16,7 @@ const TEASER_HEIGHT = "2.875rem" export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardProps) { const cardRef = useRef(null) - const { ref: tiltRef, tilt, onMouseMove, onMouseLeave } = useGlassTilt(0.6) + const { ref: tiltRef, onMouseMove, onMouseLeave } = useGlassTilt(0.6) useEffect(() => { if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) @@ -43,13 +42,11 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP
{/* Card */} - @@ -60,38 +57,34 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP

{experience.company}

- -

{experience.description}

-
+
+

{experience.description}

+
+
-
+
) } diff --git a/components/ui/hero-image.tsx b/components/ui/hero-image.tsx index 7912645..534fe0b 100644 --- a/components/ui/hero-image.tsx +++ b/components/ui/hero-image.tsx @@ -1,14 +1,8 @@ -"use client" import Image from 'next/image' -import { motion } from 'framer-motion' export function HeroImage() { return ( - +
Andres Sanchez - +
) } - diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index 4b5a940..28a02ff 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -2,8 +2,6 @@ import Image from 'next/image' import { ThemeToggle } from '@/components/ui/theme-toggle' import { useState, useEffect, useRef } from 'react' -import { motion, useScroll, AnimatePresence } from 'framer-motion' -import { useGlassTilt } from '@/hooks/use-glass-tilt' const navLinks = [ { name: "About", href: "#about" }, @@ -13,10 +11,8 @@ const navLinks = [ ] export function Navbar() { - const { scrollYProgress } = useScroll() const [activeSection, setActiveSection] = useState("") const [menuOpen, setMenuOpen] = useState(false) - const { ref: pillRef, tilt, onMouseMove, onMouseLeave } = useGlassTilt(0.8) const breadTopOpenRef = useRef(null) const breadTopCloseRef = useRef(null) @@ -37,7 +33,6 @@ export function Navbar() { return () => observer.disconnect() }, []) - // Close menu on resize to desktop useEffect(() => { const onResize = () => { if (window.innerWidth >= 768) setMenuOpen(false) } window.addEventListener("resize", onResize, { passive: true }) @@ -72,30 +67,18 @@ export function Navbar() { Skip to content
- {/* Scroll Progress Bar */} - + {/* Scroll Progress Bar — CSS scroll-driven, no JS */} +
) diff --git a/components/ui/page-transition.tsx b/components/ui/page-transition.tsx index d8fbdba..f82e6fd 100644 --- a/components/ui/page-transition.tsx +++ b/components/ui/page-transition.tsx @@ -1,22 +1,5 @@ "use client" -import { motion, AnimatePresence } from "framer-motion" -import { usePathname } from "next/navigation" - export const PageTransition = ({ children }: { children: React.ReactNode }) => { - const pathname = usePathname() - - return ( - - - {children} - - - ) + return <>{children} } diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index b30d319..acaccf6 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -2,7 +2,6 @@ import Image from "next/image" import { useRef, useEffect } from 'react' import { ProjectViewModel } from '@/src/interface-adapters/presenters/project-presenter' -import { motion, AnimatePresence } from 'framer-motion' import { ChevronDown } from 'lucide-react' const TEASER_HEIGHT = "2.875rem" @@ -41,52 +40,46 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { ) : null}
- -

{project.description}

+
+

{project.description}

-
- Challenge -

{project.challenge}

-
+
+ Challenge +

{project.challenge}

+
-
- {project.techStack.map((tech) => ( - - {tech} - - ))} +
+ {project.techStack.map((tech) => ( + + {tech} + + ))} +
- +
diff --git a/components/ui/reveal.tsx b/components/ui/reveal.tsx index 804a712..2923a3a 100644 --- a/components/ui/reveal.tsx +++ b/components/ui/reveal.tsx @@ -1,7 +1,6 @@ "use client" -import { motion, useInView, useAnimation } from "framer-motion" -import { useRef, useEffect } from "react" +import { useRef, useEffect, useState } from "react" interface Props { children: React.ReactNode @@ -10,29 +9,41 @@ interface Props { } export const Reveal = ({ children, width = "100%", delay = 0 }: Props) => { - const ref = useRef(null) - const isInView = useInView(ref, { once: true, margin: "-50px" }) - const mainControls = useAnimation() + const outerRef = useRef(null) + const innerRef = useRef(null) + const [mounted, setMounted] = useState(false) + + useEffect(() => { setMounted(true) }, []) useEffect(() => { - if (isInView) { - mainControls.start("visible") - } - }, [isInView, mainControls]) + if (!mounted || !outerRef.current || !innerRef.current) return + const inner = innerRef.current + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + inner.style.animationDelay = `${delay}s` + inner.classList.remove("reveal-hidden") + inner.classList.add("reveal-visible") + observer.disconnect() + } + }, + { rootMargin: "-50px" } + ) + + observer.observe(outerRef.current) + return () => observer.disconnect() + }, [mounted, delay]) + + if (!mounted) { + return
{children}
+ } return ( -
- +
+
{children} - +
) } diff --git a/components/ui/tactile-button.tsx b/components/ui/tactile-button.tsx index 938a3b8..edb722c 100644 --- a/components/ui/tactile-button.tsx +++ b/components/ui/tactile-button.tsx @@ -1,5 +1,4 @@ "use client" -import { motion } from "framer-motion" import { ReactNode } from "react" interface Props { @@ -10,7 +9,7 @@ interface Props { } export function TactileButton({ href, onClick, className = "", children }: Props) { - const base = `relative overflow-hidden group flex items-center justify-center ${className}` + const base = `relative overflow-hidden group flex items-center justify-center active:scale-95 transition-transform duration-100 ${className}` const shine = ( + {children} {shine} - + ) } return ( - + ) } diff --git a/hooks/use-glass-tilt.ts b/hooks/use-glass-tilt.ts index 3b842a6..a69959e 100644 --- a/hooks/use-glass-tilt.ts +++ b/hooks/use-glass-tilt.ts @@ -1,20 +1,21 @@ -import { useRef, useState, useCallback } from "react" +import { useRef, useCallback } from "react" export function useGlassTilt(intensity = 1) { const ref = useRef(null) - const [tilt, setTilt] = useState({ x: 0, y: 0 }) const onMouseMove = useCallback((e: React.MouseEvent) => { - const rect = ref.current?.getBoundingClientRect() - if (!rect) return + const el = ref.current + if (!el) return + const rect = el.getBoundingClientRect() const x = ((e.clientX - rect.left) / rect.width - 0.5) * 6 * intensity const y = ((e.clientY - rect.top) / rect.height - 0.5) * -3 * intensity - setTilt({ x, y }) + el.style.transform = `perspective(600px) rotateY(${x}deg) rotateX(${y}deg)` }, [intensity]) const onMouseLeave = useCallback(() => { - setTilt({ x: 0, y: 0 }) + const el = ref.current + if (el) el.style.transform = "" }, []) - return { ref, tilt, onMouseMove, onMouseLeave } + return { ref, onMouseMove, onMouseLeave } } diff --git a/public/swift-logo.webp b/public/swift-logo.webp new file mode 100644 index 0000000..d9dab3c Binary files /dev/null and b/public/swift-logo.webp differ diff --git a/public/tri.webp b/public/tri.webp new file mode 100644 index 0000000..8e9b98e Binary files /dev/null and b/public/tri.webp differ