From a644027c67f7120788167c2d19431f86401c3318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 11:09:56 -0500 Subject: [PATCH 01/51] refactor: migrate src/di.ts into src/di/modules/ per clean architecture Each domain (project, experience, about, contact) gets its own module file. src/di/index.ts re-exports all controllers so existing imports are unchanged. --- src/di.ts | 37 ------------------------------------ src/di/index.ts | 4 ++++ src/di/modules/about.ts | 7 +++++++ src/di/modules/contact.ts | 7 +++++++ src/di/modules/experience.ts | 7 +++++++ src/di/modules/project.ts | 7 +++++++ 6 files changed, 32 insertions(+), 37 deletions(-) delete mode 100644 src/di.ts create mode 100644 src/di/index.ts create mode 100644 src/di/modules/about.ts create mode 100644 src/di/modules/contact.ts create mode 100644 src/di/modules/experience.ts create mode 100644 src/di/modules/project.ts diff --git a/src/di.ts b/src/di.ts deleted file mode 100644 index 4b62ea9..0000000 --- a/src/di.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { GetProjectsUseCase } from "./application/use-cases/get-projects" -import { GetExperiencesUseCase } from "./application/use-cases/get-experiences" -import { GetAboutUseCase } from "./application/use-cases/get-about" -import { GetContactUseCase } from "./application/use-cases/get-contact" -import { InMemoryProjectRepository } from "./infrastructure/repositories/in-memory-project-repository" -import { InMemoryExperienceRepository } from "./infrastructure/repositories/in-memory-experience-repository" -import { InMemoryAboutRepository } from "./infrastructure/repositories/in-memory-about-repository" -import { InMemoryContactRepository } from "./infrastructure/repositories/in-memory-contact-repository" -import { ProjectController } from "./interface-adapters/controllers/project-controller" -import { ExperienceController } from "./interface-adapters/controllers/experience-controller" -import { AboutController } from "./interface-adapters/controllers/about-controller" -import { ContactController } from "./interface-adapters/controllers/contact-controller" - -// Repositories -const projectRepo = new InMemoryProjectRepository() -const experienceRepo = new InMemoryExperienceRepository() -const aboutRepo = new InMemoryAboutRepository() -const contactRepo = new InMemoryContactRepository() - -// Use Cases -const getProjectsUseCase = new GetProjectsUseCase(projectRepo) -const getExperiencesUseCase = new GetExperiencesUseCase(experienceRepo) -const getAboutUseCase = new GetAboutUseCase(aboutRepo) -const getContactUseCase = new GetContactUseCase(contactRepo) - -// Controllers -const projectController = new ProjectController(getProjectsUseCase) -const experienceController = new ExperienceController(getExperiencesUseCase) -const aboutController = new AboutController(getAboutUseCase) -const contactController = new ContactController(getContactUseCase) - -export { - projectController, - experienceController, - aboutController, - contactController, -} diff --git a/src/di/index.ts b/src/di/index.ts new file mode 100644 index 0000000..312329b --- /dev/null +++ b/src/di/index.ts @@ -0,0 +1,4 @@ +export { projectController } from "./modules/project" +export { experienceController } from "./modules/experience" +export { aboutController } from "./modules/about" +export { contactController } from "./modules/contact" diff --git a/src/di/modules/about.ts b/src/di/modules/about.ts new file mode 100644 index 0000000..cb5538c --- /dev/null +++ b/src/di/modules/about.ts @@ -0,0 +1,7 @@ +import { GetAboutUseCase } from "@/src/application/use-cases/get-about" +import { InMemoryAboutRepository } from "@/src/infrastructure/repositories/in-memory-about-repository" +import { AboutController } from "@/src/interface-adapters/controllers/about-controller" + +const aboutRepo = new InMemoryAboutRepository() +const getAboutUseCase = new GetAboutUseCase(aboutRepo) +export const aboutController = new AboutController(getAboutUseCase) diff --git a/src/di/modules/contact.ts b/src/di/modules/contact.ts new file mode 100644 index 0000000..dba7af3 --- /dev/null +++ b/src/di/modules/contact.ts @@ -0,0 +1,7 @@ +import { GetContactUseCase } from "@/src/application/use-cases/get-contact" +import { InMemoryContactRepository } from "@/src/infrastructure/repositories/in-memory-contact-repository" +import { ContactController } from "@/src/interface-adapters/controllers/contact-controller" + +const contactRepo = new InMemoryContactRepository() +const getContactUseCase = new GetContactUseCase(contactRepo) +export const contactController = new ContactController(getContactUseCase) diff --git a/src/di/modules/experience.ts b/src/di/modules/experience.ts new file mode 100644 index 0000000..2a771af --- /dev/null +++ b/src/di/modules/experience.ts @@ -0,0 +1,7 @@ +import { GetExperiencesUseCase } from "@/src/application/use-cases/get-experiences" +import { InMemoryExperienceRepository } from "@/src/infrastructure/repositories/in-memory-experience-repository" +import { ExperienceController } from "@/src/interface-adapters/controllers/experience-controller" + +const experienceRepo = new InMemoryExperienceRepository() +const getExperiencesUseCase = new GetExperiencesUseCase(experienceRepo) +export const experienceController = new ExperienceController(getExperiencesUseCase) diff --git a/src/di/modules/project.ts b/src/di/modules/project.ts new file mode 100644 index 0000000..457b70e --- /dev/null +++ b/src/di/modules/project.ts @@ -0,0 +1,7 @@ +import { GetProjectsUseCase } from "@/src/application/use-cases/get-projects" +import { InMemoryProjectRepository } from "@/src/infrastructure/repositories/in-memory-project-repository" +import { ProjectController } from "@/src/interface-adapters/controllers/project-controller" + +const projectRepo = new InMemoryProjectRepository() +const getProjectsUseCase = new GetProjectsUseCase(projectRepo) +export const projectController = new ProjectController(getProjectsUseCase) From 422a253fcf509fbb531f685c020aa5d92a02de6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 11:21:15 -0500 Subject: [PATCH 02/51] chore: expand local permissions to wildcard allow-all --- .claude/settings.local.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 626a3f8..f668905 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,8 +1,12 @@ { "permissions": { "allow": [ - "Bash(node -e \"const l = require\\(''lucide-react''\\); console.log\\(Object.keys\\(l\\).filter\\(k => k.toLowerCase\\(\\).includes\\(''github''\\) || k.toLowerCase\\(\\).includes\\(''linkedin''\\)\\)\\)\")", - "Bash(node -e \"const l = require\\(''lucide-react''\\); console.log\\(l.Github === l.GithubIcon, l.Linkedin === l.LinkedinIcon\\)\")" + "Bash(*)", + "Edit(*)", + "Write(*)", + "Read(*)", + "Glob(*)", + "Grep(*)" ] } } \ No newline at end of file From e182416922ee8c4aca35c78cf0bdf5303fd158c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 11:21:21 -0500 Subject: [PATCH 03/51] refactor: add presenter layer to interface-adapters Introduces ViewModel types and presenter functions for all four domains (about, contact, experience, project). Controllers now map entities to ViewModels via presenters before returning data. UI components import ViewModel types from the presenter layer instead of directly from entities, closing the last boundary gap in the clean architecture. --- components/ui/animated-stats.tsx | 4 ++-- components/ui/experience-card.tsx | 4 ++-- components/ui/project-card.tsx | 4 ++-- .../controllers/about-controller.ts | 7 ++++--- .../controllers/contact-controller.ts | 7 ++++--- .../controllers/experience-controller.ts | 13 ++++-------- .../controllers/project-controller.ts | 8 ++++---- .../presenters/about-presenter.ts | 20 +++++++++++++++++++ .../presenters/contact-presenter.ts | 13 ++++++++++++ .../presenters/experience-presenter.ts | 15 ++++++++++++++ .../presenters/project-presenter.ts | 14 +++++++++++++ 11 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 src/interface-adapters/presenters/about-presenter.ts create mode 100644 src/interface-adapters/presenters/contact-presenter.ts create mode 100644 src/interface-adapters/presenters/experience-presenter.ts create mode 100644 src/interface-adapters/presenters/project-presenter.ts diff --git a/components/ui/animated-stats.tsx b/components/ui/animated-stats.tsx index 1c65506..ea8dee1 100644 --- a/components/ui/animated-stats.tsx +++ b/components/ui/animated-stats.tsx @@ -1,6 +1,6 @@ "use client" import { motion } from "framer-motion" -import { AboutStat } from "@/src/entities/about" +import { AboutStatViewModel } from "@/src/interface-adapters/presenters/about-presenter" const container = { hidden: { opacity: 0 }, @@ -17,7 +17,7 @@ const item = { visible: { opacity: 1, y: 0 } } -export function AnimatedStats({ stats }: { stats: AboutStat[] }) { +export function AnimatedStats({ stats }: { stats: AboutStatViewModel[] }) { return ( { - return this.getAboutUseCase.execute() + async getAbout(): Promise { + const about = await this.getAboutUseCase.execute() + return presentAbout(about) } } diff --git a/src/interface-adapters/controllers/contact-controller.ts b/src/interface-adapters/controllers/contact-controller.ts index 53fc3a5..cf422fe 100644 --- a/src/interface-adapters/controllers/contact-controller.ts +++ b/src/interface-adapters/controllers/contact-controller.ts @@ -1,10 +1,11 @@ import { GetContactUseCase } from "../../application/use-cases/get-contact" -import { Contact } from "../../entities/contact" +import { ContactViewModel, presentContact } from "../presenters/contact-presenter" export class ContactController { constructor(private getContactUseCase: GetContactUseCase) { } - async getContact(): Promise { - return this.getContactUseCase.execute() + async getContact(): Promise { + const contact = await this.getContactUseCase.execute() + return presentContact(contact) } } diff --git a/src/interface-adapters/controllers/experience-controller.ts b/src/interface-adapters/controllers/experience-controller.ts index 5b27f85..c84e4cd 100644 --- a/src/interface-adapters/controllers/experience-controller.ts +++ b/src/interface-adapters/controllers/experience-controller.ts @@ -1,16 +1,11 @@ import { GetExperiencesUseCase } from "../../application/use-cases/get-experiences" -import { Experience } from "../../entities/experience" - -// We can create a View Model here if we want to add UI-specific properties (like mapping iconName to actual Icon reference) -// BUT, since we are in strict clean arch, we shouldn't import UI libraries (lucide-react) even in the Controller if we can avoid it. -// The Presenter pattern usually handles formatting. -// For this simpler refactor, the Controller can return the Entity or a DTO. -// Let's return the Entity for now and handle the Icon mapping in the UI Component (which acts as the Viewer/Presenter). +import { ExperienceViewModel, presentExperiences } from "../presenters/experience-presenter" export class ExperienceController { constructor(private getExperiencesUseCase: GetExperiencesUseCase) { } - async getExperiences(): Promise { - return this.getExperiencesUseCase.execute() + async getExperiences(): Promise { + const experiences = await this.getExperiencesUseCase.execute() + return presentExperiences(experiences) } } diff --git a/src/interface-adapters/controllers/project-controller.ts b/src/interface-adapters/controllers/project-controller.ts index 325af2f..b0c50d9 100644 --- a/src/interface-adapters/controllers/project-controller.ts +++ b/src/interface-adapters/controllers/project-controller.ts @@ -1,11 +1,11 @@ import { GetProjectsUseCase } from "../../application/use-cases/get-projects" +import { ProjectViewModel, presentProjects } from "../presenters/project-presenter" export class ProjectController { constructor(private getProjectsUseCase: GetProjectsUseCase) { } - async getProjects() { - // In a real API route, we might validate request params here. - // For RSCs, we just delegate to the use case. - return this.getProjectsUseCase.execute() + async getProjects(): Promise { + const projects = await this.getProjectsUseCase.execute() + return presentProjects(projects) } } diff --git a/src/interface-adapters/presenters/about-presenter.ts b/src/interface-adapters/presenters/about-presenter.ts new file mode 100644 index 0000000..3a270ba --- /dev/null +++ b/src/interface-adapters/presenters/about-presenter.ts @@ -0,0 +1,20 @@ +import { About } from "@/src/entities/about" + +export interface AboutStatViewModel { + label: string + value: string +} + +export interface AboutViewModel { + name: string + heroDescription: string[] + bio: string[] + stats: AboutStatViewModel[] + skills: string[] + personalBio: string[] + hobbies: string[] +} + +export function presentAbout(about: About): AboutViewModel { + return about +} diff --git a/src/interface-adapters/presenters/contact-presenter.ts b/src/interface-adapters/presenters/contact-presenter.ts new file mode 100644 index 0000000..ae6570b --- /dev/null +++ b/src/interface-adapters/presenters/contact-presenter.ts @@ -0,0 +1,13 @@ +import { Contact } from "@/src/entities/contact" + +export interface ContactViewModel { + location: string + timezone: string + email: string + linkedinUrl: string + githubUrl: string +} + +export function presentContact(contact: Contact): ContactViewModel { + return contact +} diff --git a/src/interface-adapters/presenters/experience-presenter.ts b/src/interface-adapters/presenters/experience-presenter.ts new file mode 100644 index 0000000..ea64e49 --- /dev/null +++ b/src/interface-adapters/presenters/experience-presenter.ts @@ -0,0 +1,15 @@ +import { Experience } from "@/src/entities/experience" + +export interface ExperienceViewModel { + id: string + year: string + title: string + company: string + description: string + imagePath: string + color: string +} + +export function presentExperiences(experiences: Experience[]): ExperienceViewModel[] { + return experiences +} diff --git a/src/interface-adapters/presenters/project-presenter.ts b/src/interface-adapters/presenters/project-presenter.ts new file mode 100644 index 0000000..5a7ec72 --- /dev/null +++ b/src/interface-adapters/presenters/project-presenter.ts @@ -0,0 +1,14 @@ +import { Project } from "@/src/entities/project" + +export interface ProjectViewModel { + id: string + title: string + role: string + description: string + challenge: string + techStack: string[] +} + +export function presentProjects(projects: Project[]): ProjectViewModel[] { + return projects +} From a1c555e5862b7569cb1b144811355b2d65461b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:04:33 -0500 Subject: [PATCH 04/51] feat: implement animated fluid blob background shapes in the hero section --- app/globals.css | 49 ++++++++++++++++++++++++++++++++++++ components/sections/hero.tsx | 8 ++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/app/globals.css b/app/globals.css index 3a43e6a..009612f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,6 +18,9 @@ --foreground: #171717; --accent: #6366f1; --secondary: #10b981; + --blob-primary: #2563eb; /* suit blue — hue 220°, full saturation */ + --blob-secondary: #d97706; /* tie gold — hue 38°, full saturation */ + --blob-tertiary: #9333ea; /* shirt lavender — hue 285°, full saturation */ } /* Dark Theme (Class-based) */ @@ -43,4 +46,50 @@ body { 100% { transform: translateX(200%); } +} + +@keyframes floatFluid { + 0% { transform: translate(0, 0) scale(1) rotate(0deg); } + 100% { transform: translate(30px, 50px) scale(1.1) rotate(10deg); } +} + +.fluid-shape { + position: absolute; + border-radius: 50%; + filter: blur(50px); + opacity: 0.8; + animation: floatFluid 20s cubic-bezier(0.4, 0, 0.2, 1) infinite alternate; + pointer-events: none; +} + +.fluid-shape-1 { + width: 60vh; + height: 60vh; + top: -10%; + right: -10%; + background: radial-gradient(circle, var(--blob-primary), transparent 65%); + animation-delay: 0s; +} + +.fluid-shape-2 { + width: 50vh; + height: 50vh; + bottom: -10%; + left: -10%; + background: radial-gradient(circle, var(--blob-secondary), transparent 65%); + animation-delay: -5s; +} + +.fluid-shape-3 { + width: 30vh; + height: 30vh; + top: 40%; + left: 40%; + background: radial-gradient(circle, var(--blob-tertiary), transparent 65%); + animation-delay: -10s; + opacity: 0.65; +} + +@media (prefers-reduced-motion: reduce) { + .fluid-shape { animation: none; } } \ No newline at end of file diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index 575fedc..54f40d7 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -49,8 +49,12 @@ export async function Hero() { - {/* Background Glow Effect */} -
+ {/* Background Fluid Blobs */} +
+
+
+
+
) } From 25851783167981120d363dfa5295d12ee29df3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:21:22 -0500 Subject: [PATCH 05/51] feat: implement react cache for about data and update UI components and documentation --- README.md | 40 ++++++++++++------------------ app/globals.css | 22 +++++++++++----- components/sections/about.tsx | 4 +-- components/sections/hero.tsx | 8 +++--- components/sections/more-about.tsx | 4 +-- src/di/index.ts | 2 +- src/di/modules/about.ts | 2 ++ 7 files changed, 43 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e215bc4..0dad8c0 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,28 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# andsang.github.io -## Getting Started +Personal portfolio site for Andrés Sánchez. Built with Next.js (App Router, TypeScript, Tailwind CSS), deployed to GitHub Pages as a static export. -First, run the development server: +## Dev ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +npx next dev --port 3000 --webpack ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +> Use `--webpack` — Turbopack panics with `output: 'export'` mode in Next.js 16. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Open [http://localhost:3000](http://localhost:3000). -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Build -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +```bash +npm run build +``` -## Deploy on Vercel +Outputs to `out/` via static export. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Stack -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- Next.js 16 (App Router, static export) +- TypeScript +- Tailwind CSS +- Clean Architecture (entities → use cases → interface adapters → infrastructure) diff --git a/app/globals.css b/app/globals.css index 009612f..004c841 100644 --- a/app/globals.css +++ b/app/globals.css @@ -18,9 +18,12 @@ --foreground: #171717; --accent: #6366f1; --secondary: #10b981; - --blob-primary: #2563eb; /* suit blue — hue 220°, full saturation */ - --blob-secondary: #d97706; /* tie gold — hue 38°, full saturation */ - --blob-tertiary: #9333ea; /* shirt lavender — hue 285°, full saturation */ + --blob-primary: #06e2ff; + /* suit blue — hue 220°, full saturation */ + --blob-secondary: #d97706; + /* tie gold — hue 38°, full saturation */ + --blob-tertiary: #9333ea; + /* shirt lavender — hue 285°, full saturation */ } /* Dark Theme (Class-based) */ @@ -49,8 +52,13 @@ body { } @keyframes floatFluid { - 0% { transform: translate(0, 0) scale(1) rotate(0deg); } - 100% { transform: translate(30px, 50px) scale(1.1) rotate(10deg); } + 0% { + transform: translate(0, 0) scale(1) rotate(0deg); + } + + 100% { + transform: translate(30px, 50px) scale(1.1) rotate(10deg); + } } .fluid-shape { @@ -91,5 +99,7 @@ body { } @media (prefers-reduced-motion: reduce) { - .fluid-shape { animation: none; } + .fluid-shape { + animation: none; + } } \ No newline at end of file diff --git a/components/sections/about.tsx b/components/sections/about.tsx index 03c7a91..df559e6 100644 --- a/components/sections/about.tsx +++ b/components/sections/about.tsx @@ -1,10 +1,10 @@ -import { aboutController } from "@/src/di" +import { getAbout } from "@/src/di" import { Reveal } from "@/components/ui/reveal" import { AnimatedStats } from "@/components/ui/animated-stats" import { AnimatedMarquee } from '@/components/ui/animated-marquee' export async function AboutSection() { - const about = await aboutController.getAbout() + const about = await getAbout() return (
diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index 54f40d7..a0969b4 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -1,10 +1,10 @@ -import { aboutController } from '@/src/di' +import { getAbout } from '@/src/di' import { CvDialog } from '@/components/ui/cv-dialog' import { HeroImage } from '@/components/ui/hero-image' import { TactileButton } from '@/components/ui/tactile-button' export async function Hero() { - const about = await aboutController.getAbout() + const about = await getAbout() return (
@@ -14,11 +14,11 @@ export async function Hero() {

{about.name}

-

+

Developing
High-Availability
Mobile Apps -

+
{about.heroDescription.map((line, i) => (

{line}

diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index 3f33453..2dc525b 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -1,8 +1,8 @@ import Image from 'next/image' -import { aboutController } from "@/src/di" +import { getAbout } from "@/src/di" export async function MoreAboutSection() { - const about = await aboutController.getAbout() + const about = await getAbout() return (
diff --git a/src/di/index.ts b/src/di/index.ts index 312329b..b2f1a2d 100644 --- a/src/di/index.ts +++ b/src/di/index.ts @@ -1,4 +1,4 @@ export { projectController } from "./modules/project" export { experienceController } from "./modules/experience" -export { aboutController } from "./modules/about" +export { aboutController, getAbout } from "./modules/about" export { contactController } from "./modules/contact" diff --git a/src/di/modules/about.ts b/src/di/modules/about.ts index cb5538c..1064f66 100644 --- a/src/di/modules/about.ts +++ b/src/di/modules/about.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { GetAboutUseCase } from "@/src/application/use-cases/get-about" import { InMemoryAboutRepository } from "@/src/infrastructure/repositories/in-memory-about-repository" import { AboutController } from "@/src/interface-adapters/controllers/about-controller" @@ -5,3 +6,4 @@ import { AboutController } from "@/src/interface-adapters/controllers/about-cont const aboutRepo = new InMemoryAboutRepository() const getAboutUseCase = new GetAboutUseCase(aboutRepo) export const aboutController = new AboutController(getAboutUseCase) +export const getAbout = cache(() => aboutController.getAbout()) From 6cf913a825e28ec3734da536233dd6ab69f4af57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:25:36 -0500 Subject: [PATCH 06/51] style: update hero description container width to full-width and remove max-width constraint --- components/sections/hero.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index a0969b4..a06f44b 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -19,7 +19,7 @@ export async function Hero() { High-Availability
Mobile Apps -
+
{about.heroDescription.map((line, i) => (

{line}

))} From eb8b055538cdc786ea9617d400439d2b33a02eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:28:35 -0500 Subject: [PATCH 07/51] refactor: replace hero description container div with paragraph for semantic structure and update line wrapping behavior --- components/sections/hero.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index a06f44b..1b27807 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -19,11 +19,11 @@ export async function Hero() { High-Availability
Mobile Apps -
+

{about.heroDescription.map((line, i) => ( -

{line}

+ {line}{' '} ))} -
+

Date: Wed, 27 May 2026 13:32:50 -0500 Subject: [PATCH 08/51] feat: implement mobile-responsive hero layout with conditional image rendering and updated spacing --- components/sections/hero.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index 1b27807..9b14f35 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -7,7 +7,7 @@ export async function Hero() { const about = await getAbout() return ( -
+
{/* Text Column */}
@@ -25,7 +25,12 @@ export async function Hero() { ))}

-
+ {/* Mobile-only image, between description and CTAs */} +
+ +
+ +
- {/* Image Column */} -
+ {/* Image Column — desktop only */} +
From 6d31fccb2346ef8a09ea1981d5c754e5ea879315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:37:04 -0500 Subject: [PATCH 09/51] style: reduce vertical padding across all section components from 24 to 16 units --- components/sections/about.tsx | 2 +- components/sections/contact.tsx | 2 +- components/sections/experience.tsx | 2 +- components/sections/more-about.tsx | 2 +- components/sections/projects.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/sections/about.tsx b/components/sections/about.tsx index df559e6..9dc8992 100644 --- a/components/sections/about.tsx +++ b/components/sections/about.tsx @@ -7,7 +7,7 @@ export async function AboutSection() { const about = await getAbout() return ( -
+

diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 0af6184..0427ef5 100644 --- a/components/sections/contact.tsx +++ b/components/sections/contact.tsx @@ -5,7 +5,7 @@ export async function ContactSection() { const contact = await contactController.getContact() return ( -
+

Get in Touch diff --git a/components/sections/experience.tsx b/components/sections/experience.tsx index d03edd9..7f6c212 100644 --- a/components/sections/experience.tsx +++ b/components/sections/experience.tsx @@ -5,7 +5,7 @@ export async function ExperienceSection() { const experiences = await experienceController.getExperiences() return ( -
+

Professional Journey diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index 2dc525b..092057c 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -5,7 +5,7 @@ export async function MoreAboutSection() { const about = await getAbout() return ( -
+
{/* Text Column */}
diff --git a/components/sections/projects.tsx b/components/sections/projects.tsx index dcd4cf0..a50f787 100644 --- a/components/sections/projects.tsx +++ b/components/sections/projects.tsx @@ -5,7 +5,7 @@ export async function ProjectsSection() { const projects = await projectController.getProjects() return ( -
+

Selected Projects From 85ed310b6c5cce5244cdeb8d5c2117db95d20e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:54:56 -0500 Subject: [PATCH 10/51] feat: add expandable descriptions to experience cards and apply line-clamping to project card content --- components/ui/experience-card.tsx | 55 +++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index c6e6b03..bc6dc56 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -1,34 +1,75 @@ "use client" import Image from "next/image" +import { useState } from "react" import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter" -import { motion } from "framer-motion" +import { motion, AnimatePresence } from "framer-motion" +import { ChevronDown } from "lucide-react" interface ExperienceCardProps { experience: ExperienceViewModel isEven: boolean } +// ~2 lines at text-sm / leading-relaxed (14px × 1.625 × 2 ≈ 46px) +const TEASER_HEIGHT = "2.875rem" + export function ExperienceCard({ experience, isEven }: ExperienceCardProps) { + const [isOpen, setIsOpen] = useState(false) + return (
{/* Content Side */}
- - + {experience.year} -

{experience.title}

+

+ {experience.title} +

{experience.company}

-

- {experience.description} -

+ + {/* Teaser + expand */} + +

+ {experience.description} +

+
+ +
From 8de30077fcb2174e95a7b6e7010eb4deb6ba1608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 13:55:03 -0500 Subject: [PATCH 11/51] style: enforce uniform height and line clamping for project card content areas --- components/ui/project-card.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index b8b4e22..41f7407 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -4,18 +4,18 @@ import { motion } from 'framer-motion' export function ProjectCard({ project }: { project: ProjectViewModel }) { return ( -

{project.title}

{project.role}

-

{project.description}

+

{project.description}

Challenge - {project.challenge} +

{project.challenge}

From f082219bf00bc248764d4764084ff1e0023eee77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Wed, 27 May 2026 14:20:37 -0500 Subject: [PATCH 12/51] feat: add project thumbnails to project entities and display them in cards --- components/ui/project-card.tsx | 50 +++++++++++------- public/projects/bancolombia.webp | Bin 0 -> 31462 bytes public/projects/iuvibanking.webp | Bin 0 -> 34782 bytes public/projects/supercines.webp | Bin 0 -> 15208 bytes src/entities/project.ts | 1 + .../in-memory-project-repository.ts | 9 ++-- .../presenters/project-presenter.ts | 1 + 7 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 public/projects/bancolombia.webp create mode 100644 public/projects/iuvibanking.webp create mode 100644 public/projects/supercines.webp diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index 41f7407..4fc0609 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -1,4 +1,5 @@ "use client" +import Image from "next/image" import { ProjectViewModel } from '@/src/interface-adapters/presenters/project-presenter' import { motion } from 'framer-motion' @@ -6,28 +7,41 @@ export function ProjectCard({ project }: { project: ProjectViewModel }) { return ( -

{project.title}

-

{project.role}

+ {project.imagePath && ( +
+ {project.title} +
+ )} + +
+

{project.title}

+

{project.role}

-
-

{project.description}

-
- Challenge -

{project.challenge}

+
+

{project.description}

+
+ Challenge +

{project.challenge}

+
-
-
- {project.techStack.map((tech) => ( - - {tech} - - ))} +
+ {project.techStack.map((tech) => ( + + {tech} + + ))} +
) diff --git a/public/projects/bancolombia.webp b/public/projects/bancolombia.webp new file mode 100644 index 0000000000000000000000000000000000000000..7aeb86fce252dcc8537562c54d4036f31e8a935b GIT binary patch literal 31462 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 O03K=M6bU3MxBvjW{`$xO literal 0 HcmV?d00001 diff --git a/public/projects/supercines.webp b/public/projects/supercines.webp new file mode 100644 index 0000000000000000000000000000000000000000..567eebeba5e03ac88d35d226ef21966dde2f8234 GIT binary patch literal 15208 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 GFaAHhu3i}c literal 0 HcmV?d00001 diff --git a/src/entities/project.ts b/src/entities/project.ts index ac32a0a..0cd97cb 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -5,4 +5,5 @@ export interface Project { description: string challenge: string techStack: string[] + imagePath?: string } diff --git a/src/infrastructure/repositories/in-memory-project-repository.ts b/src/infrastructure/repositories/in-memory-project-repository.ts index e8994f7..876350e 100644 --- a/src/infrastructure/repositories/in-memory-project-repository.ts +++ b/src/infrastructure/repositories/in-memory-project-repository.ts @@ -9,7 +9,8 @@ export class InMemoryProjectRepository implements ProjectRepository { role: 'Senior iOS Engineer — Technical Coordinator', description: 'Production certification authority across React microfrontends, React Native, and the iOS native layer — cutting deployment time by 60% through Server-Driven UI.', challenge: 'Maintaining zero-defect handoffs across independent teams through certification gates, failure triage, and ownership routing — without people-management authority.', - techStack: ['React', 'React Native', 'Swift', 'TypeScript', 'AWS', 'Modyo', 'OIDC', 'Server-Driven UI'] + techStack: ['React', 'React Native', 'Swift', 'TypeScript', 'AWS', 'Modyo', 'OIDC', 'Server-Driven UI'], + imagePath: '/projects/iuvibanking.webp', } const bancolombia: Project = { @@ -18,7 +19,8 @@ export class InMemoryProjectRepository implements ProjectRepository { role: 'Senior iOS Engineer', description: 'iOS banking app for 10M+ active users — 99.5% uptime, 95% on-time SLA, full security compliance stack.', challenge: 'Modernizing a legacy MVC codebase to VIPER and state-driven patterns without disrupting a live app under continuous high-volume traffic.', - techStack: ['Swift', 'VIPER', 'MVVM', 'Combine', 'Firebase', 'freeRASP'] + techStack: ['Swift', 'VIPER', 'MVVM', 'Combine', 'Firebase', 'freeRASP'], + imagePath: '/projects/bancolombia.webp', } const supercines: Project = { @@ -27,7 +29,8 @@ export class InMemoryProjectRepository implements ProjectRepository { role: 'iOS Developer', description: 'Cinema app for Supercines — custom seat-selection SDK, PCI DSS-compliant payments, and CoreData offline support.', challenge: 'Building a pixel-accurate interactive seat map SDK with custom touch handling, then wiring a compliant payment gateway under strict PCI DSS requirements.', - techStack: ['Swift', 'Objective-C', 'UIKit', 'CoreData', 'MapKit', 'PCI DSS'] + techStack: ['Swift', 'Objective-C', 'UIKit', 'CoreData', 'MapKit', 'PCI DSS'], + imagePath: '/projects/supercines.webp', } return [iuvibanking, bancolombia, supercines] diff --git a/src/interface-adapters/presenters/project-presenter.ts b/src/interface-adapters/presenters/project-presenter.ts index 5a7ec72..38929d5 100644 --- a/src/interface-adapters/presenters/project-presenter.ts +++ b/src/interface-adapters/presenters/project-presenter.ts @@ -7,6 +7,7 @@ export interface ProjectViewModel { description: string challenge: string techStack: string[] + imagePath?: string } export function presentProjects(projects: Project[]): ProjectViewModel[] { From 610bd39d5370b9472a091c5e5a9287b58664c7da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 09:43:06 -0500 Subject: [PATCH 13/51] feat: back-to-top navigation component --- app/page.tsx | 2 ++ components/ui/back-to-top.tsx | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 components/ui/back-to-top.tsx diff --git a/app/page.tsx b/app/page.tsx index b0d00fb..bf3f4ae 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,6 +6,7 @@ import { ExperienceSection } from '@/components/sections/experience' import { MoreAboutSection } from '@/components/sections/more-about' import { ContactSection } from '@/components/sections/contact' import { PageTransition } from '@/components/ui/page-transition' +import { BackToTop } from '@/components/ui/back-to-top' export default function Home() { return ( @@ -19,6 +20,7 @@ export default function Home() { + ) } diff --git a/components/ui/back-to-top.tsx b/components/ui/back-to-top.tsx new file mode 100644 index 0000000..aade491 --- /dev/null +++ b/components/ui/back-to-top.tsx @@ -0,0 +1,33 @@ +"use client" + +import { useEffect, useState, startTransition } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { ArrowUp } from "lucide-react" + +export function BackToTop() { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const onScroll = () => startTransition(() => setVisible(window.scrollY > 400)) + window.addEventListener("scroll", onScroll, { passive: true }) + return () => window.removeEventListener("scroll", onScroll) + }, []) + + return ( + + {visible && ( + window.scrollTo({ top: 0, behavior: "smooth" })} + className="fixed bottom-8 right-8 z-50 p-3 rounded-full glass-card border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:text-accent dark:hover:text-accent transition-colors shadow-lg" + aria-label="Back to top" + > + + + )} + + ) +} From 8d281860ad0b2e6b716d15f64bf3625c1a14be28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 09:43:53 -0500 Subject: [PATCH 14/51] refactor: hoist navbar --- components/ui/navbar.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index f6fe30d..33ba2f6 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -4,6 +4,13 @@ import { ThemeToggle } from '@/components/ui/theme-toggle' import { useState, useEffect } from 'react' import { motion, useScroll } from 'framer-motion' +const navLinks = [ + { name: "About", href: "#about" }, + { name: "Projects", href: "#projects" }, + { name: "More", href: "#more-about" }, + { name: "Contact", href: "#contact" }, +] + export function Navbar() { const { scrollYProgress } = useScroll() const [activeSection, setActiveSection] = useState("") @@ -26,13 +33,6 @@ export function Navbar() { return () => observer.disconnect() }, []) - const navLinks = [ - { name: "About", href: "#about" }, - { name: "Projects", href: "#projects" }, - { name: "More", href: "#more-about" }, - { name: "Contact", href: "#contact" }, - ] - return ( <> Date: Thu, 28 May 2026 09:44:35 -0500 Subject: [PATCH 15/51] refactor: make cv button tactile --- components/ui/cv-dialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx index d847ddd..0223fd3 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { X, Download } from 'lucide-react' +import { TactileButton } from '@/components/ui/tactile-button' const REPO = 'AndSanG/andsang.github.io' const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf` @@ -34,12 +35,12 @@ export function CvDialog() { return ( <> - + {open && createPortal(
Date: Thu, 28 May 2026 09:48:58 -0500 Subject: [PATCH 16/51] feat: implement expandable project cards with dynamic height animations in a new ProjectList component --- components/sections/projects.tsx | 8 +--- components/ui/project-card.tsx | 79 ++++++++++++++++++++++++-------- components/ui/project-list.tsx | 22 +++++++++ 3 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 components/ui/project-list.tsx diff --git a/components/sections/projects.tsx b/components/sections/projects.tsx index a50f787..e59fea7 100644 --- a/components/sections/projects.tsx +++ b/components/sections/projects.tsx @@ -1,4 +1,4 @@ -import { ProjectCard } from '@/components/ui/project-card' +import { ProjectList } from '@/components/ui/project-list' import { projectController } from '@/src/di' export async function ProjectsSection() { @@ -11,11 +11,7 @@ export async function ProjectsSection() { Selected Projects

-
- {projects.map(project => ( - - ))} -
+
) diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index 4fc0609..c72b3cd 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -1,13 +1,24 @@ "use client" import Image from "next/image" import { ProjectViewModel } from '@/src/interface-adapters/presenters/project-presenter' -import { motion } from 'framer-motion' +import { motion, AnimatePresence } from 'framer-motion' +import { ChevronDown } from 'lucide-react' + +// ~2 lines at text-sm / leading-relaxed (14px × 1.625 × 2 ≈ 46px) +const TEASER_HEIGHT = "2.875rem" + +interface ProjectCardProps { + project: ProjectViewModel + isOpen: boolean + onToggle: () => void +} + +export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { -export function ProjectCard({ project }: { project: ProjectViewModel }) { return ( {project.imagePath && (
@@ -20,28 +31,58 @@ export function ProjectCard({ project }: { project: ProjectViewModel }) {
)} -
+

{project.title}

-

{project.role}

+

{project.role}

+ + {/* Teaser + expand */} + +

{project.description}

-
-

{project.description}

-
+
Challenge -

{project.challenge}

+

{project.challenge}

-
-
- {project.techStack.map((tech) => ( - + {project.techStack.map((tech) => ( + + {tech} + + ))} +
+ + +
+ {isOpen ? "Show less" : "Read more"} + + + + + +
) diff --git a/components/ui/project-list.tsx b/components/ui/project-list.tsx new file mode 100644 index 0000000..617b4fd --- /dev/null +++ b/components/ui/project-list.tsx @@ -0,0 +1,22 @@ +"use client" + +import { useState } from "react" +import { ProjectViewModel } from "@/src/interface-adapters/presenters/project-presenter" +import { ProjectCard } from "./project-card" + +export function ProjectList({ projects }: { projects: ProjectViewModel[] }) { + const [openId, setOpenId] = useState(null) + + return ( +
+ {projects.map(project => ( + setOpenId(openId === project.id ? null : project.id)} + /> + ))} +
+ ) +} From 8b1c0d6cab2d275daea17e5636fdd1d497a95590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 10:09:43 -0500 Subject: [PATCH 17/51] refactor: update experience descriptions, project titles, and UI theme colors --- components/ui/experience-card.tsx | 2 +- .../repositories/in-memory-experience-repository.ts | 12 ++++++------ .../repositories/in-memory-project-repository.ts | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index bc6dc56..6bc00ea 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -31,7 +31,7 @@ export function ExperienceCard({ experience, isEven }: ExperienceCardProps) { {experience.year} -

+

{experience.title}

{experience.company}

diff --git a/src/infrastructure/repositories/in-memory-experience-repository.ts b/src/infrastructure/repositories/in-memory-experience-repository.ts index 83dfee9..df726db 100644 --- a/src/infrastructure/repositories/in-memory-experience-repository.ts +++ b/src/infrastructure/repositories/in-memory-experience-repository.ts @@ -11,7 +11,7 @@ export class InMemoryExperienceRepository implements ExperienceRepository { company: "iuvity (Todo1 Services Inc.)", description: "Senior technical reference and production certification authority for iuviBANKING — iuvity's white-label banking SaaS deployed for Financiera Comultrasan, Banco Mundo Mujer Retail, and BMM Corporate. All artifacts required sign-off before reaching operations. Owned the AppShell web-to-native bridge and Swift/Objective-C native modules for the React Native shell. Highest AWS developer access across all client accounts.", imagePath: '/iuvity.jpeg', - color: "text-orange-500" + color: "text-green-600" }, { id: "2020", @@ -20,7 +20,7 @@ export class InMemoryExperienceRepository implements ExperienceRepository { company: "iuvity (Todo1 Services Inc.)", description: "Engineered resilient iOS banking apps for Bancolombia (10M+ active users) through iuvity's SaaS platform. Achieved 99.5% availability and 95% on-time SLA resolution. Drove architecture from MVC to VIPER and state-driven patterns, improving testability by 40%. Implemented Server-Driven UI, cutting deployment time by 60%. Delivered ISO 27001, OWASP MASVS, and PCI DSS compliance — FaceID, Apple Pay, SSL pinning, jailbreak detection.", imagePath: '/iuvity.jpeg', - color: "text-amber-500" + color: "text-green-500" }, { id: "2018", @@ -29,16 +29,16 @@ export class InMemoryExperienceRepository implements ExperienceRepository { company: "Vrije Universiteit Brussel — Brussels, Belgium", description: "Government-funded scholarship. Graduated with Distinction. Thesis: Minimalistic AI Architectures for Language Models — developed a cognitive architecture for explainable AI bridging statistical language modeling and brain-inspired predictive processing.", imagePath: '/vub.jpeg', - color: "text-purple-500" + color: "text-sky-600" }, { id: "2017", year: "2017 - 2018", title: "Mid-level iOS Developer", company: "iuvity (Todo1 Services Inc.)", - description: "Stepped up as de facto coordinator for a distributed team of external providers. Delivered banking UI features for Banistmo (Panama) using VIPER architecture through iuvity's SaaS platform. Refactored legacy Objective-C codebases to Swift, migrated layouts to iPhone X (Safe Area, AutoLayout), and coordinated App Store releases via TestFlight, Fastlane, and Jenkins.", + description: "Delivered banking UI features for Banistmo (Panama) using VIPER architecture through iuvity's SaaS platform. Refactored legacy Objective-C codebases to Swift, migrated layouts to iPhone X (Safe Area, AutoLayout), and coordinated App Store releases via TestFlight, Fastlane, and Jenkins. Stepped up as de facto coordinator for a distributed team of external providers.", imagePath: '/iuvity.jpeg', - color: "text-blue-500" + color: "text-green-400" }, { id: "2015", @@ -47,7 +47,7 @@ export class InMemoryExperienceRepository implements ExperienceRepository { company: "Bayteq Cia Ltda", description: "Built Supercines Cinema App with a custom seat-selection SDK and PCI DSS-compliant payments. Developed white-label mobile banking for Produbanco and integrated FacePhi biometric verification for Banco Bolivariano. Developed a Points of Interest module (branches/ATMs) using MapKit. Contributed to a 20-week Agile transformation from concept to App Store.", imagePath: '/bayteq.jpeg', - color: "text-green-500" + color: "text-sky-900" } ] } diff --git a/src/infrastructure/repositories/in-memory-project-repository.ts b/src/infrastructure/repositories/in-memory-project-repository.ts index 876350e..4e2d37a 100644 --- a/src/infrastructure/repositories/in-memory-project-repository.ts +++ b/src/infrastructure/repositories/in-memory-project-repository.ts @@ -6,7 +6,7 @@ export class InMemoryProjectRepository implements ProjectRepository { const iuvibanking: Project = { id: 'iuvibanking', title: 'iuviBANKING Platform', - role: 'Senior iOS Engineer — Technical Coordinator', + role: 'Senior iOS Engineer — Technical Ownership', description: 'Production certification authority across React microfrontends, React Native, and the iOS native layer — cutting deployment time by 60% through Server-Driven UI.', challenge: 'Maintaining zero-defect handoffs across independent teams through certification gates, failure triage, and ownership routing — without people-management authority.', techStack: ['React', 'React Native', 'Swift', 'TypeScript', 'AWS', 'Modyo', 'OIDC', 'Server-Driven UI'], @@ -25,7 +25,7 @@ export class InMemoryProjectRepository implements ProjectRepository { const supercines: Project = { id: 'supercines', - title: 'Cinema & Entertainment Platform', + title: 'Supercines iOS App', role: 'iOS Developer', description: 'Cinema app for Supercines — custom seat-selection SDK, PCI DSS-compliant payments, and CoreData offline support.', challenge: 'Building a pixel-accurate interactive seat map SDK with custom touch handling, then wiring a compliant payment gateway under strict PCI DSS requirements.', From 1c18959821de531c81e46407b0ede5ac3d05702c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:21:11 -0500 Subject: [PATCH 18/51] feat: redesign projects section as Apple-style slide gallery Replace 3-column grid with horizontal scroll gallery: caption above image tile, h-64 hero with hover zoom, paddle nav below (left/right chevron buttons), snap-scroll always on with no-scrollbar utility. Right button disables after one click since the third card is already visible as a peek at 44% card width. --- app/globals.css | 8 +++++ components/ui/project-card.tsx | 32 +++++++++---------- components/ui/project-list.tsx | 58 ++++++++++++++++++++++++++++------ 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/app/globals.css b/app/globals.css index 004c841..9ddd1a2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -43,6 +43,14 @@ body { .glass-card { @apply bg-white/5 backdrop-blur-md border border-white/10 rounded-2xl shadow-2xl; } + + .no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; + } } @keyframes shine { diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index c72b3cd..c7ab93f 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -4,7 +4,6 @@ import { ProjectViewModel } from '@/src/interface-adapters/presenters/project-pr import { motion, AnimatePresence } from 'framer-motion' import { ChevronDown } from 'lucide-react' -// ~2 lines at text-sm / leading-relaxed (14px × 1.625 × 2 ≈ 46px) const TEASER_HEIGHT = "2.875rem" interface ProjectCardProps { @@ -14,28 +13,27 @@ interface ProjectCardProps { } export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { - return ( - - {project.imagePath && ( -
+
+
+

+ {project.title} +

+

{project.role}

+
+ + {project.imagePath ? ( +
{project.title}
- )} - -
-

{project.title}

-

{project.role}

+ ) : null} - {/* Teaser + expand */} +
@@ -84,6 +82,6 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) {
- +
) } diff --git a/components/ui/project-list.tsx b/components/ui/project-list.tsx index 617b4fd..096d793 100644 --- a/components/ui/project-list.tsx +++ b/components/ui/project-list.tsx @@ -1,22 +1,60 @@ "use client" -import { useState } from "react" +import { useRef, useState } from "react" import { ProjectViewModel } from "@/src/interface-adapters/presenters/project-presenter" import { ProjectCard } from "./project-card" +import { ChevronLeft, ChevronRight } from "lucide-react" export function ProjectList({ projects }: { projects: ProjectViewModel[] }) { const [openId, setOpenId] = useState(null) + const [currentIndex, setCurrentIndex] = useState(0) + const scrollRef = useRef(null) + + const scrollToIndex = (index: number) => { + const container = scrollRef.current + if (!container) return + const cards = Array.from(container.children) as HTMLElement[] + if (!cards[index]) return + const containerLeft = container.getBoundingClientRect().left + const cardLeft = cards[index].getBoundingClientRect().left + container.scrollBy({ left: cardLeft - containerLeft, behavior: 'smooth' }) + setCurrentIndex(index) + } return ( -
- {projects.map(project => ( - setOpenId(openId === project.id ? null : project.id)} - /> - ))} +
+
+ {projects.map(project => ( + setOpenId(prev => prev === project.id ? null : project.id)} + /> + ))} +
+ +
+ + +
) } From f89a78bae5aa0387a735bf9f0a5f600856824112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:22:14 -0500 Subject: [PATCH 19/51] docs: add todo list with i18n and o11y feature plans --- todo.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 todo.md diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..0cca92f --- /dev/null +++ b/todo.md @@ -0,0 +1,55 @@ +# Todo + +## i18n — Spanish support + +Add a language toggle (EN / ES) so the site can be read in Spanish. +Uses `next-intl` with static-export-compatible `[locale]` routing. + +### 1. Setup +- [ ] Install `next-intl` +- [ ] Create `messages/en.json` and `messages/es.json` with all UI strings +- [ ] Create `i18n/routing.ts` — define `locales: ['en', 'es']` and `defaultLocale: 'en'` +- [ ] Create `i18n/request.ts` — `getRequestConfig` for static export +- [ ] Update `next.config.ts` to use `createNextIntlPlugin` + +### 2. App directory restructure +- [ ] Move `app/layout.tsx` → `app/[locale]/layout.tsx` +- [ ] Move `app/page.tsx` → `app/[locale]/page.tsx` +- [ ] Add `generateStaticParams` to `[locale]/layout.tsx` returning `['en', 'es']` +- [ ] Add a root `app/page.tsx` that redirects to `/en` + +### 3. UI strings — extract & translate +Hardcoded strings to move into `messages/`: + +| Component | Strings | +|---|---| +| `sections/hero.tsx` | "Developing", "High-Availability", "Mobile Apps", "View Work", "Contact Me" | +| `sections/about.tsx` | "About Me" | +| `sections/experience.tsx` | "Professional Journey" | +| `sections/projects.tsx` | "Selected Projects" | +| `sections/more-about.tsx` | "More About Me", "When I'm not coding, I like:" | +| `sections/contact.tsx` | "Get in Touch" | +| `components/ui/navbar.tsx` | nav link labels (About, Projects, More, Contact), "Skip to content", aria labels | +| `components/ui/cv-dialog.tsx` | dialog labels | + +- [ ] Add all English strings to `messages/en.json` +- [ ] Translate all strings to `messages/es.json` +- [ ] Replace hardcoded strings in each component with `useTranslations` / `getTranslations` + +### 4. Content data — localized copies +Data lives in `src/infrastructure/repositories/`. Fields that need ES variants: + +- [ ] `in-memory-about-repository.ts` — `heroDescription`, `bio`, `personalBio`, `hobbies`, `stats` labels +- [ ] `in-memory-experience-repository.ts` — job titles, company descriptions, bullet points +- [ ] `in-memory-project-repository.ts` — project descriptions, tags + +Strategy: add a `locale` parameter to each repository method and return the matching copy. + +### 5. Locale switcher +- [ ] Add `EN / ES` toggle to `Navbar` (desktop + mobile menu) +- [ ] Wire toggle to `next-intl`'s locale routing (navigate to `/` equivalent of current path) + +### 6. Cleanup & validation +- [ ] Update `` in `[locale]/layout.tsx` to use the active locale +- [ ] Smoke-test both locales — check all sections render correctly +- [ ] Verify `output: 'export'` still builds successfully (`npm run build`) From e26c7f29a7d35d9ab284cdbba8f6b1cabdb8b311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:22:39 -0500 Subject: [PATCH 20/51] fix: restore TactileButton shimmer and align icon/text inline Move shine element back into JSX (was a module-level string constant that Tailwind JIT could miss). Add pointer-events-none and aria-hidden. Inner span now flex items-center gap-2 so icons stay inline with label text. --- components/ui/tactile-button.tsx | 34 ++++++++++++-------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/components/ui/tactile-button.tsx b/components/ui/tactile-button.tsx index fdd81c8..938a3b8 100644 --- a/components/ui/tactile-button.tsx +++ b/components/ui/tactile-button.tsx @@ -10,35 +10,27 @@ interface Props { } export function TactileButton({ href, onClick, className = "", children }: Props) { - const isLink = href !== undefined - - const baseClasses = `relative overflow-hidden group flex items-center justify-center ${className}` - - const shineElement = ( - + const base = `relative overflow-hidden group flex items-center justify-center ${className}` + const shine = ( + ) - if (isLink) { + if (href !== undefined) { return ( - - {children} - {shineElement} + + {children} + {shine} ) } return ( - - {children} - {shineElement} + + {children} + {shine} ) } From 8adc2a1d35c776fc85d7467fce10ecc6cfc2b1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:22:43 -0500 Subject: [PATCH 21/51] =?UTF-8?q?feat:=20upgrade=20CvDialog=20=E2=80=94=20?= =?UTF-8?q?TactileButton=20download,=20lazy-loaded=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace plain
download button with TactileButton for tap/shimmer animation. Extract ReactMarkdown + remark-gfm into CvMarkdown component loaded via next/dynamic, keeping them out of the initial bundle. --- components/ui/cv-dialog.tsx | 33 ++++++++++----------------------- components/ui/cv-markdown.tsx | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 components/ui/cv-markdown.tsx diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx index 0223fd3..a0a9586 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -2,11 +2,15 @@ import { useState, useEffect } from 'react' import { createPortal } from 'react-dom' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' +import dynamic from 'next/dynamic' import { X, Download } from 'lucide-react' import { TactileButton } from '@/components/ui/tactile-button' +const CvMarkdown = dynamic( + () => import('./cv-markdown').then(m => ({ default: m.CvMarkdown })), + { loading: () =>

Loading…

} +) + const REPO = 'AndSanG/andsang.github.io' const CV_PDF_URL = `https://github.com/${REPO}/releases/latest/download/cv.pdf` @@ -77,35 +81,18 @@ export function CvDialog() { ) : content === '' ? (

Could not load CV content.

) : ( -
- - {content} - -
+ )}
{/* Footer */}
, diff --git a/components/ui/cv-markdown.tsx b/components/ui/cv-markdown.tsx new file mode 100644 index 0000000..e276662 --- /dev/null +++ b/components/ui/cv-markdown.tsx @@ -0,0 +1,22 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' + +export function CvMarkdown({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ) +} From 6a282e2cf0f7408a6abcd63bfb0290908239b688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:22:47 -0500 Subject: [PATCH 22/51] perf: wrap DI controllers with React.cache for per-request deduplication Add cache()-wrapped getProjects, getExperiences, getContact alongside their controllers, matching the existing getAbout pattern. Prevents duplicate data fetches if multiple RSCs ever call the same controller. --- src/di/index.ts | 6 +++--- src/di/modules/contact.ts | 2 ++ src/di/modules/experience.ts | 2 ++ src/di/modules/project.ts | 2 ++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/di/index.ts b/src/di/index.ts index b2f1a2d..0a49081 100644 --- a/src/di/index.ts +++ b/src/di/index.ts @@ -1,4 +1,4 @@ -export { projectController } from "./modules/project" -export { experienceController } from "./modules/experience" +export { projectController, getProjects } from "./modules/project" +export { experienceController, getExperiences } from "./modules/experience" export { aboutController, getAbout } from "./modules/about" -export { contactController } from "./modules/contact" +export { contactController, getContact } from "./modules/contact" diff --git a/src/di/modules/contact.ts b/src/di/modules/contact.ts index dba7af3..4d3a7db 100644 --- a/src/di/modules/contact.ts +++ b/src/di/modules/contact.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { GetContactUseCase } from "@/src/application/use-cases/get-contact" import { InMemoryContactRepository } from "@/src/infrastructure/repositories/in-memory-contact-repository" import { ContactController } from "@/src/interface-adapters/controllers/contact-controller" @@ -5,3 +6,4 @@ import { ContactController } from "@/src/interface-adapters/controllers/contact- const contactRepo = new InMemoryContactRepository() const getContactUseCase = new GetContactUseCase(contactRepo) export const contactController = new ContactController(getContactUseCase) +export const getContact = cache(() => contactController.getContact()) diff --git a/src/di/modules/experience.ts b/src/di/modules/experience.ts index 2a771af..c8052d4 100644 --- a/src/di/modules/experience.ts +++ b/src/di/modules/experience.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { GetExperiencesUseCase } from "@/src/application/use-cases/get-experiences" import { InMemoryExperienceRepository } from "@/src/infrastructure/repositories/in-memory-experience-repository" import { ExperienceController } from "@/src/interface-adapters/controllers/experience-controller" @@ -5,3 +6,4 @@ import { ExperienceController } from "@/src/interface-adapters/controllers/exper const experienceRepo = new InMemoryExperienceRepository() const getExperiencesUseCase = new GetExperiencesUseCase(experienceRepo) export const experienceController = new ExperienceController(getExperiencesUseCase) +export const getExperiences = cache(() => experienceController.getExperiences()) diff --git a/src/di/modules/project.ts b/src/di/modules/project.ts index 457b70e..0765ed9 100644 --- a/src/di/modules/project.ts +++ b/src/di/modules/project.ts @@ -1,3 +1,4 @@ +import { cache } from 'react' import { GetProjectsUseCase } from "@/src/application/use-cases/get-projects" import { InMemoryProjectRepository } from "@/src/infrastructure/repositories/in-memory-project-repository" import { ProjectController } from "@/src/interface-adapters/controllers/project-controller" @@ -5,3 +6,4 @@ import { ProjectController } from "@/src/interface-adapters/controllers/project- const projectRepo = new InMemoryProjectRepository() const getProjectsUseCase = new GetProjectsUseCase(projectRepo) export const projectController = new ProjectController(getProjectsUseCase) +export const getProjects = cache(() => projectController.getProjects()) From 57ae89b05f139dd3fed827892f80f52fb930e453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 11:22:51 -0500 Subject: [PATCH 23/51] feat: animated SMIL SVG hamburger for mobile navbar Replace static icon with an Apple-style animated hamburger that morphs to X via SMIL elements driven by beginElement() calls. Add slide-down AnimatePresence mobile menu, auto-close on desktop resize, and accessibility attributes (aria-expanded, aria-label). --- components/ui/navbar.tsx | 123 ++++++++++++++++++++++++++++++--------- 1 file changed, 95 insertions(+), 28 deletions(-) diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index 33ba2f6..dd543ce 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -1,8 +1,8 @@ "use client" import Image from 'next/image' import { ThemeToggle } from '@/components/ui/theme-toggle' -import { useState, useEffect } from 'react' -import { motion, useScroll } from 'framer-motion' +import { useState, useEffect, useRef } from 'react' +import { motion, useScroll, AnimatePresence } from 'framer-motion' const navLinks = [ { name: "About", href: "#about" }, @@ -14,25 +14,53 @@ const navLinks = [ export function Navbar() { const { scrollYProgress } = useScroll() const [activeSection, setActiveSection] = useState("") + const [menuOpen, setMenuOpen] = useState(false) + + const breadTopOpenRef = useRef(null) + const breadTopCloseRef = useRef(null) + const breadBotOpenRef = useRef(null) + const breadBotCloseRef = useRef(null) useEffect(() => { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - if (entry.isIntersecting) { - setActiveSection(entry.target.id) - } + if (entry.isIntersecting) setActiveSection(entry.target.id) }) }, { rootMargin: "-20% 0px -80% 0px" } ) - const sections = document.querySelectorAll("section[id]") sections.forEach((section) => observer.observe(section)) - return () => observer.disconnect() }, []) + // Close menu on resize to desktop + useEffect(() => { + const onResize = () => { if (window.innerWidth >= 768) setMenuOpen(false) } + window.addEventListener("resize", onResize, { passive: true }) + return () => window.removeEventListener("resize", onResize) + }, []) + + function toggleMenu() { + const opening = !menuOpen + setMenuOpen(opening) + if (opening) { + breadTopOpenRef.current?.beginElement() + breadBotOpenRef.current?.beginElement() + } else { + breadTopCloseRef.current?.beginElement() + breadBotCloseRef.current?.beginElement() + } + } + + function closeMenu() { + if (!menuOpen) return + setMenuOpen(false) + breadTopCloseRef.current?.beginElement() + breadBotCloseRef.current?.beginElement() + } + return ( <> Skip to content - + {/* Scroll Progress Bar */}
- Swift Logo - Triathlon + Swift Logo + Triathlon + {/* Desktop links */}
{navLinks.map((link) => ( - {link.name} ))}
+ + {/* Mobile: theme toggle + hamburger */} +
+ + +
+ + {/* Mobile menu */} + + {menuOpen && ( + +
+ {navLinks.map((link) => ( + + {link.name} + + ))} +
+
+ )} +
) From 871551a1b62fc9ad16f74c80afe0ed642cabfc2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:06:09 -0500 Subject: [PATCH 24/51] feat: horizontal timeline gallery for experience section Replace vertical alternating timeline with a horizontal scroll gallery matching the projects pattern. Circle nodes sit on a continuous h-1 timeline rail; a vertical stem connects each node to a glass card below. Cards scroll with snap, paddle nav below, IntersectionObserver-driven left/right enable state, equal closed heights via JS measurement after animation settles (350ms), and closed cards hold their height when one expands. --- components/sections/experience.tsx | 27 ++---- components/ui/experience-card.tsx | 133 ++++++++++++++--------------- components/ui/experience-list.tsx | 131 ++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 93 deletions(-) create mode 100644 components/ui/experience-list.tsx diff --git a/components/sections/experience.tsx b/components/sections/experience.tsx index 7f6c212..ccd3ae9 100644 --- a/components/sections/experience.tsx +++ b/components/sections/experience.tsx @@ -1,34 +1,17 @@ -import { ExperienceCard } from "@/components/ui/experience-card" +import { ExperienceList } from "@/components/ui/experience-list" import { experienceController } from "@/src/di" export async function ExperienceSection() { const experiences = await experienceController.getExperiences() return ( -
-
-

+
+
+

Professional Journey

-
- {/* Vertical Center Line */} -
- - {/* Mobile Line (Left aligned) */} -
- - -
- {experiences.map((exp, index) => ( - - ))} -
-
+
) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 6bc00ea..de2befb 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -1,96 +1,87 @@ "use client" import Image from "next/image" -import { useState } from "react" +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" interface ExperienceCardProps { experience: ExperienceViewModel - isEven: boolean + isOpen: boolean + onToggle: () => void } -// ~2 lines at text-sm / leading-relaxed (14px × 1.625 × 2 ≈ 46px) const TEASER_HEIGHT = "2.875rem" -export function ExperienceCard({ experience, isEven }: ExperienceCardProps) { - const [isOpen, setIsOpen] = useState(false) +export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardProps) { + const cardRef = useRef(null) + + useEffect(() => { + if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + }, [isOpen]) return ( -
+
- {/* Content Side */} -
- - - {experience.year} - -

- {experience.title} -

-

{experience.company}

+ {/* Horizontal timeline + circle node */} +
+
+
+ {experience.company} +
+
- {/* Teaser + expand */} - -

- {experience.description} -

-
+ {/* Vertical stem from circle to card */} +
- - -
- - {/* Timeline Node (Image) */} -
- {experience.company} + + + + +
- - {/* Connection Line (Desktop) */} - - - {/* Empty Side for Spacing */} -
-
) } diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx new file mode 100644 index 0000000..9c7c90a --- /dev/null +++ b/components/ui/experience-list.tsx @@ -0,0 +1,131 @@ +"use client" + +import { useRef, useState, useEffect } from "react" +import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter" +import { ExperienceCard } from "./experience-card" +import { ChevronLeft, ChevronRight } from "lucide-react" + + +export function ExperienceList({ experiences }: { experiences: ExperienceViewModel[] }) { + const [openId, setOpenId] = useState(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) + const scrollRef = useRef(null) + const wrapperRef = useRef(null) + + useEffect(() => { + const container = scrollRef.current + if (!container || experiences.length === 0) return + + const cards = Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + const first = cards[0] + const last = cards[cards.length - 1] + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(({ target, isIntersecting }) => { + if (target === first) setCanScrollLeft(!isIntersecting) + if (target === last) setCanScrollRight(!isIntersecting) + }) + }, + { root: container, threshold: 0.9 } + ) + + observer.observe(first) + observer.observe(last) + return () => observer.disconnect() + }, [experiences.length]) + + useEffect(() => { + if (openId !== null) return + const container = scrollRef.current + if (!container) return + const timer = setTimeout(() => { + const cards = Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + cards.forEach(el => (el.style.minHeight = '')) + const max = Math.max(...cards.map(el => el.getBoundingClientRect().height)) + cards.forEach(el => (el.style.minHeight = `${max}px`)) + }, 350) + return () => clearTimeout(timer) + }, [openId, experiences.length]) + + const getCards = () => { + const container = scrollRef.current + if (!container) return [] + return Array.from(container.children).slice(0, experiences.length) as HTMLElement[] + } + + const navigate = (getTarget: (cards: HTMLElement[], containerLeft: number) => HTMLElement | undefined) => { + const container = scrollRef.current + if (!container) return + + const containerLeft = container.getBoundingClientRect().left + const cards = getCards() + const target = getTarget(cards, containerLeft) + if (!target) return + + const doScroll = () => { + const left = target.getBoundingClientRect().left - container.getBoundingClientRect().left + container.scrollBy({ left, behavior: 'smooth' }) + } + + if (openId !== null) { + setOpenId(null) + const wrapper = wrapperRef.current + if (wrapper) { + const top = wrapper.getBoundingClientRect().top + window.scrollY - 96 + window.scrollTo({ top, behavior: 'instant' }) + } + doScroll() + } else { + doScroll() + } + } + + const handleScrollLeft = () => navigate((cards, containerLeft) => { + const currentIdx = cards.findIndex(c => c.getBoundingClientRect().left >= containerLeft - 8) + return cards[Math.max(0, currentIdx - 1)] + }) + + const handleScrollRight = () => navigate((cards, containerLeft) => + cards.find(c => c.getBoundingClientRect().left > containerLeft + 8) + ) + + return ( +
+
+ {experiences.map((experience) => ( + setOpenId(prev => prev === experience.id ? null : experience.id)} + /> + ))} +
+
+ +
+ + +
+
+ ) +} From c92e5d75c918e279bc700bf47f887c1079808c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:06:14 -0500 Subject: [PATCH 25/51] fix: project gallery equal heights, no-grow on expand, UX polish Switch container to items-start; equalize closed card heights via JS measurement after animation settles (350ms) so cards start short and grow only when opened. Equalize caption heights via useLayoutEffect so images align across cards. Read more/less button right-aligned with self-end and scrolls card into view on expand. --- components/ui/project-card.tsx | 15 +++-- components/ui/project-list.tsx | 108 ++++++++++++++++++++++++++++----- 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index c7ab93f..a01d87e 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -1,5 +1,6 @@ "use client" 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' @@ -13,9 +14,15 @@ interface ProjectCardProps { } export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { + const cardRef = useRef(null) + + useEffect(() => { + if (isOpen) cardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }) + }, [isOpen]) + return ( -
-
+
+

{project.title}

@@ -33,7 +40,7 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) {
) : null} -
+
diff --git a/components/ui/project-list.tsx b/components/ui/project-list.tsx index 096d793..f51c0f6 100644 --- a/components/ui/project-list.tsx +++ b/components/ui/project-list.tsx @@ -1,31 +1,110 @@ "use client" -import { useRef, useState } from "react" +import { useRef, useState, useEffect, useLayoutEffect } from "react" import { ProjectViewModel } from "@/src/interface-adapters/presenters/project-presenter" import { ProjectCard } from "./project-card" import { ChevronLeft, ChevronRight } from "lucide-react" + export function ProjectList({ projects }: { projects: ProjectViewModel[] }) { const [openId, setOpenId] = useState(null) - const [currentIndex, setCurrentIndex] = useState(0) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(true) const scrollRef = useRef(null) + const wrapperRef = useRef(null) + + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) return + const captions = Array.from(container.querySelectorAll('[data-caption]')) + captions.forEach(el => (el.style.height = '')) + const max = Math.max(...captions.map(el => el.getBoundingClientRect().height)) + captions.forEach(el => (el.style.height = `${max}px`)) + }, [projects]) - const scrollToIndex = (index: number) => { + useEffect(() => { + if (openId !== null) return const container = scrollRef.current if (!container) return - const cards = Array.from(container.children) as HTMLElement[] - if (!cards[index]) return + const timer = setTimeout(() => { + const cards = Array.from(container.children).slice(0, projects.length) as HTMLElement[] + cards.forEach(el => (el.style.minHeight = '')) + const max = Math.max(...cards.map(el => el.getBoundingClientRect().height)) + cards.forEach(el => (el.style.minHeight = `${max}px`)) + }, 350) + return () => clearTimeout(timer) + }, [openId, projects.length]) + + useEffect(() => { + const container = scrollRef.current + if (!container || projects.length === 0) return + + const cards = Array.from(container.children).slice(0, projects.length) as HTMLElement[] + const first = cards[0] + const last = cards[cards.length - 1] + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach(({ target, isIntersecting }) => { + if (target === first) setCanScrollLeft(!isIntersecting) + if (target === last) setCanScrollRight(!isIntersecting) + }) + }, + { root: container, threshold: 0.9 } + ) + + observer.observe(first) + observer.observe(last) + return () => observer.disconnect() + }, [projects.length]) + + const getCards = () => { + const container = scrollRef.current + if (!container) return [] + return Array.from(container.children).slice(0, projects.length) as HTMLElement[] + } + + const navigate = (getTarget: (cards: HTMLElement[], containerLeft: number) => HTMLElement | undefined) => { + const container = scrollRef.current + if (!container) return + const containerLeft = container.getBoundingClientRect().left - const cardLeft = cards[index].getBoundingClientRect().left - container.scrollBy({ left: cardLeft - containerLeft, behavior: 'smooth' }) - setCurrentIndex(index) + const cards = getCards() + const target = getTarget(cards, containerLeft) + if (!target) return + + const doScroll = () => { + const left = target.getBoundingClientRect().left - container.getBoundingClientRect().left + container.scrollBy({ left, behavior: 'smooth' }) + } + + if (openId !== null) { + setOpenId(null) + const wrapper = wrapperRef.current + if (wrapper) { + const top = wrapper.getBoundingClientRect().top + window.scrollY - 96 + window.scrollTo({ top, behavior: 'instant' }) + } + doScroll() + } else { + doScroll() + } } + const handleScrollLeft = () => navigate((cards, containerLeft) => { + const currentIdx = cards.findIndex(c => c.getBoundingClientRect().left >= containerLeft - 8) + return cards[Math.max(0, currentIdx - 1)] + }) + + const handleScrollRight = () => navigate((cards, containerLeft) => + cards.find(c => c.getBoundingClientRect().left > containerLeft + 8) + ) + return ( -
+
{projects.map(project => ( setOpenId(prev => prev === project.id ? null : project.id)} /> ))} +
{/* Footer */} -
+
Date: Thu, 28 May 2026 15:34:25 -0500 Subject: [PATCH 33/51] =?UTF-8?q?feat:=20liquid=20glass=20contact=20icon?= =?UTF-8?q?=20circles=20=E2=80=94=20frosted=20surface=20with=20specular=20?= =?UTF-8?q?ring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace opaque zinc backgrounds with translucent glass (bg-white/60 light / bg-zinc-900/50 dark, backdrop-blur-md, specular inner ring). Soften hover border to accent/50 to avoid clashing with glass edges. --- components/sections/contact.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 0427ef5..4ef8b5d 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 c1c0630a590b7086c21968b54d665b68ac020d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:34:43 -0500 Subject: [PATCH 34/51] =?UTF-8?q?feat:=20liquid=20glass=20ghost=20buttons?= =?UTF-8?q?=20=E2=80=94=20frosted=20glass=20surface=20on=20secondary=20CTA?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace invisible border-transparent with a glass surface: bg-white/20 light / bg-zinc-900/20 dark, backdrop-blur-sm, translucent border. Applied to Contact Me (hero) and View CV (cv-dialog trigger). --- components/sections/hero.tsx | 2 +- components/ui/cv-dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/sections/hero.tsx b/components/sections/hero.tsx index 9b14f35..ffbb699 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -39,7 +39,7 @@ export async function Hero() { Contact Me diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx index 360d834..63898d4 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -48,7 +48,7 @@ export function CvDialog() { <> setOpen(true)} - className="px-8 py-3 rounded-xl border-2 border-transparent text-zinc-500 dark:text-gray-400 font-semibold hover:text-zinc-900 dark:hover:text-white transition-all duration-300" + className="px-8 py-3 rounded-xl border border-white/30 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-sm text-zinc-600 dark:text-gray-400 font-semibold hover:bg-white/50 dark:hover:bg-zinc-900/40 hover:text-zinc-900 dark:hover:text-white transition-all duration-300" > View CV From 591ada57a5c8a359722d1d0e7d241b1ee64c73fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:35:00 -0500 Subject: [PATCH 35/51] =?UTF-8?q?feat:=20liquid=20glass=20ProjectCard=20?= =?UTF-8?q?=E2=80=94=20glass=20challenge=20block=20and=20tech=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Challenge block: bg-white/50 light / bg-white/5 dark, backdrop-blur-sm, translucent border. Tech stack badges: same glass treatment replacing opaque zinc-200/zinc-800 surfaces. --- components/ui/project-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index a2bd5f1..549a2b0 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -48,7 +48,7 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { >

{project.description}

-
+
Challenge

{project.challenge}

@@ -57,7 +57,7 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { {project.techStack.map((tech) => ( {tech} From a7a62b4fc7f610117dacc35ee450c944597d4c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:35:14 -0500 Subject: [PATCH 36/51] =?UTF-8?q?feat:=20liquid=20glass=20ExperienceCard?= =?UTF-8?q?=20timeline=20nodes=20=E2=80=94=20frosted=20company=20logo=20ci?= =?UTF-8?q?rcles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace opaque bg-white/bg-zinc-900 with translucent glass surface (bg-white/70 light / bg-zinc-900/60 dark, backdrop-blur-sm, glass border). --- components/ui/experience-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 3e81724..a9e4fb9 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -27,7 +27,7 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP {/* Horizontal timeline + circle node */}
-
+
{experience.company} Date: Thu, 28 May 2026 15:35:26 -0500 Subject: [PATCH 37/51] =?UTF-8?q?feat:=20liquid=20glass=20code=20snippet?= =?UTF-8?q?=20in=20MoreAbout=20=E2=80=94=20subtle=20glass=20tint=20on=20da?= =?UTF-8?q?rk=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backdrop-blur-sm, translucent border, and specular inner ring to the Swift hobbies code block without compromising code readability. --- components/sections/more-about.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index 092057c..b0c41f0 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -24,7 +24,7 @@ export async function MoreAboutSection() {

{/* Swift-styled code snippet */} -
+
let hobbies = From 2594a030e48ab09a14744b13de1c1a51a7932ba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:44:42 -0500 Subject: [PATCH 38/51] feat: add backdrop-saturate-150 to all glass surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saturation boost is a core Liquid Glass property — the glass enriches the color of content behind it rather than washing it out. Applied to: glass-card utility, navbar pill, ThemeToggle, CvDialog (backdrop + surface + trigger), contact circles, hero ghost buttons, ProjectCard challenge block and badges, ExperienceCard timeline nodes, MoreAbout code snippet. --- app/globals.css | 2 +- components/sections/contact.tsx | 8 ++++---- components/sections/hero.tsx | 2 +- components/sections/more-about.tsx | 2 +- components/ui/cv-dialog.tsx | 6 +++--- components/ui/experience-card.tsx | 2 +- components/ui/navbar.tsx | 2 +- components/ui/project-card.tsx | 4 ++-- components/ui/theme-toggle.tsx | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/globals.css b/app/globals.css index f83bfa7..39a661d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,7 +41,7 @@ body { @layer components { .glass-card { - @apply bg-white/50 dark:bg-zinc-900/40 backdrop-blur-2xl border border-white/50 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-white/30 dark:ring-white/5; + @apply bg-white/50 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150 border border-white/50 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-white/30 dark:ring-white/5; } .no-scrollbar { diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 4ef8b5d..1ac8673 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/sections/hero.tsx b/components/sections/hero.tsx index ffbb699..d2ca048 100644 --- a/components/sections/hero.tsx +++ b/components/sections/hero.tsx @@ -39,7 +39,7 @@ export async function Hero() { Contact Me diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index b0c41f0..a16b702 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -24,7 +24,7 @@ export async function MoreAboutSection() {

{/* Swift-styled code snippet */} -
+
let hobbies = diff --git a/components/ui/cv-dialog.tsx b/components/ui/cv-dialog.tsx index 63898d4..d4a7e81 100644 --- a/components/ui/cv-dialog.tsx +++ b/components/ui/cv-dialog.tsx @@ -48,7 +48,7 @@ export function CvDialog() { <> setOpen(true)} - className="px-8 py-3 rounded-xl border border-white/30 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-sm text-zinc-600 dark:text-gray-400 font-semibold hover:bg-white/50 dark:hover:bg-zinc-900/40 hover:text-zinc-900 dark:hover:text-white transition-all duration-300" + className="px-8 py-3 rounded-xl border border-white/30 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-sm backdrop-saturate-150 text-zinc-600 dark:text-gray-400 font-semibold hover:bg-white/50 dark:hover:bg-zinc-900/40 hover:text-zinc-900 dark:hover:text-white transition-all duration-300" > View CV @@ -62,12 +62,12 @@ export function CvDialog() { > {/* Backdrop */}
setOpen(false)} /> {/* Dialog */} -
+
{/* Header */}
diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index a9e4fb9..91b5034 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -27,7 +27,7 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP {/* Horizontal timeline + circle node */}
-
+
{experience.company} -
+
Swift Logo diff --git a/components/ui/project-card.tsx b/components/ui/project-card.tsx index 549a2b0..b30d319 100644 --- a/components/ui/project-card.tsx +++ b/components/ui/project-card.tsx @@ -48,7 +48,7 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { >

{project.description}

-
+
Challenge

{project.challenge}

@@ -57,7 +57,7 @@ export function ProjectCard({ project, isOpen, onToggle }: ProjectCardProps) { {project.techStack.map((tech) => ( {tech} diff --git a/components/ui/theme-toggle.tsx b/components/ui/theme-toggle.tsx index f727b56..998fc88 100644 --- a/components/ui/theme-toggle.tsx +++ b/components/ui/theme-toggle.tsx @@ -15,7 +15,7 @@ export function ThemeToggle() { return (
-
+
) } diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index 7ad01cf..2f9d72c 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -3,6 +3,7 @@ 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" }, @@ -15,6 +16,7 @@ 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) @@ -79,8 +81,17 @@ export function Navbar() { ) diff --git a/hooks/use-glass-tilt.ts b/hooks/use-glass-tilt.ts new file mode 100644 index 0000000..3b842a6 --- /dev/null +++ b/hooks/use-glass-tilt.ts @@ -0,0 +1,20 @@ +import { useRef, useState, 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 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 }) + }, [intensity]) + + const onMouseLeave = useCallback(() => { + setTilt({ x: 0, y: 0 }) + }, []) + + return { ref, tilt, onMouseMove, onMouseLeave } +} From 6ba792fee1588a0ef65e040a67308e5f7af14e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:49:13 -0500 Subject: [PATCH 41/51] =?UTF-8?q?fix:=20revert=20SVG=20displacement=20filt?= =?UTF-8?q?er=20=E2=80=94=20distorts=20text=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feDisplacementMap applies to every pixel of the element including text, not just the backdrop layer. At any useful scale the warping is visible on nav links and card body text. The filter needs a separate background layer architecture to isolate content from distortion — removed for now. --- app/globals.css | 5 ----- app/layout.tsx | 11 +---------- components/ui/navbar.tsx | 2 +- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/app/globals.css b/app/globals.css index 188fd7d..39a661d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -42,11 +42,6 @@ body { @layer components { .glass-card { @apply bg-white/50 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150 border border-white/50 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-white/30 dark:ring-white/5; - filter: url(#liquid-glass-distort); - } - - .liquid-glass-distort { - filter: url(#liquid-glass-distort); } .no-scrollbar { diff --git a/app/layout.tsx b/app/layout.tsx index 4bf1608..b854079 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -39,16 +39,7 @@ export default function RootLayout({
- {/* SVG filter for liquid glass edge refraction — referenced by .liquid-glass-distort */} - - + {children} diff --git a/components/ui/navbar.tsx b/components/ui/navbar.tsx index 2f9d72c..4b5a940 100644 --- a/components/ui/navbar.tsx +++ b/components/ui/navbar.tsx @@ -90,7 +90,7 @@ export function Navbar() { animate={{ rotateY: tilt.x, rotateX: tilt.y }} transition={{ type: "spring", stiffness: 300, damping: 25 }} style={{ transformStyle: "preserve-3d" }} - className="liquid-glass-distort max-w-3xl mx-auto pointer-events-auto rounded-2xl bg-white/70 dark:bg-zinc-900/50 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-lg shadow-black/8 dark:shadow-black/30 ring-1 ring-inset ring-white/40 dark:ring-white/5 transition-colors" + className="max-w-3xl mx-auto pointer-events-auto rounded-2xl bg-white/70 dark:bg-zinc-900/50 backdrop-blur-2xl backdrop-saturate-150 border border-white/60 dark:border-white/10 shadow-lg shadow-black/8 dark:shadow-black/30 ring-1 ring-inset ring-white/40 dark:ring-white/5 transition-colors" >
From 4751597a3de0aa8a617e0dfacf4ec8a85662a775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 15:58:51 -0500 Subject: [PATCH 42/51] =?UTF-8?q?feat:=20liquid=20glass=20ExperienceCard?= =?UTF-8?q?=20timeline=20details=20=E2=80=94=20softer=20lines=20and=20glas?= =?UTF-8?q?s=20year=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline line and vertical stem: h-px + bg-white/15 (was thick zinc bar). Year badge: glass surface with backdrop-blur-sm and translucent border replacing opaque bg-zinc-800. --- components/ui/experience-card.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 26e8248..43c1f4e 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -28,7 +28,7 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP {/* Horizontal timeline + circle node */}
-
+
{/* Vertical stem from circle to card */} -
+
{/* Card */} - + {experience.year}

From b0236aaa7a4adcbf1166622e83c53288feae1f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 16:06:28 -0500 Subject: [PATCH 43/51] feat: fade mask on experience gallery right edge for card hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CSS mask-image gradient (82% opaque → transparent) on the scroll container so the peeking next card fades out gracefully. Tied to canScrollRight so the fade removes itself when the last card is reached. --- components/ui/experience-list.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index 9c7c90a..c149029 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -96,6 +96,7 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod
{experiences.map((experience) => ( Date: Thu, 28 May 2026 16:10:15 -0500 Subject: [PATCH 44/51] fix: add -webkit-mask-image prefix for iOS Safari fade mask React does not auto-prefix mask-image, so the fade was invisible on iOS/Safari. Adding WebkitMaskImage alongside maskImage fixes it. --- components/ui/experience-list.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index c149029..4654856 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -96,7 +96,10 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod
{experiences.map((experience) => ( Date: Thu, 28 May 2026 16:12:35 -0500 Subject: [PATCH 45/51] =?UTF-8?q?fix:=20steeper=20fade=20mask=20=E2=80=94?= =?UTF-8?q?=20start=20at=2070%=20so=20hint=20card=20fades=20more=20aggress?= =?UTF-8?q?ively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/experience-list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index 4654856..74d2ce9 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -97,8 +97,8 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod ref={scrollRef} className="no-scrollbar flex overflow-x-auto snap-x snap-mandatory gap-0 pb-4 items-start" style={canScrollRight ? { - maskImage: "linear-gradient(to right, black 82%, transparent 100%)", - WebkitMaskImage: "linear-gradient(to right, black 82%, transparent 100%)", + maskImage: "linear-gradient(to right, black 70%, transparent 90%)", + WebkitMaskImage: "linear-gradient(to right, black 70%, transparent 90%)", } : undefined} > {experiences.map((experience) => ( From df28927413e93fec36b4c7298fcb93717dbca22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 16:17:04 -0500 Subject: [PATCH 46/51] =?UTF-8?q?fix:=20remove=20fade=20mask=20from=20expe?= =?UTF-8?q?rience=20gallery=20=E2=80=94=20hint=20card=20now=20shows=20glas?= =?UTF-8?q?s=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mask (70%→90%) was fading the entire hint area, so the peeking card appeared as a dark smear with no visible border or glass surface. Removing the mask lets the next card's glass-card styling (border, ring, rounded corners) show naturally through the scroll overflow clip. --- components/ui/experience-list.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index 74d2ce9..9c7c90a 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -96,10 +96,6 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod
{experiences.map((experience) => ( Date: Thu, 28 May 2026 16:18:48 -0500 Subject: [PATCH 47/51] =?UTF-8?q?fix:=20remove=20hint=20spacer=20div=20?= =?UTF-8?q?=E2=80=94=20next=20card=20peeks=20naturally=20via=20scroll=20ov?= =?UTF-8?q?erflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/ui/experience-list.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index 9c7c90a..cf1b5ae 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -105,7 +105,6 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod onToggle={() => setOpenId(prev => prev === experience.id ? null : experience.id)} /> ))} -
From 8f7071480600792bc9ce4547a003d5778951f8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 16:24:19 -0500 Subject: [PATCH 48/51] =?UTF-8?q?fix:=20contact=20circles=20visible=20in?= =?UTF-8?q?=20light=20mode=20=E2=80=94=20zinc=20tint=20replaces=20white-on?= =?UTF-8?q?-white?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bg-white/60 + border-white/50 + ring-white/30 were invisible on the white light background. Replaced with bg-zinc-100/80 + border-zinc-200 + ring-zinc-100 for light mode; dark mode values unchanged. --- components/sections/contact.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 1ac8673..17d5f7e 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 c5d3fa1e9b63b8b8e85eacab7d5ec3af5ed4293c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 16:25:18 -0500 Subject: [PATCH 49/51] =?UTF-8?q?fix:=20glass-card=20visible=20in=20light?= =?UTF-8?q?=20mode=20=E2=80=94=20zinc=20tint=20replaces=20white-on-white?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bg-white/50 + border-white/50 + ring-white/30 were invisible on white backgrounds. Replaced with bg-zinc-100/80 + border-zinc-200 + ring-zinc-100 for light mode; dark mode values unchanged. Fixes BackToTop and ExperienceCard. --- app/globals.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 39a661d..eaaf94e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,7 +41,7 @@ body { @layer components { .glass-card { - @apply bg-white/50 dark:bg-zinc-900/40 backdrop-blur-2xl backdrop-saturate-150 border border-white/50 dark:border-white/10 rounded-2xl shadow-lg shadow-black/8 dark:shadow-black/25 ring-1 ring-inset ring-white/30 dark:ring-white/5; + @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; } .no-scrollbar { From 6cdf5287af4f0025e53dba6dfd051a85cc50bd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 19:46:35 -0500 Subject: [PATCH 50/51] fix: halve section spacing and normalize heading margins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section vertical padding py-16→py-8 (64px→32px) on all non-hero sections so the border divider has 32px above and below instead of 64px. Section heading margins mb-16→mb-8 and mb-12→mb-8 for consistency. Remove leftover w-6 hint spacer from ProjectList. --- components/sections/about.tsx | 4 ++-- components/sections/contact.tsx | 4 ++-- components/sections/experience.tsx | 4 ++-- components/sections/more-about.tsx | 2 +- components/sections/projects.tsx | 4 ++-- components/ui/project-list.tsx | 1 - 6 files changed, 9 insertions(+), 10 deletions(-) diff --git a/components/sections/about.tsx b/components/sections/about.tsx index 9dc8992..912cf95 100644 --- a/components/sections/about.tsx +++ b/components/sections/about.tsx @@ -7,10 +7,10 @@ export async function AboutSection() { const about = await getAbout() return ( -
+
-

+

About Me

diff --git a/components/sections/contact.tsx b/components/sections/contact.tsx index 17d5f7e..4f3260f 100644 --- a/components/sections/contact.tsx +++ b/components/sections/contact.tsx @@ -5,9 +5,9 @@ export async function ContactSection() { const contact = await contactController.getContact() return ( -
+
-

+

Get in Touch

diff --git a/components/sections/experience.tsx b/components/sections/experience.tsx index ccd3ae9..3fba41e 100644 --- a/components/sections/experience.tsx +++ b/components/sections/experience.tsx @@ -5,9 +5,9 @@ export async function ExperienceSection() { const experiences = await experienceController.getExperiences() return ( -
+
-

+

Professional Journey

diff --git a/components/sections/more-about.tsx b/components/sections/more-about.tsx index a16b702..90c3aba 100644 --- a/components/sections/more-about.tsx +++ b/components/sections/more-about.tsx @@ -5,7 +5,7 @@ export async function MoreAboutSection() { const about = await getAbout() return ( -
+
{/* Text Column */}
diff --git a/components/sections/projects.tsx b/components/sections/projects.tsx index e59fea7..a5e8089 100644 --- a/components/sections/projects.tsx +++ b/components/sections/projects.tsx @@ -5,9 +5,9 @@ export async function ProjectsSection() { const projects = await projectController.getProjects() return ( -
+
-

+

Selected Projects

diff --git a/components/ui/project-list.tsx b/components/ui/project-list.tsx index f51c0f6..5899489 100644 --- a/components/ui/project-list.tsx +++ b/components/ui/project-list.tsx @@ -114,7 +114,6 @@ export function ProjectList({ projects }: { projects: ProjectViewModel[] }) { onToggle={() => setOpenId(prev => prev === project.id ? null : project.id)} /> ))} -
From 24384b7ae24a4d4b882d159bee815b316a1e4359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20S=C3=A1nchez?= Date: Thu, 28 May 2026 19:55:24 -0500 Subject: [PATCH 51/51] =?UTF-8?q?fix:=20align=20experience=20card=20elemen?= =?UTF-8?q?ts=20=E2=80=94=20equalize=20title=20heights=20across=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title (h3) is the only variable-height element (can wrap to 2 lines). Mark it with data-exp-title and add a useLayoutEffect in ExperienceList that resets and re-measures all titles to the tallest one before paint, matching the same pattern ProjectList uses for data-caption. Once titles are aligned, year/company/description fall into place naturally. --- components/ui/experience-card.tsx | 2 +- components/ui/experience-list.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/components/ui/experience-card.tsx b/components/ui/experience-card.tsx index 43c1f4e..3469a86 100644 --- a/components/ui/experience-card.tsx +++ b/components/ui/experience-card.tsx @@ -55,7 +55,7 @@ export function ExperienceCard({ experience, isOpen, onToggle }: ExperienceCardP {experience.year} -

+

{experience.title}

{experience.company}

diff --git a/components/ui/experience-list.tsx b/components/ui/experience-list.tsx index cf1b5ae..24011b2 100644 --- a/components/ui/experience-list.tsx +++ b/components/ui/experience-list.tsx @@ -1,6 +1,6 @@ "use client" -import { useRef, useState, useEffect } from "react" +import { useRef, useState, useEffect, useLayoutEffect } from "react" import { ExperienceViewModel } from "@/src/interface-adapters/presenters/experience-presenter" import { ExperienceCard } from "./experience-card" import { ChevronLeft, ChevronRight } from "lucide-react" @@ -36,6 +36,16 @@ export function ExperienceList({ experiences }: { experiences: ExperienceViewMod return () => observer.disconnect() }, [experiences.length]) + useLayoutEffect(() => { + const container = scrollRef.current + if (!container) return + const titles = Array.from(container.querySelectorAll('[data-exp-title]')) + titles.forEach(el => (el.style.height = '')) + if (titles.length === 0) return + const max = Math.max(...titles.map(el => el.getBoundingClientRect().height)) + titles.forEach(el => (el.style.height = `${max}px`)) + }, [experiences]) + useEffect(() => { if (openId !== null) return const container = scrollRef.current