From 17a0205500c4e89211fedd0143022284ecad7f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 21:32:45 -0500 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20show=20content=20immediately=20?= =?UTF-8?q?=E2=80=94=20remove=20SSR=20opacity:0=20from=20animated=20compon?= =?UTF-8?q?ents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framer Motion baked opacity:0 into server-rendered HTML via initial="hidden", making the page appear blank until JS hydrated (~20s on mobile). Fix: - page-transition: no-op wrapper (content always visible) - reveal, animated-skills, animated-stats: mounted guard renders plain HTML on SSR; switches to IntersectionObserver + CSS animation after hydration --- components/ui/animated-skills.tsx | 64 +++++++++++++++++-------------- components/ui/animated-stats.tsx | 64 ++++++++++++++++++------------- components/ui/page-transition.tsx | 19 +-------- components/ui/reveal.tsx | 51 ++++++++++++++---------- 4 files changed, 104 insertions(+), 94 deletions(-) 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/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/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} - +
) } From 2df3a6753e55ec033a43c3557f1bae1743bba11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 21:32:49 -0500 Subject: [PATCH 2/5] fix: defer cv.md fetch until dialog opens CvDialog was fetching /cv.md on every page load (from GitHub CDN) just to decide whether to show the button, blocking Safari's loading indicator until the request resolved. Now fetches only when the user taps "View CV". --- components/ui/cv-dialog.tsx | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) 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…

) : ( )} From 7bdaa286642159ec976de1feffc7229890b40ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 21:32:53 -0500 Subject: [PATCH 3/5] perf: compress navbar logos from PNG to webp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift-logo.png: 192KB → 16KB (12× smaller) tri.png: 12KB → 8KB Both loaded on every page view in the fixed navbar. --- public/swift-logo.webp | Bin 0 -> 16602 bytes public/tri.webp | Bin 0 -> 7694 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 public/swift-logo.webp create mode 100644 public/tri.webp diff --git a/public/swift-logo.webp b/public/swift-logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..d9dab3c8604fdec2b4351d0840fbdc152b5d9cf6 GIT binary patch literal 16602 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 Date: Thu, 28 May 2026 21:33:03 -0500 Subject: [PATCH 4/5] perf: reduce GPU-heavy rendering on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - glass-card: backdrop-blur-2xl only on md+; mobile gets opaque background - navbar pill: same — opaque on mobile, blur only on desktop - fluid blobs: hidden on touch devices (filter:blur(50px) animated shapes) - contact circles: removed backdrop-blur-md (over solid bg, no visual benefit) - about glow: hidden on mobile (blur-[120px] on 600×600 div) - globals.css: CSS keyframes for reveal, skill, stat, float, marquee, scroll-progress animations replacing Framer Motion equivalents --- app/globals.css | 70 ++++++++++++++++++++++++++++++++- components/sections/about.tsx | 2 +- components/sections/contact.tsx | 8 ++-- 3 files changed, 73 insertions(+), 7 deletions(-) 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 From bfd09182d3695d9f1a3747548f31186a231968c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 21:33:11 -0500 Subject: [PATCH 5/5] =?UTF-8?q?perf:=20remove=20Framer=20Motion=20from=20b?= =?UTF-8?q?undle=20=E2=80=94=20replace=20with=20CSS=20and=20vanilla=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framer Motion (~120KB) was pulled into the initial bundle by multiple components in the critical render path, adding ~20s of JS parse/execution time on mobile Safari. Replacements: - hero-image: CSS @keyframes float animation - animated-marquee: CSS @keyframes infinite scroll - back-to-top: CSS opacity/translate transition classes - tactile-button: CSS active:scale-95 - use-glass-tilt: direct DOM style writes (no spring, CSS transition instead) - experience-card + project-card: CSS grid-template-rows 0fr→1fr expand - navbar: CSS scroll-driven progress bar, CSS max-height mobile menu, webp logo references, backdrop-blur deferred to md+ breakpoint Framer Motion is now absent from all source files and the output bundle. --- components/ui/animated-marquee.tsx | 17 ++----- components/ui/back-to-top.tsx | 24 ++++------ components/ui/experience-card.tsx | 53 +++++++++------------ components/ui/hero-image.tsx | 11 +---- components/ui/navbar.tsx | 75 ++++++++++-------------------- components/ui/project-card.tsx | 67 ++++++++++++-------------- components/ui/tactile-button.tsx | 11 ++--- hooks/use-glass-tilt.ts | 15 +++--- 8 files changed, 103 insertions(+), 170 deletions(-) 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/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/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/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/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 } }