From aa07d6faebc75ac4b1dfb0977ffc2db479a6fc67 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sat, 7 Mar 2026 06:50:56 -0300 Subject: [PATCH 1/6] feat: Implement initial portfolio website structure with core components and a new post creator tool. --- app/components/About.tsx | 228 ++++----------- app/components/Contact.tsx | 296 +++++++------------ app/components/Experience.tsx | 214 +++----------- app/components/Header.tsx | 256 +++++++--------- app/components/Hero.tsx | 323 +++++++++++++-------- app/components/Projects.tsx | 267 +++++------------ app/components/Skills.tsx | 150 ++-------- app/components/WorkModalities.tsx | 296 +++++-------------- app/globals.css | 468 ++++++++++++++++++++---------- app/layout.tsx | 57 +--- app/page.tsx | 75 +---- pnpm-lock.yaml | 462 +++++++++++++++-------------- pnpm-workspace.yaml | 11 +- tailwind.config.js | 65 ++++- tsconfig.json | 24 +- 15 files changed, 1337 insertions(+), 1855 deletions(-) diff --git a/app/components/About.tsx b/app/components/About.tsx index fd42ad4..fadea9e 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -1,194 +1,90 @@ -"use client"; +import { Section } from "./Section"; -import React from "react"; -import { motion } from "framer-motion"; -import { LucideIcon, Brain, Zap, Target, Rocket, Users, Lightbulb, Code, Shield, Terminal, Cpu } from "lucide-react"; -import Squares from "@/components/Squares"; - -// --- Types & Interfaces (Tipagem Forte para Previsibilidade) --- -interface StoryStep { - icon: LucideIcon; - title: string; - content: string; - gradient: string; - accent: string; - id: string; -} - -interface CoreValue { - icon: LucideIcon; - title: string; - description: string; -} - -// --- Data (Desacoplado para facilitar manutenção/CMS) --- -const STORY_DATA: StoryStep[] = [ +const STORY_DATA = [ { id: "KERNEL_INIT", - icon: Brain, - title: "Engenharia de Fundamentos", - content: "Minha mentalidade é pautada pela redução de entropia. Não apenas escrevo código; projeto sistemas onde a escalabilidade é uma propriedade intrínseca da arquitetura, não um ajuste posterior.", - gradient: "from-blue-600 to-cyan-500", - accent: "text-blue-400", + title: "Engenharia de fundamentos", + content: + "Projeto sistemas com foco em previsibilidade, manutenção e crescimento real. Escala entra na arquitetura desde o início.", }, { id: "RUNTIME_EXEC", - icon: Zap, - title: "Entrega sob Restrições", - content: "Em 3 anos de atuação profissional, especializei-me em cenários de alta criticidade. Atuei na ponte entre sistemas legados e microserviços, garantindo a resolução do problema.", - gradient: "from-purple-600 to-pink-600", - accent: "text-purple-400", + title: "Entrega sob restrições", + content: + "Atuo bem em cenários com legado, prazos apertados e decisões de produto em movimento, sem sacrificar legibilidade técnica.", }, ]; -const VALUES_DATA: CoreValue[] = [ +const VALUES_DATA = [ { - icon: Code, - title: "Pragmatismo Arquitetural", - description: "Priorizo a manutenibilidade e a resiliência. Código limpo é o meio, sistemas que geram ROI são o fim." + title: "Pragmatismo arquitetural", + description: + "Decisões guiadas por ROI, operabilidade e clareza para o time que mantém o sistema depois.", }, { - icon: Brain, - title: "Fundacionalismo Técnico", - description: "Domínio profundo de estruturas de dados e protocolos. Tecnologias mudam, fundamentos são perenes." + title: "Fundação técnica", + description: + "Fundamentos fortes em dados, protocolos e modelagem para evitar soluções frágeis e modismos vazios.", }, { - icon: Users, - title: "Propriedade e Ownership", - description: "Comunicação de baixa latência e responsabilidade radical sobre cada entrega e decisão técnica." + title: "Ownership real", + description: + "Responsabilidade sobre entrega, comunicação e qualidade final, não só sobre o trecho de código escrito.", }, { - icon: Shield, - title: "Security by Design", - description: "Integridade e conformidade técnica (GDPR/LGPD) tratadas como requisitos funcionais, não opcionais." + title: "Security by design", + description: + "Integridade, proteção de dados e conformidade tratadas como parte do produto, não como remendo.", }, ]; -// --- Micro-componentes (Feynman Technique: Simplicidade e Reuso) --- - -const SectionHeader = () => ( -
- - - WHOAMI.sys - - - Engenharia
- Nuclear -
-

- "Transformando complexidade técnica em valor de negócio através da engenharia." -

-
-); - -const StoryCard = ({ step, index }: { step: StoryStep; index: number }) => { - const isEven = index % 2 === 0; - - return ( - -
-
- {/* Background Glow */} -
- -
-
-
- -
- - {step.id} - -
- -

{step.title}

-

{step.content}

-
-
-
- - {/* Connection Line & Node */} -
-
-
-
- -
- - ); -}; - export default function About() { return ( -
- {/* Background Gradients */} -
-
-
-
- -
- - -
- {/* Center Line for Mobile/Desktop */} -
- - {STORY_DATA.map((step, idx) => ( - +
+ + Garuva, SC · Remote worldwide +
+ } + > +
+
+ {STORY_DATA.map((step) => ( +
+
{step.id}
+

{step.title}

+

{step.content}

+
))}
- {/* Grid de Valores com Pareto 80/20 */} -
- {VALUES_DATA.map((val, i) => ( - -
- -
-

{val.title}

-

{val.description}

-
- ))} -
- - {/* SEO Semantic Content - Visible but subtle */} -
-
- - System_Signature -
-

- Evandro Fonseca Junior :: Engenheiro Full-Stack
- Next.js / React / Angular / Node.js / Tauri / PostgreSQL / IoT
- Garuva, Santa Catarina, Brasil :: Lat: -26.0264, Long: -48.8544 +

+
System Signature
+

Base técnica e postura de entrega

+

+ Evandro Fonseca Junior. Engenheiro Full-Stack com foco em Next.js, + React, Node.js, Tauri, integrações e arquitetura pragmática.

+
+ {VALUES_DATA.map((value) => ( +
+
+ {value.title} +
+

{value.description}

+
+ ))} +
-
+ ); -} \ No newline at end of file +} diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index b9213c6..57f285a 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -1,223 +1,123 @@ -"use client"; +"use client"; -import React, { useState } from "react"; -import { motion } from "framer-motion"; -import { Send, User, Mail, MessageSquare, Sparkles } from "lucide-react"; -import { ShimmerButton as MagicShimmerButton } from "@/components/magicui/shimmer-button"; -import Squares from "@/components/Squares"; +import type React from "react"; +import { useState } from "react"; +import { Section } from "./Section"; -// --- CONFIGURAÇÃO DE ENGENHARIA --- -// Substitua pelo seu número real: CódigoPaís + DDD + Número (sem espaços ou símbolos) const WHATSAPP_NUMBER = "5547992742365"; -interface FormData { - name: string; - email: string; - subject: string; - message: string; -} - -/** - * Wrapper Component: Resolve o erro de tipagem TS2322 (IntrinsicAttributes) - * Estendemos a interface para aceitar propriedades de estilo customizadas. - */ -interface ExtendedShimmerProps extends React.ComponentProps { - shimmerColor?: string; - shimmerSize?: string; - type?: "button" | "submit" | "reset"; - disabled?: boolean; -} - -const CustomShimmerButton = ({ - children, - className, - shimmerColor = "#3b82f6", - shimmerSize = "0.1em", - ...props -}: ExtendedShimmerProps) => { - return ( - - {children} - - ); -}; - export default function Contact() { - const [isSubmitting, setIsSubmitting] = useState(false); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ name: "", email: "", subject: "full-stack", message: "", }); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - setIsSubmitting(true); - await new Promise((resolve) => setTimeout(resolve, 800)); - - // Definimos os emojis via Unicode Escape para máxima compatibilidade - const icons = { - rocket: "\u{1F680}", - person: "\u{1F464}", - email: "\u{1F4E7}", - tools: "\u{1F6E0}", - chat: "\u{1F4AC}" - }; - - const messageTemplate = `*NOVA SOLICITAÇÃO DE PROJETO* - -*Origem:* Portfólio Profissional -*Prioridade:* Alta (Lead Qualificado) - ---- - *CLIENTE:* ${formData.name} - *EMAIL:* ${formData.email} - *ÁREA:* ${formData.subject.toUpperCase()} - - *DESCRIÇÃO:* -"${formData.message}" ---- -_Mensagem gerada via Evandro.dev | Garuva, SC_`; + const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`; const encodedMessage = encodeURIComponent(messageTemplate); const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`; window.open(whatsappUrl, "_blank", "noopener,noreferrer"); - setIsSubmitting(false); }; - const inputStyles = - "w-full bg-black/40 border border-white/5 rounded-xl px-4 py-3 text-slate-200 placeholder:text-slate-600 focus:border-blue-500/50 focus:ring-2 focus:ring-blue-500/10 focus:bg-black/60 outline-none transition-all duration-300 font-mono text-sm backdrop-blur-sm group-hover:border-white/10"; - const labelStyles = - "text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2 flex items-center gap-2 transition-colors group-hover:text-slate-400"; - return ( -
- - -
- -
- -
-

- Vamos Construir o Futuro -

-

- Descreva seu desafio técnico. Retorno com uma arquitetura sólida em até 24h. -

+
+
+
+
Response SLA
+

Retorno inicial em até 24h

+

+ Ideal para discovery técnico, revisão de arquitetura, rescue de + projeto, otimização de performance ou construção de produto do + zero. +

+
+
WhatsApp: +55 47 99274-2365
+
E-mail: evandrofonsecajunior@gmail.com
+
Base: Garuva, Santa Catarina · atuação remota
+
-
-
-
- - setFormData({ ...formData, name: e.target.value })} - /> -
- -
- - setFormData({ ...formData, email: e.target.value })} - /> -
-
- -
- -
- -
- -
-
-
- -
- -
@@ -913,11 +899,13 @@ ${userName}`; {getCtaMessage()} - {projectType && desiredDeadline && budgetValue && ( -

- {getEstimatedValue()} -

- )} + {formData.projectType && + formData.desiredDeadline && + formData.budgetValue && ( +

+ {getEstimatedValue()} +

+ )} )} diff --git a/app/components/Experience.categorization.pbt.test.tsx b/app/components/Experience.categorization.pbt.test.tsx new file mode 100644 index 0000000..4956ed4 --- /dev/null +++ b/app/components/Experience.categorization.pbt.test.tsx @@ -0,0 +1,556 @@ +import { render } from "@testing-library/react"; +import Experience from "./Experience"; +import fc from "fast-check"; +import { usePortfolioData } from "@/lib/usePortfolioData"; + +// Mock the portfolio data hook +jest.mock("@/lib/usePortfolioData"); +const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< + typeof usePortfolioData +>; + +// Mock framer-motion +jest.mock("framer-motion", () => { + const actual = jest.requireActual("framer-motion"); + return { + ...actual, + useReducedMotion: () => false, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + article: ({ children, ...props }: any) => ( +
{children}
+ ), + }, + }; +}); + +// Mock Section component +jest.mock("./Section", () => ({ + Section: ({ children, ...props }: any) => ( +
{children}
+ ), +})); + +// Mock animation components +jest.mock("./animations/DrawLine", () => ({ + DrawLine: () =>
, +})); + +jest.mock("./animations/CounterValue", () => ({ + CounterValue: ({ value, suffix }: any) => ( + + {value} + {suffix} + + ), +})); + +// Mock SectionLoading +jest.mock("./SectionLoading", () => ({ + SectionLoading: ({ message }: any) =>
{message}
, +})); + +describe("Property 5: Experience Categorization", () => { + // Counter for unique IDs + let idCounter = 0; + + // Generator for experience entries + const experienceArbitrary = (type: "Software" | "Hardware") => + fc.record({ + id: fc + .string({ minLength: 10, maxLength: 20 }) + .map((s) => `exp-${type.toLowerCase()}-${idCounter++}-${s}`), + title: fc.string({ minLength: 5, maxLength: 50 }), + company: fc.string({ minLength: 3, maxLength: 30 }), + period: fc.string({ minLength: 10, maxLength: 30 }), + location: fc.string({ minLength: 5, maxLength: 40 }), + type: fc.constant(type), + description: fc.string({ minLength: 20, maxLength: 200 }), + achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), { + minLength: 1, + maxLength: 5, + }), + technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), { + minLength: 1, + maxLength: 10, + }), + featured: fc.constant(true), + }); + + beforeEach(() => { + idCounter = 0; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should filter and render Software experiences in the Software category", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any experience entry with type="Software", it should be + * filtered and rendered within the Software category group. This ensures + * correct categorization of software-related work experiences. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 5, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 0, + maxLength: 5, + }), + (softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Software category section + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) { + // If no software heading, there should be no software experiences + return softwareExps.length === 0; + } + + // Get the parent container of software experiences + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + // Count experience cards in the software section + const softwareCards = softwareSection.querySelectorAll("article"); + + // The number of software cards should match the number of software experiences + return softwareCards.length === softwareExps.length; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should filter and render Hardware experiences in the Hardware category", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any experience entry with type="Hardware", it should be + * filtered and rendered within the Hardware category group. This ensures + * correct categorization of hardware-related work experiences. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 0, + maxLength: 5, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 5, + }), + (softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Hardware category section + const hardwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Hardware")); + + if (!hardwareHeading) { + // If no hardware heading, there should be no hardware experiences + return hardwareExps.length === 0; + } + + // Get the parent container of hardware experiences + const hardwareSection = hardwareHeading.closest("div"); + if (!hardwareSection) return false; + + // Count experience cards in the hardware section + const hardwareCards = hardwareSection.querySelectorAll("article"); + + // The number of hardware cards should match the number of hardware experiences + return hardwareCards.length === hardwareExps.length; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should separate Software and Hardware experiences into distinct category groups", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any mix of Software and Hardware experiences, they should be + * rendered in separate category groups with distinct headings. No experience + * should appear in both categories. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 3, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 3, + }), + (softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find both category headings + const headings = Array.from(container.querySelectorAll("h3")); + const softwareHeading = headings.find((h3) => + h3.textContent?.includes("Software"), + ); + const hardwareHeading = headings.find((h3) => + h3.textContent?.includes("Hardware"), + ); + + // Both headings should exist + if (!softwareHeading || !hardwareHeading) return false; + + // Get all experience cards + const allCards = container.querySelectorAll("article"); + + // Total cards should equal total experiences + return allCards.length === allExperiences.length; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should not render category heading when no experiences exist for that type", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any category (Software or Hardware) with zero experiences, + * the category heading should not be rendered. This ensures clean UI without + * empty category sections. + */ + fc.assert( + fc.property( + fc.oneof( + fc.record({ + software: fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 5, + }), + hardware: fc.constant([]), + }), + fc.record({ + software: fc.constant([]), + hardware: fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 5, + }), + }), + ), + ({ software, hardware }) => { + const allExperiences = [...software, ...hardware]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + const headings = Array.from(container.querySelectorAll("h3")); + const softwareHeading = headings.find((h3) => + h3.textContent?.includes("Software"), + ); + const hardwareHeading = headings.find((h3) => + h3.textContent?.includes("Hardware"), + ); + + // If software array is empty, no software heading should exist + const softwareCheck = + software.length === 0 ? !softwareHeading : !!softwareHeading; + + // If hardware array is empty, no hardware heading should exist + const hardwareCheck = + hardware.length === 0 ? !hardwareHeading : !!hardwareHeading; + + return softwareCheck && hardwareCheck; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain experience data integrity within each category", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any experience entry, its data (title, company, description) + * should be rendered correctly within its assigned category. The categorization + * process should not modify or lose experience data. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 3, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 3, + }), + (softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Check that all experience titles are present in the rendered output + const allTitlesPresent = allExperiences.every((exp) => { + const titleElements = Array.from(container.querySelectorAll("h3")); + return titleElements.some((el) => + el.textContent?.includes(exp.title), + ); + }); + + // Check that all company names are present + const allCompaniesPresent = allExperiences.every((exp) => { + return container.textContent?.includes(exp.company); + }); + + return allTitlesPresent && allCompaniesPresent; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should handle mixed case and variations in type field values", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any experience entry, the type field should be matched + * case-sensitively to ensure correct categorization. Only exact matches + * to "Software" or "Hardware" should be categorized. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 3, + }), + (softwareExps) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Software category heading + const headings = Array.from(container.querySelectorAll("h3")); + const softwareHeading = headings.find((h3) => + h3.textContent?.includes("Software"), + ); + + // Software heading should exist + if (!softwareHeading) return false; + + // All experiences should be rendered + const cards = container.querySelectorAll("article"); + return cards.length === softwareExps.length; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should render all experiences when both categories have entries", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any non-empty set of Software and Hardware experiences, + * all experiences should be rendered across both categories. No experiences + * should be lost during the categorization process. + */ + fc.assert( + fc.property( + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 4, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 4, + }), + (softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Count all rendered experience cards + const allCards = container.querySelectorAll("article"); + + // Total cards should equal total experiences + return allCards.length === allExperiences.length; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain category separation across multiple renders", () => { + /** + * **Validates: Requirements 4.1** + * + * Property: For any number of component renders with the same data, + * the categorization should remain consistent. Software experiences + * should always appear in the Software category and Hardware experiences + * in the Hardware category. + */ + fc.assert( + fc.property( + fc.integer({ min: 2, max: 4 }), // Number of renders + fc.array(experienceArbitrary("Software"), { + minLength: 1, + maxLength: 2, + }), + fc.array(experienceArbitrary("Hardware"), { + minLength: 1, + maxLength: 2, + }), + (numRenders, softwareExps, hardwareExps) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + let allRendersValid = true; + + for (let i = 0; i < numRenders; i++) { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container, unmount } = render(); + + // Check that both categories exist + const headings = Array.from(container.querySelectorAll("h3")); + const softwareHeading = headings.find((h3) => + h3.textContent?.includes("Software"), + ); + const hardwareHeading = headings.find((h3) => + h3.textContent?.includes("Hardware"), + ); + + if (!softwareHeading || !hardwareHeading) { + allRendersValid = false; + } + + // Check total card count + const allCards = container.querySelectorAll("article"); + if (allCards.length !== allExperiences.length) { + allRendersValid = false; + } + + unmount(); + } + + return allRendersValid; + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Experience.chronologicalOrder.pbt.test.tsx b/app/components/Experience.chronologicalOrder.pbt.test.tsx new file mode 100644 index 0000000..92d75f2 --- /dev/null +++ b/app/components/Experience.chronologicalOrder.pbt.test.tsx @@ -0,0 +1,641 @@ +import { render } from "@testing-library/react"; +import Experience from "./Experience"; +import fc from "fast-check"; +import { usePortfolioData } from "@/lib/usePortfolioData"; + +// Mock the portfolio data hook +jest.mock("@/lib/usePortfolioData"); +const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< + typeof usePortfolioData +>; + +// Mock framer-motion +jest.mock("framer-motion", () => { + const actual = jest.requireActual("framer-motion"); + return { + ...actual, + useReducedMotion: () => false, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + article: ({ children, ...props }: any) => ( +
{children}
+ ), + }, + }; +}); + +// Mock Section component +jest.mock("./Section", () => ({ + Section: ({ children, ...props }: any) => ( +
{children}
+ ), +})); + +// Mock animation components +jest.mock("./animations/DrawLine", () => ({ + DrawLine: () =>
, +})); + +jest.mock("./animations/CounterValue", () => ({ + CounterValue: ({ value, suffix }: any) => ( + + {value} + {suffix} + + ), +})); + +// Mock SectionLoading +jest.mock("./SectionLoading", () => ({ + SectionLoading: ({ message }: any) =>
{message}
, +})); + +describe("Property 6: Chronological Ordering Within Categories", () => { + // Counter for unique IDs + let idCounter = 0; + + // Generator for experience entries with specific year + const experienceWithYearArbitrary = ( + type: "Software" | "Hardware", + year: number, + ) => + fc.record({ + id: fc + .string({ minLength: 10, maxLength: 20 }) + .map((s) => `exp-${type.toLowerCase()}-${year}-${idCounter++}-${s}`), + title: fc.string({ minLength: 5, maxLength: 50 }), + company: fc.string({ minLength: 3, maxLength: 30 }), + period: fc.constant(`Janeiro ${year} - Dezembro ${year}`), + location: fc.string({ minLength: 5, maxLength: 40 }), + type: fc.constant(type), + description: fc.string({ minLength: 20, maxLength: 200 }), + achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), { + minLength: 1, + maxLength: 5, + }), + technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), { + minLength: 1, + maxLength: 10, + }), + featured: fc.constant(true), + }); + + // Generator for array of experiences with random years + const experiencesWithYearsArbitrary = ( + type: "Software" | "Hardware", + count: number, + ) => { + return fc + .array(fc.integer({ min: 2015, max: 2025 }), { + minLength: count, + maxLength: count, + }) + .chain((years) => { + return fc.tuple( + ...years.map((year) => experienceWithYearArbitrary(type, year)), + ); + }); + }; + + beforeEach(() => { + idCounter = 0; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should sort Software experiences in descending chronological order by year", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any set of Software experiences with different years, + * they should be rendered in descending chronological order (newest first). + * This ensures users see the most recent experiences at the top. + */ + fc.assert( + fc.property( + fc + .integer({ min: 2, max: 5 }) + .chain((count) => experiencesWithYearsArbitrary("Software", count)), + (softwareExps) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Software category section + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + // Get the parent container of software experiences + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + // Get all experience cards in the software section + const softwareCards = Array.from( + softwareSection.querySelectorAll("article"), + ); + + // Extract years from the rendered cards + const renderedYears = softwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if years are in descending order + for (let i = 0; i < renderedYears.length - 1; i++) { + if (renderedYears[i] < renderedYears[i + 1]) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should sort Hardware experiences in descending chronological order by year", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any set of Hardware experiences with different years, + * they should be rendered in descending chronological order (newest first). + * This ensures users see the most recent experiences at the top. + */ + fc.assert( + fc.property( + fc + .integer({ min: 2, max: 5 }) + .chain((count) => experiencesWithYearsArbitrary("Hardware", count)), + (hardwareExps) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: hardwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Hardware category section + const hardwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Hardware")); + + if (!hardwareHeading) return false; + + // Get the parent container of hardware experiences + const hardwareSection = hardwareHeading.closest("div"); + if (!hardwareSection) return false; + + // Get all experience cards in the hardware section + const hardwareCards = Array.from( + hardwareSection.querySelectorAll("article"), + ); + + // Extract years from the rendered cards + const renderedYears = hardwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if years are in descending order + for (let i = 0; i < renderedYears.length - 1; i++) { + if (renderedYears[i] < renderedYears[i + 1]) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain chronological order within each category independently", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any mix of Software and Hardware experiences with various years, + * each category should maintain its own descending chronological order independently. + * The ordering in one category should not affect the other. + */ + fc.assert( + fc.property( + fc + .integer({ min: 2, max: 4 }) + .chain((softwareCount) => + fc + .integer({ min: 2, max: 4 }) + .chain((hardwareCount) => + fc.tuple( + experiencesWithYearsArbitrary("Software", softwareCount), + experiencesWithYearsArbitrary("Hardware", hardwareCount), + ), + ), + ), + ([softwareExps, hardwareExps]) => { + const allExperiences = [...softwareExps, ...hardwareExps]; + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: allExperiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Check Software category ordering + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + const softwareCards = Array.from( + softwareSection.querySelectorAll("article"), + ); + const softwareYears = softwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if software years are in descending order + for (let i = 0; i < softwareYears.length - 1; i++) { + if (softwareYears[i] < softwareYears[i + 1]) { + return false; + } + } + + // Check Hardware category ordering + const hardwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Hardware")); + + if (!hardwareHeading) return false; + + const hardwareSection = hardwareHeading.closest("div"); + if (!hardwareSection) return false; + + const hardwareCards = Array.from( + hardwareSection.querySelectorAll("article"), + ); + const hardwareYears = hardwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if hardware years are in descending order + for (let i = 0; i < hardwareYears.length - 1; i++) { + if (hardwareYears[i] < hardwareYears[i + 1]) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should handle experiences with the same year within a category", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any set of experiences with the same year within a category, + * they should all be rendered without errors. The relative order of same-year + * experiences is not specified but should be stable. + */ + fc.assert( + fc.property( + fc.integer({ min: 2015, max: 2025 }), + fc.integer({ min: 2, max: 4 }), + (year, count) => { + const softwareExps = Array.from({ length: count }, (_, i) => ({ + id: `exp-software-${year}-${i}`, + title: `Software Title ${i}`, + company: `Company ${i}`, + period: `Janeiro ${year} - Dezembro ${year}`, + location: `Location ${i}`, + type: "Software" as const, + description: `Description ${i}`, + achievements: [`Achievement ${i}`], + technologies: [`Tech ${i}`], + featured: true, + })); + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Software category section + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + const softwareCards = softwareSection.querySelectorAll("article"); + + // All experiences should be rendered + return softwareCards.length === count; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should correctly parse years from various period formats", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any experience with a period string containing a 4-digit year, + * the year should be correctly extracted and used for chronological ordering. + * This ensures robust parsing across different date formats. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + year: fc.integer({ min: 2015, max: 2025 }), + format: fc.constantFrom( + (y: number) => `Janeiro ${y} - Dezembro ${y}`, + (y: number) => `${y} - Presente`, + (y: number) => `Dezembro ${y} - Fevereiro ${y + 1}`, + (y: number) => `Junho ${y} - Presente`, + ), + }), + { minLength: 2, maxLength: 5 }, + ), + (periodConfigs) => { + const softwareExps = periodConfigs.map((config, i) => ({ + id: `exp-software-${i}`, + title: `Software Title ${i}`, + company: `Company ${i}`, + period: config.format(config.year), + location: `Location ${i}`, + type: "Software" as const, + description: `Description ${i}`, + achievements: [`Achievement ${i}`], + technologies: [`Tech ${i}`], + featured: true, + })); + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the Software category section + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + const softwareCards = Array.from( + softwareSection.querySelectorAll("article"), + ); + + // Extract years from the rendered cards + const renderedYears = softwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if years are in descending order + for (let i = 0; i < renderedYears.length - 1; i++) { + if (renderedYears[i] < renderedYears[i + 1]) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain chronological order across component re-renders", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any set of experiences, the chronological ordering should + * remain consistent across multiple component renders. The sorting should + * be deterministic and stable. + */ + fc.assert( + fc.property( + fc + .integer({ min: 3, max: 5 }) + .chain((count) => experiencesWithYearsArbitrary("Software", count)), + fc.integer({ min: 2, max: 3 }), // Number of renders + (softwareExps, numRenders) => { + let firstRenderYears: number[] = []; + + for (let i = 0; i < numRenders; i++) { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container, unmount } = render(); + + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + const softwareCards = Array.from( + softwareSection.querySelectorAll("article"), + ); + const renderedYears = softwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + if (i === 0) { + firstRenderYears = renderedYears; + } else { + // Check if the order matches the first render + if (renderedYears.length !== firstRenderYears.length) + return false; + for (let j = 0; j < renderedYears.length; j++) { + if (renderedYears[j] !== firstRenderYears[j]) return false; + } + } + + unmount(); + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should sort experiences with year ranges by the first year", () => { + /** + * **Validates: Requirements 4.3** + * + * Property: For any experience with a period containing multiple years + * (e.g., "2023 - 2024"), the sorting should use the first year found. + * This ensures consistent ordering for multi-year experiences. + */ + fc.assert( + fc.property( + fc.array(fc.integer({ min: 2015, max: 2024 }), { + minLength: 2, + maxLength: 5, + }), + (years) => { + const softwareExps = years.map((year, i) => ({ + id: `exp-software-${i}`, + title: `Software Title ${i}`, + company: `Company ${i}`, + period: `Janeiro ${year} - Dezembro ${year + 1}`, + location: `Location ${i}`, + type: "Software" as const, + description: `Description ${i}`, + achievements: [`Achievement ${i}`], + technologies: [`Tech ${i}`], + featured: true, + })); + + mockUsePortfolioData.mockReturnValue({ + data: { + experiences: softwareExps, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + const softwareHeading = Array.from( + container.querySelectorAll("h3"), + ).find((h3) => h3.textContent?.includes("Software")); + + if (!softwareHeading) return false; + + const softwareSection = softwareHeading.closest("div"); + if (!softwareSection) return false; + + const softwareCards = Array.from( + softwareSection.querySelectorAll("article"), + ); + + // Extract first year from each rendered card + const renderedYears = softwareCards.map((card) => { + const periodText = + card.querySelector(".ds-kicker")?.textContent || ""; + const yearMatch = periodText.match(/\d{4}/); + return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0; + }); + + // Check if years are in descending order + for (let i = 0; i < renderedYears.length - 1; i++) { + if (renderedYears[i] < renderedYears[i + 1]) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Experience.headingHierarchy.pbt.test.tsx b/app/components/Experience.headingHierarchy.pbt.test.tsx new file mode 100644 index 0000000..1f4a6ee --- /dev/null +++ b/app/components/Experience.headingHierarchy.pbt.test.tsx @@ -0,0 +1,407 @@ +import { render } from "@testing-library/react"; +import Experience from "./Experience"; +import fc from "fast-check"; +import { usePortfolioData } from "@/lib/usePortfolioData"; + +// Mock the portfolio data hook +jest.mock("@/lib/usePortfolioData"); +const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< + typeof usePortfolioData +>; + +// Mock framer-motion +jest.mock("framer-motion", () => { + const actual = jest.requireActual("framer-motion"); + return { + ...actual, + useReducedMotion: () => false, + motion: { + div: ({ children, ...props }: any) =>
{children}
, + article: ({ children, ...props }: any) => ( +
{children}
+ ), + }, + }; +}); + +// Mock Section component +jest.mock("./Section", () => ({ + Section: ({ children, ...props }: any) => ( +
{children}
+ ), +})); + +// Mock animation components +jest.mock("./animations/DrawLine", () => ({ + DrawLine: () =>
, +})); + +jest.mock("./animations/CounterValue", () => ({ + CounterValue: ({ value, suffix }: any) => ( + + {value} + {suffix} + + ), +})); + +// Mock SectionLoading +jest.mock("./SectionLoading", () => ({ + SectionLoading: ({ message }: any) =>
{message}
, +})); + +describe("Property 11: Semantic Heading Hierarchy", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => { + /** + * **Validates: Requirements 6.4** + * + * Property: For any sequence of heading elements in the Experience section, + * the heading levels should follow proper semantic hierarchy (h2 for section + * title, h3 for experience card titles, no skipped levels). This ensures + * screen readers can properly navigate the document structure. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + id: fc.string(), + title: fc.string({ minLength: 1 }), + company: fc.string({ minLength: 1 }), + type: fc.constantFrom("Software", "Hardware"), + period: fc.string({ minLength: 1 }), + location: fc.string({ minLength: 1 }), + description: fc.string({ minLength: 1 }), + achievements: fc.array(fc.string({ minLength: 1 })), + technologies: fc.array(fc.string({ minLength: 1 })), + featured: fc.constant(true), + }), + { minLength: 1, maxLength: 10 }, + ), + (experiences) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Get all heading elements in order + const headings = Array.from( + container.querySelectorAll("h1, h2, h3, h4, h5, h6"), + ); + + if (headings.length === 0) { + return false; + } + + // Extract heading levels + const levels = headings.map((h) => parseInt(h.tagName.substring(1))); + + // Check for proper hierarchy: + // 1. Should start with h2 (section title) + // 2. Experience card titles should be h3 + // 3. No skipped levels (difference should not be > 1) + + // First heading should be h2 (section title) + if (levels[0] !== 2) { + return false; + } + + // Check for skipped levels + for (let i = 1; i < levels.length; i++) { + const diff = levels[i] - levels[i - 1]; + // Difference should be -1, 0, or 1 (no skipping levels) + if (Math.abs(diff) > 1 && diff > 0) { + return false; + } + } + + // All experience card titles should be h3 + const cardTitles = container.querySelectorAll("article h3"); + if (cardTitles.length === 0) { + return false; + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should not skip heading levels in the hierarchy", () => { + /** + * **Validates: Requirements 6.4** + * + * Property: For any generated set of experiences, the heading hierarchy + * should never skip levels (e.g., h2 directly to h4). This is critical + * for accessibility as screen reader users rely on heading levels to + * understand document structure. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + id: fc.uuid(), + title: fc.lorem({ maxCount: 5 }), + company: fc.lorem({ maxCount: 2 }), + type: fc.constantFrom("Software", "Hardware"), + period: fc.string({ minLength: 1 }), + location: fc.string({ minLength: 1 }), + description: fc.lorem({ maxCount: 10 }), + achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }), + technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }), + featured: fc.constant(true), + }), + { minLength: 2, maxLength: 8 }, + ), + (experiences) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Get all headings + const headings = Array.from( + container.querySelectorAll("h1, h2, h3, h4, h5, h6"), + ); + const levels = headings.map((h) => parseInt(h.tagName.substring(1))); + + // Check that we never skip levels when going deeper + for (let i = 1; i < levels.length; i++) { + const currentLevel = levels[i]; + const previousLevel = levels[i - 1]; + + // When going deeper (increasing level number), should only increase by 1 + if ( + currentLevel > previousLevel && + currentLevel - previousLevel > 1 + ) { + return false; + } + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should use h2 for section title consistently", () => { + /** + * **Validates: Requirements 6.4** + * + * Property: For any Experience section render, the main section title + * should always be an h2 element. This maintains consistency with the + * overall page structure where main sections use h2. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + id: fc.string(), + title: fc.string({ minLength: 1 }), + company: fc.string({ minLength: 1 }), + type: fc.constantFrom("Software", "Hardware"), + period: fc.string({ minLength: 1 }), + location: fc.string({ minLength: 1 }), + description: fc.string({ minLength: 1 }), + achievements: fc.array(fc.string({ minLength: 1 })), + technologies: fc.array(fc.string({ minLength: 1 })), + featured: fc.constant(true), + }), + { minLength: 1, maxLength: 5 }, + ), + (experiences) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find the section element + const section = container.querySelector("section"); + if (!section) return false; + + // The first heading in the section should be h2 + const firstHeading = section.querySelector("h1, h2, h3, h4, h5, h6"); + if (!firstHeading) return false; + + return firstHeading.tagName === "H2"; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should use h3 for all experience card titles", () => { + /** + * **Validates: Requirements 6.4** + * + * Property: For any set of experience cards rendered, each card's title + * should be an h3 element. This maintains proper hierarchy under the + * section h2 and ensures consistent navigation for assistive technology. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + id: fc.uuid(), + title: fc.lorem({ maxCount: 5 }), + company: fc.lorem({ maxCount: 2 }), + type: fc.constantFrom("Software", "Hardware"), + period: fc.string({ minLength: 1 }), + location: fc.string({ minLength: 1 }), + description: fc.lorem({ maxCount: 10 }), + achievements: fc.array(fc.lorem({ maxCount: 5 })), + technologies: fc.array(fc.lorem({ maxCount: 1 })), + featured: fc.constant(true), + }), + { minLength: 1, maxLength: 10 }, + ), + (experiences) => { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container } = render(); + + // Find all article elements (experience cards) + const articles = container.querySelectorAll("article"); + + if (articles.length === 0) { + return false; + } + + // Each article should have at least one h3 for the title + let allArticlesHaveH3 = true; + + articles.forEach((article) => { + const h3Elements = article.querySelectorAll("h3"); + // Should have at least one h3 (the title) + if (h3Elements.length === 0) { + allArticlesHaveH3 = false; + } + }); + + return allArticlesHaveH3; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should maintain hierarchy across multiple renders", () => { + /** + * **Validates: Requirements 6.4** + * + * Property: For any number of component re-renders with the same data, + * the heading hierarchy should remain consistent. This ensures the + * semantic structure is stable and reliable for assistive technology. + */ + fc.assert( + fc.property( + fc.array( + fc.record({ + id: fc.string(), + title: fc.string({ minLength: 1 }), + company: fc.string({ minLength: 1 }), + type: fc.constantFrom("Software", "Hardware"), + period: fc.string({ minLength: 1 }), + location: fc.string({ minLength: 1 }), + description: fc.string({ minLength: 1 }), + achievements: fc.array(fc.string({ minLength: 1 })), + technologies: fc.array(fc.string({ minLength: 1 })), + featured: fc.constant(true), + }), + { minLength: 1, maxLength: 5 }, + ), + fc.integer({ min: 2, max: 5 }), + (experiences, numRenders) => { + let previousHierarchy: number[] | null = null; + + for (let i = 0; i < numRenders; i++) { + mockUsePortfolioData.mockReturnValue({ + data: { + experiences, + personal: {} as any, + projects: [], + skills: { technical: [], soft: [] }, + contact: {} as any, + social: [], + }, + loading: false, + error: null, + }); + + const { container, unmount } = render(); + + const headings = Array.from( + container.querySelectorAll("h1, h2, h3, h4, h5, h6"), + ); + const currentHierarchy = headings.map((h) => + parseInt(h.tagName.substring(1)), + ); + + if (previousHierarchy !== null) { + // Hierarchy should be identical across renders + if ( + JSON.stringify(currentHierarchy) !== + JSON.stringify(previousHierarchy) + ) { + return false; + } + } + + previousHierarchy = currentHierarchy; + unmount(); + } + + return true; + }, + ), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Experience.tsx b/app/components/Experience.tsx index 8a1766d..74cff5d 100644 --- a/app/components/Experience.tsx +++ b/app/components/Experience.tsx @@ -1,72 +1,126 @@ "use client"; import { useMemo } from "react"; +import { motion, type Variants, useReducedMotion } from "framer-motion"; import { usePortfolioData } from "@/lib/usePortfolioData"; import { Section } from "./Section"; -import { Reveal } from "./Reveal"; +import { DrawLine } from "./animations/DrawLine"; +import { CounterValue } from "./animations/CounterValue"; +import { SectionLoading } from "./SectionLoading"; export default function Experience() { const { data, loading, error } = usePortfolioData(); + const shouldReduceMotion = useReducedMotion(); const sortedExperiences = useMemo(() => { if (!data?.experiences) return []; return [...data.experiences] .filter((exp) => exp.featured) .sort((a, b) => { - const yearA = parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10); - const yearB = parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10); + const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10); + const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10); return yearB - yearA; }); }, [data]); - if (loading) return
Loading system logs
; - if (error || !data) + if (loading) { + return ; + } + + if (error || !data) { return
Error loading system logs
; + } + + const listVariants: Variants = { + hidden: {}, + visible: { + transition: { + delayChildren: 0.08, + staggerChildren: 0.08, + }, + }, + }; + + const itemVariants: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" }, + visible: { + opacity: 1, + y: 0, + clipPath: "inset(0% 0 0 0)", + transition: shouldReduceMotion + ? { duration: 0.25 } + : { duration: 0.8, ease: [0.16, 1, 0.3, 1] }, + }, + }; return (
-
- {sortedExperiences.map((exp, index) => ( - -
-
-
-
{exp.period}
-

{exp.title}

-
- {exp.company} · {exp.type} -
-
-
- {exp.location} - - {exp.technologies.length} tecnologias - + + {sortedExperiences.map((exp) => ( + +
+
+
{exp.period}
+ +

{exp.title}

+
+ {exp.company} | {exp.type}
-

{exp.description}

-
    - {exp.achievements?.map((achievement) => ( -
  • {achievement}
  • - ))} -
-
    - {exp.technologies?.map((tech) => ( -
  • - {tech} -
  • - ))} -
-
-
+
+ {exp.location} + + + +
+
+

{exp.description}

+
    + {exp.achievements?.map((achievement) => ( +
  • {achievement}
  • + ))} +
+
    + {exp.technologies?.map((tech) => ( +
  • + {tech} +
  • + ))} +
+ ))} -
+
); } diff --git a/app/components/Footer.ariaLabels.pbt.test.tsx b/app/components/Footer.ariaLabels.pbt.test.tsx new file mode 100644 index 0000000..eff1f32 --- /dev/null +++ b/app/components/Footer.ariaLabels.pbt.test.tsx @@ -0,0 +1,300 @@ +import { render } from "@testing-library/react"; +import Footer from "./Footer"; +import fc from "fast-check"; + +describe("Property 9: ARIA Labels for Links", () => { + it("should have descriptive aria-label attributes for all social media links", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any social media link in the Footer component, it should have + * a descriptive aria-label attribute that identifies the platform and action. + * This ensures accessibility for users with assistive technologies. + */ + fc.assert( + fc.property( + fc.constant(null), // No random input needed, testing static component + () => { + const { container } = render(
); + + // Find all social media links (they have SVG icons and are in the Connect section) + const socialLinks = container.querySelectorAll( + "footer a[aria-label]", + ); + + // Filter to only social links (those with SVG children) + const socialLinksWithSvg = Array.from(socialLinks).filter((link) => { + return link.querySelector("svg") !== null; + }); + + // Verify we found social links + if (socialLinksWithSvg.length === 0) { + return false; + } + + // Check each social link has a non-empty, descriptive aria-label + return socialLinksWithSvg.every((link) => { + const ariaLabel = link.getAttribute("aria-label"); + + // aria-label must exist and be non-empty + if (!ariaLabel || ariaLabel.trim().length === 0) { + return false; + } + + // aria-label should be descriptive (at least 5 characters) + // and should describe an action (contain words like "Visit", "View", "Go to", etc.) + return ariaLabel.length >= 5; + }); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should have unique aria-label for each social platform", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any set of social media links, each should have a unique + * aria-label that identifies the specific platform. This ensures users can + * distinguish between different social links when using screen readers. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all social media links with aria-labels + const socialLinks = container.querySelectorAll("footer a[aria-label]"); + + // Filter to only social links (those with SVG children) + const socialLinksWithSvg = Array.from(socialLinks).filter((link) => { + return link.querySelector("svg") !== null; + }); + + // Collect all aria-labels + const ariaLabels = socialLinksWithSvg.map((link) => + link.getAttribute("aria-label"), + ); + + // Verify we have aria-labels + if (ariaLabels.length === 0) { + return false; + } + + // Check that all aria-labels are unique + const uniqueLabels = new Set(ariaLabels); + return uniqueLabels.size === ariaLabels.length; + }), + { numRuns: 100 }, + ); + }); + + it("should have aria-label that identifies both platform and action", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any social media link, the aria-label should contain both + * the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.). + * This provides complete context for assistive technology users. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all social media links + const socialLinks = container.querySelectorAll("footer a[aria-label]"); + + // Filter to only social links (those with SVG children) + const socialLinksWithSvg = Array.from(socialLinks).filter((link) => { + return link.querySelector("svg") !== null; + }); + + // Verify we found social links + if (socialLinksWithSvg.length === 0) { + return false; + } + + // Common action verbs for social links + const actionVerbs = ["visit", "view", "go to", "open", "see", "check"]; + + // Check each link has an action verb in its aria-label + return socialLinksWithSvg.every((link) => { + const ariaLabel = link.getAttribute("aria-label"); + + if (!ariaLabel) { + return false; + } + + const lowerLabel = ariaLabel.toLowerCase(); + + // Check if aria-label contains at least one action verb + return actionVerbs.some((verb) => lowerLabel.includes(verb)); + }); + }), + { numRuns: 100 }, + ); + }); + + it("should maintain aria-labels across multiple renders", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any number of component renders, social links should consistently + * maintain their aria-label attributes. This verifies that accessibility attributes + * are not accidentally removed during re-renders. + */ + fc.assert( + fc.property( + fc.integer({ min: 1, max: 5 }), // Test 1-5 renders + (numRenders) => { + let allRendersValid = true; + + for (let i = 0; i < numRenders; i++) { + const { container, unmount } = render(
); + + // Find social links + const socialLinks = container.querySelectorAll( + "footer a[aria-label]", + ); + const socialLinksWithSvg = Array.from(socialLinks).filter( + (link) => { + return link.querySelector("svg") !== null; + }, + ); + + // Check each link has a valid aria-label + socialLinksWithSvg.forEach((link) => { + const ariaLabel = link.getAttribute("aria-label"); + + if (!ariaLabel || ariaLabel.trim().length < 5) { + allRendersValid = false; + } + }); + + unmount(); + } + + return allRendersValid; + }, + ), + { numRuns: 100 }, + ); + }); + + it('should have aria-label for every social link with target="_blank"', () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any social link that opens in a new tab (target="_blank"), + * it must have an aria-label. This ensures users with assistive technologies + * are informed about where the link leads before activating it. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all links that open in new tab + const newTabLinks = container.querySelectorAll( + 'footer a[target="_blank"]', + ); + + // Verify we found links + if (newTabLinks.length === 0) { + return false; + } + + // Each new tab link should have an aria-label + return Array.from(newTabLinks).every((link) => { + const ariaLabel = link.getAttribute("aria-label"); + return ariaLabel !== null && ariaLabel.trim().length > 0; + }); + }), + { numRuns: 100 }, + ); + }); + + it("should not have empty or whitespace-only aria-labels", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any social media link with an aria-label attribute, + * the value should not be empty or contain only whitespace. Empty + * aria-labels provide no accessibility benefit and can confuse screen readers. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all social media links + const socialLinks = container.querySelectorAll("footer a[aria-label]"); + + // Filter to only social links (those with SVG children) + const socialLinksWithSvg = Array.from(socialLinks).filter((link) => { + return link.querySelector("svg") !== null; + }); + + // Verify we found social links + if (socialLinksWithSvg.length === 0) { + return false; + } + + // Check that no aria-label is empty or whitespace-only + return socialLinksWithSvg.every((link) => { + const ariaLabel = link.getAttribute("aria-label"); + return ariaLabel !== null && ariaLabel.trim().length > 0; + }); + }), + { numRuns: 100 }, + ); + }); + + it("should have aria-label that matches or relates to the link title", () => { + /** + * **Validates: Requirements 6.1** + * + * Property: For any social media link with both aria-label and title attributes, + * the aria-label should provide context that relates to the title (platform name). + * This ensures consistency between visual tooltips and screen reader announcements. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all social media links with both aria-label and title + const socialLinks = container.querySelectorAll( + "footer a[aria-label][title]", + ); + + // Filter to only social links (those with SVG children) + const socialLinksWithSvg = Array.from(socialLinks).filter((link) => { + return link.querySelector("svg") !== null; + }); + + // If no links have both attributes, that's okay (not all implementations require title) + if (socialLinksWithSvg.length === 0) { + return true; + } + + // Check that aria-label relates to title + return socialLinksWithSvg.every((link) => { + const ariaLabel = link.getAttribute("aria-label"); + const title = link.getAttribute("title"); + + if (!ariaLabel || !title) { + return false; + } + + // aria-label should contain the platform name from title + // or title should be contained in aria-label + const lowerLabel = ariaLabel.toLowerCase(); + const lowerTitle = title.toLowerCase(); + + return ( + lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel) + ); + }); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Footer.responsiveLayout.pbt.test.tsx b/app/components/Footer.responsiveLayout.pbt.test.tsx new file mode 100644 index 0000000..a916d67 --- /dev/null +++ b/app/components/Footer.responsiveLayout.pbt.test.tsx @@ -0,0 +1,264 @@ +import { render } from "@testing-library/react"; +import Footer from "./Footer"; +import fc from "fast-check"; + +describe("Property 3: Responsive Component Layout", () => { + it("should include Tailwind responsive utility classes for grid layout", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component, it should include Tailwind responsive + * utility classes (sm:, md:, lg:) to adapt the grid layout across viewport sizes. + * This ensures the component is responsive and provides optimal layout at different + * screen widths. + */ + fc.assert( + fc.property( + fc.constant(null), // No random input needed, testing static component + () => { + const { container } = render(
); + + // Find the main grid container + const gridContainer = container.querySelector("footer .grid"); + + // Verify grid container exists + if (!gridContainer) { + return false; + } + + const className = gridContainer.className; + + // Check for responsive grid-cols classes + // Should have base grid-cols-1 and responsive variants + const hasBaseGridCols = className.includes("grid-cols-1"); + const hasMdResponsive = className.includes("md:grid-cols-"); + const hasLgResponsive = className.includes("lg:grid-cols-"); + + return hasBaseGridCols && hasMdResponsive && hasLgResponsive; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should include responsive padding classes", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component, the container should use the design system + * section class (ds-section) which provides responsive padding that scales + * appropriately across viewport sizes. This ensures consistent spacing that + * adapts to different screen widths. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find the footer element + const footer = container.querySelector("footer"); + + if (!footer) { + return false; + } + + const className = footer.className; + + // Check for design system section class which handles responsive padding + const hasDsSection = className.includes("ds-section"); + + // The ds-section class in globals.css provides responsive padding via CSS + // padding: var(--sp-20) var(--sp-16) on desktop + // padding: var(--sp-16) var(--sp-6) on mobile (via @media query) + return hasDsSection; + }), + { numRuns: 100 }, + ); + }); + + it("should include responsive gap classes for grid spacing", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component grid, it should include gap + * classes to adjust spacing between grid items. + * This ensures optimal spacing that scales with the layout. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find the grid container + const gridContainer = container.querySelector("footer .grid"); + + if (!gridContainer) { + return false; + } + + const className = gridContainer.className; + + // Check for gap classes (base gap) + const hasGap = className.includes("gap-sp-"); + + // Should have at least base gap + return hasGap; + }), + { numRuns: 100 }, + ); + }); + + it("should maintain responsive classes across multiple renders", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any number of component renders, the Footer should consistently + * maintain its responsive utility classes. This verifies that responsive behavior + * is not accidentally removed during re-renders. + */ + fc.assert( + fc.property( + fc.integer({ min: 1, max: 5 }), // Test 1-5 renders + (numRenders) => { + let allRendersValid = true; + + for (let i = 0; i < numRenders; i++) { + const { container, unmount } = render(
); + + // Check grid container + const gridContainer = container.querySelector("footer .grid"); + + if (!gridContainer) { + allRendersValid = false; + unmount(); + continue; + } + + const className = gridContainer.className; + + // Verify responsive classes are present + const hasResponsiveClasses = + className.includes("grid-cols-1") && + className.includes("md:grid-cols-") && + className.includes("lg:grid-cols-"); + + if (!hasResponsiveClasses) { + allRendersValid = false; + } + + unmount(); + } + + return allRendersValid; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should have responsive column span for copyright section", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component, the copyright section should include + * responsive column span classes (md:col-span-2, lg:col-span-1) to adapt + * its position in the grid layout across different viewport sizes. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find the copyright section (contains "Informacoes legais" heading) + const copyrightSection = Array.from( + container.querySelectorAll("footer h3"), + ).find((h3) => + h3.textContent?.includes("Informacoes legais"), + )?.parentElement; + + if (!copyrightSection) { + return false; + } + + const className = copyrightSection.className; + + // Check for responsive column span classes + const hasMdColSpan = className.includes("md:col-span-"); + const hasLgColSpan = className.includes("lg:col-span-"); + + return hasMdColSpan && hasLgColSpan; + }), + { numRuns: 100 }, + ); + }); + + it("should use consistent responsive breakpoint prefixes", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component, all responsive utility classes should + * use consistent Tailwind breakpoint prefixes (md:, lg:). This ensures the + * component follows Tailwind's responsive design system correctly. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Get all elements with classes in the footer + const allElements = container.querySelectorAll("footer *"); + + let hasMdPrefix = false; + let hasLgPrefix = false; + let hasInvalidPrefix = false; + + allElements.forEach((element) => { + const className = element.className; + + if (typeof className === "string") { + // Check for valid responsive prefixes + if (className.includes("md:")) hasMdPrefix = true; + if (className.includes("lg:")) hasLgPrefix = true; + + // Check for invalid/inconsistent prefixes (sm: should not be used based on design) + // We're looking for md: and lg: specifically + } + }); + + // Should have md: and lg: prefixes, no invalid ones + return hasMdPrefix && hasLgPrefix && !hasInvalidPrefix; + }), + { numRuns: 100 }, + ); + }); + + it("should have mobile-first responsive design with base classes", () => { + /** + * **Validates: Requirements 2.6** + * + * Property: For any Footer component, it should follow mobile-first responsive + * design by having base classes for mobile and progressive enhancement with + * responsive variants. This ensures the component works on all devices. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find the grid container + const gridContainer = container.querySelector("footer .grid"); + + if (!gridContainer) { + return false; + } + + const className = gridContainer.className; + + // Mobile-first means base class (grid-cols-1) should be present + // Then enhanced with md: and lg: variants + const hasMobileBase = className.includes("grid-cols-1"); + const hasTabletEnhancement = className.includes("md:grid-cols-"); + const hasDesktopEnhancement = className.includes("lg:grid-cols-"); + + // Verify progressive enhancement: mobile -> tablet -> desktop + return hasMobileBase && hasTabletEnhancement && hasDesktopEnhancement; + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Footer.socialLinks.pbt.test.tsx b/app/components/Footer.socialLinks.pbt.test.tsx new file mode 100644 index 0000000..8e0a7a5 --- /dev/null +++ b/app/components/Footer.socialLinks.pbt.test.tsx @@ -0,0 +1,246 @@ +import { render } from "@testing-library/react"; +import Footer from "./Footer"; +import fc from "fast-check"; + +describe("Property 2: Social Links Open in New Tab", () => { + it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => { + /** + * **Validates: Requirements 2.7** + * + * Property: For any social media link in the Footer component, it should have + * target="_blank" and rel="noopener noreferrer" attributes to open in a new tab + * securely. This ensures consistent behavior across all social links and prevents + * security vulnerabilities (tabnabbing). + */ + fc.assert( + fc.property( + fc.constant(null), // No random input needed, testing static component + () => { + const { container } = render(
); + + // Find all social media links (they are within the "Connect" section) + const socialLinks = container.querySelectorAll( + 'a[aria-label*="Visit"]', + ); + + // Verify we found social links + if (socialLinks.length === 0) { + return false; + } + + // Check each social link + let allLinksValid = true; + + socialLinks.forEach((link) => { + const target = link.getAttribute("target"); + const rel = link.getAttribute("rel"); + + // Both attributes must be present and correct + if (target !== "_blank" || rel !== "noopener noreferrer") { + allLinksValid = false; + } + }); + + return allLinksValid; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should consistently apply security attributes across all social link variations", () => { + /** + * **Validates: Requirements 2.7** + * + * Property: For any subset of social links rendered, each link should maintain + * the security attributes (target="_blank" and rel="noopener noreferrer"). + * This property verifies that the security pattern is consistently applied + * regardless of which social platforms are included. + */ + fc.assert( + fc.property( + fc.integer({ min: 0, max: 10 }), // Random seed for variation + () => { + const { container } = render(
); + + // Get all anchor tags in the footer + const allLinks = container.querySelectorAll("footer a"); + + // Filter to only social links (those with SVG icons and aria-labels about visiting profiles) + const socialLinks = Array.from(allLinks).filter((link) => { + const ariaLabel = link.getAttribute("aria-label"); + const hasSvg = link.querySelector("svg") !== null; + return ariaLabel && ariaLabel.includes("Visit") && hasSvg; + }); + + // Verify we found social links + if (socialLinks.length === 0) { + return false; + } + + // Check that ALL social links have the required attributes + return socialLinks.every((link) => { + const target = link.getAttribute("target"); + const rel = link.getAttribute("rel"); + return target === "_blank" && rel === "noopener noreferrer"; + }); + }, + ), + { numRuns: 100 }, + ); + }); + + it("should not apply new tab attributes to non-social links", () => { + /** + * **Validates: Requirements 2.7** + * + * Property: Only social media links should have target="_blank". Other links + * in the footer (like email) should not force new tab behavior. This ensures + * the new tab behavior is intentionally applied only where appropriate. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Get all anchor tags in the footer + const allLinks = container.querySelectorAll("footer a"); + + // Separate social links from other links + const socialLinks: Element[] = []; + const otherLinks: Element[] = []; + + allLinks.forEach((link) => { + const ariaLabel = link.getAttribute("aria-label"); + const hasSvg = link.querySelector("svg") !== null; + + if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) { + socialLinks.push(link); + } else { + otherLinks.push(link); + } + }); + + // All social links should have target="_blank" + const allSocialLinksHaveTarget = socialLinks.every((link) => { + return link.getAttribute("target") === "_blank"; + }); + + // Non-social links (like email) should NOT have target="_blank" + const noOtherLinksHaveTarget = otherLinks.every((link) => { + return link.getAttribute("target") !== "_blank"; + }); + + return allSocialLinksHaveTarget && noOtherLinksHaveTarget; + }), + { numRuns: 100 }, + ); + }); + + it("should maintain security attributes after multiple renders", () => { + /** + * **Validates: Requirements 2.7** + * + * Property: For any number of component renders, social links should consistently + * maintain their security attributes. This verifies that the attributes are not + * accidentally removed or modified during re-renders. + */ + fc.assert( + fc.property( + fc.integer({ min: 1, max: 5 }), // Test 1-5 renders + (numRenders) => { + let allRendersValid = true; + + for (let i = 0; i < numRenders; i++) { + const { container, unmount } = render(
); + + // Find social links + const socialLinks = container.querySelectorAll( + 'a[aria-label*="Visit"]', + ); + + // Check each link + socialLinks.forEach((link) => { + const target = link.getAttribute("target"); + const rel = link.getAttribute("rel"); + + if (target !== "_blank" || rel !== "noopener noreferrer") { + allRendersValid = false; + } + }); + + unmount(); + } + + return allRendersValid; + }, + ), + { numRuns: 100 }, + ); + }); + + it("should have both target and rel attributes present together", () => { + /** + * **Validates: Requirements 2.7** + * + * Property: For any social link, if target="_blank" is present, then + * rel="noopener noreferrer" must also be present. These attributes work + * together to provide secure new tab behavior, and one without the other + * creates a security vulnerability. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all links with target="_blank" + const linksWithTarget = container.querySelectorAll( + 'footer a[target="_blank"]', + ); + + // Verify we found links + if (linksWithTarget.length === 0) { + return false; + } + + // Each link with target="_blank" must have rel="noopener noreferrer" + return Array.from(linksWithTarget).every((link) => { + const rel = link.getAttribute("rel"); + return rel === "noopener noreferrer"; + }); + }), + { numRuns: 100 }, + ); + }); + + it("should have exactly the expected number of social links with security attributes", () => { + /** + * **Validates: Requirements 2.7** + * + * Property: The Footer should render a specific number of social links (based on + * the socialLinks array), and all of them should have the security attributes. + * This ensures no links are accidentally missing the attributes. + */ + fc.assert( + fc.property(fc.constant(null), () => { + const { container } = render(
); + + // Find all social links with security attributes + const secureLinks = container.querySelectorAll( + 'footer a[target="_blank"][rel="noopener noreferrer"]', + ); + + // Find all social links (with aria-label containing "Visit") + const allSocialLinks = container.querySelectorAll( + 'footer a[aria-label*="Visit"]', + ); + + // The number of secure links should equal the number of social links + // This ensures ALL social links have the security attributes + return ( + secureLinks.length === allSocialLinks.length && + allSocialLinks.length > 0 + ); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/app/components/Footer.test.tsx b/app/components/Footer.test.tsx new file mode 100644 index 0000000..a85d1f5 --- /dev/null +++ b/app/components/Footer.test.tsx @@ -0,0 +1,323 @@ +import { render, screen } from "@testing-library/react"; +import Footer from "./Footer"; + +describe("Footer Component - Contact Information", () => { + it("should render contact section heading", () => { + render(
); + + const contactHeading = screen.getByText("Contato direto"); + expect(contactHeading).toBeInTheDocument(); + expect(contactHeading.tagName).toBe("H3"); + }); + + it("should render email address", () => { + render(
); + + const emailLink = screen.getByText("evandro@dev.br"); + expect(emailLink).toBeInTheDocument(); + expect(emailLink.tagName).toBe("A"); + }); + + it("should have mailto link for email", () => { + render(
); + + const emailLink = screen.getByText("evandro@dev.br"); + expect(emailLink).toHaveAttribute("href", "mailto:evandro@dev.br"); + }); + + it("should apply correct styling classes to email link", () => { + render(
); + + const emailLink = screen.getByText("evandro@dev.br"); + const classes = emailLink.className; + + expect(classes).toContain("text-[12px]"); + expect(classes).toContain("hover:text-[var(--slash)]"); + }); +}); + +describe("Footer Component - Social Links", () => { + it("should render social links section heading", () => { + render(
); + + const connectHeading = screen.getByText("Redes profissionais"); + expect(connectHeading).toBeInTheDocument(); + expect(connectHeading.tagName).toBe("H3"); + }); + + it("should render GitHub social link", () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + expect(githubLink).toBeInTheDocument(); + expect(githubLink.tagName).toBe("A"); + }); + + it("should render LinkedIn social link", () => { + render(
); + + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + expect(linkedinLink).toBeInTheDocument(); + expect(linkedinLink.tagName).toBe("A"); + }); + + it("should have correct href for GitHub link", () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + expect(githubLink).toHaveAttribute( + "href", + "https://github.com/evandrodevbr", + ); + }); + + it("should have correct href for LinkedIn link", () => { + render(
); + + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + expect(linkedinLink).toHaveAttribute( + "href", + "https://linkedin.com/in/evandrodevbr", + ); + }); + + it('should open social links in new tab with target="_blank"', () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + + expect(githubLink).toHaveAttribute("target", "_blank"); + expect(linkedinLink).toHaveAttribute("target", "_blank"); + }); + + it('should have rel="noopener noreferrer" for security', () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + + expect(githubLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(linkedinLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should have aria-label for accessibility", () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + + expect(githubLink).toHaveAttribute("aria-label", "Visit GitHub profile"); + expect(linkedinLink).toHaveAttribute( + "aria-label", + "Visit LinkedIn profile", + ); + }); + + it("should render SVG icons for social links", () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + + const githubSvg = githubLink.querySelector("svg"); + const linkedinSvg = linkedinLink.querySelector("svg"); + + expect(githubSvg).toBeInTheDocument(); + expect(linkedinSvg).toBeInTheDocument(); + }); + + it('should have aria-hidden="true" on SVG icons', () => { + render(
); + + const githubLink = screen.getByLabelText("Visit GitHub profile"); + const linkedinLink = screen.getByLabelText("Visit LinkedIn profile"); + + const githubSvg = githubLink.querySelector("svg"); + const linkedinSvg = linkedinLink.querySelector("svg"); + + expect(githubSvg).toHaveAttribute("aria-hidden", "true"); + expect(linkedinSvg).toHaveAttribute("aria-hidden", "true"); + }); +}); + +describe("Footer Component - Copyright", () => { + it("should render copyright section heading", () => { + render(
); + + const copyrightHeading = screen.getByText("Informacoes legais"); + expect(copyrightHeading).toBeInTheDocument(); + expect(copyrightHeading.tagName).toBe("H3"); + }); + + it("should display current year in copyright text", () => { + render(
); + + const currentYear = new Date().getFullYear(); + const copyrightText = screen.getByText( + new RegExp(`© ${currentYear} Evandro Fonseca Junior`), + ); + + expect(copyrightText).toBeInTheDocument(); + }); + + it("should update copyright year dynamically", () => { + // Test that the component uses new Date().getFullYear() dynamically + // by verifying it shows the current year (not a hardcoded value) + const currentYear = new Date().getFullYear(); + + render(
); + + // Verify the copyright includes the current year + const copyrightText = screen.getByText( + new RegExp(`© ${currentYear} Evandro Fonseca Junior`), + ); + expect(copyrightText).toBeInTheDocument(); + + // Verify it's not hardcoded to a specific year like 2023 or 2024 + // by checking that the year matches the actual current year + expect(currentYear).toBeGreaterThanOrEqual(2024); + }); + + it("should apply correct styling to copyright text", () => { + render(
); + + const currentYear = new Date().getFullYear(); + const copyrightText = screen.getByText( + new RegExp(`© ${currentYear} Evandro Fonseca Junior`), + ); + const classes = copyrightText.className; + + expect(classes).toContain("text-[12px]"); + expect(classes).toContain("text-[rgba(255,255,255,0.5)]"); + }); +}); + +describe("Footer Component - Responsive Layout", () => { + beforeEach(() => { + // Mock window.matchMedia for responsive tests + Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + }); + + it("should have responsive grid layout classes", () => { + const { container } = render(
); + + const gridContainer = container.querySelector(".grid"); + expect(gridContainer).toBeInTheDocument(); + + const classes = gridContainer?.className || ""; + expect(classes).toContain("grid-cols-1"); + expect(classes).toContain("md:grid-cols-2"); + expect(classes).toContain("lg:grid-cols-3"); + }); + + it("should have responsive padding classes on container", () => { + const { container } = render(
); + + const innerContainer = container.querySelector(".ds-section-shell"); + expect(innerContainer).toBeInTheDocument(); + + // The ds-section class handles responsive padding via CSS + const footer = container.querySelector("footer"); + const classes = footer?.className || ""; + expect(classes).toContain("ds-section"); + }); + + it("should have responsive gap classes on grid", () => { + const { container } = render(
); + + const gridContainer = container.querySelector(".grid"); + const classes = gridContainer?.className || ""; + + expect(classes).toContain("gap-sp-6"); + }); + + it("should have responsive column span for copyright section", () => { + const { container } = render(
); + + // Find the copyright section by its heading + const copyrightHeading = screen.getByText("Informacoes legais"); + const copyrightSection = copyrightHeading.closest(".ds-card"); + + expect(copyrightSection).toBeInTheDocument(); + const classes = copyrightSection?.className || ""; + + expect(classes).toContain("md:col-span-2"); + expect(classes).toContain("lg:col-span-1"); + }); +}); + +describe("Footer Component - Design System Consistency", () => { + it("should use design system kicker class for section labels", () => { + render(
); + + const kickers = document.querySelectorAll(".ds-kicker"); + expect(kickers.length).toBeGreaterThan(0); + }); + + it("should have ds-section class on footer element", () => { + const { container } = render(
); + + const footer = container.querySelector("footer"); + expect(footer).toBeInTheDocument(); + + const classes = footer?.className || ""; + expect(classes).toContain("ds-section"); + }); + + it("should have ds-section-dark class for dark background", () => { + const { container } = render(
); + + const footer = container.querySelector("footer"); + const classes = footer?.className || ""; + + expect(classes).toContain("ds-section-dark"); + }); + + it("should use design system shell class for container", () => { + const { container } = render(
); + + const shell = container.querySelector(".ds-section-shell"); + expect(shell).toBeInTheDocument(); + }); +}); + +describe("Footer Component - Structure", () => { + it("should render as footer semantic element", () => { + const { container } = render(
); + + const footer = container.querySelector("footer"); + expect(footer).toBeInTheDocument(); + }); + + it("should have three main sections", () => { + render(
); + + const contactHeading = screen.getByText("Contato direto"); + const connectHeading = screen.getByText("Redes profissionais"); + const copyrightHeading = screen.getByText("Informacoes legais"); + + expect(contactHeading).toBeInTheDocument(); + expect(connectHeading).toBeInTheDocument(); + expect(copyrightHeading).toBeInTheDocument(); + }); + + it("should have design system shell container", () => { + const { container } = render(
); + + const innerContainer = container.querySelector(".ds-section-shell"); + expect(innerContainer).toBeInTheDocument(); + }); +}); diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 40d6aaf..26a30a0 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,29 +1,125 @@ "use client"; -import { useEffect, useState } from "react"; +/** + * Footer Component + * + * Displays contact information, social links, and copyright in a consistent footer layout. + * Follows the minimalist design system with Monument Extended typography and #ccff00 accent color. + * + * Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7 + */ + +// TypeScript interface for social media links +export interface SocialLink { + name: string; // Display name (e.g., "GitHub") + href: string; // Full URL + icon: string; // SVG path or icon identifier + ariaLabel: string; // Accessibility label +} + +// Social links data +const socialLinks: SocialLink[] = [ + { + name: "GitHub", + href: "https://github.com/evandrodevbr", + icon: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z", + ariaLabel: "Visit GitHub profile", + }, + { + name: "LinkedIn", + href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true", + icon: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z", + ariaLabel: "Connect on LinkedIn", + }, + { + name: "Instagram", + href: "https://www.instagram.com/evandrodevbr/", + icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.85.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.85-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z", + ariaLabel: "Follow on Instagram", + }, +]; export default function Footer() { - const [blink, setBlink] = useState(true); - - useEffect(() => { - const interval = setInterval(() => { - setBlink((prev) => !prev); - }, 500); - return () => clearInterval(interval); - }, []); + const currentYear = new Date().getFullYear(); return ( -
-
- evandro@dev - : - ~ - $ exit - +
+
+
+ {/* Contact & Legal */} +
+
+
SYSTEM_INFO
+ +

+ Transformando complexidade técnica em sistemas estáveis +

+ +
+

+ Contato +

+ + evandro@dev.br + +
+
+ +
+
+ + Garuva, SC | Remote worldwide +
+

+ © {currentYear} Evandro Fonseca Junior. All rights reserved. +

+
+
+ + {/* Social Links */} +
+
+
SOCIAL_LINKS
+ +

Redes profissionais

+ +
+ {socialLinks.map((link) => ( + +
+ + + {link.name} + +
+ + → + +
+ ))} +
+
+ +
Open to collaboration
+
+
); diff --git a/app/components/Hero.reducedMotion.test.tsx b/app/components/Hero.reducedMotion.test.tsx new file mode 100644 index 0000000..e4eeb2c --- /dev/null +++ b/app/components/Hero.reducedMotion.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import Hero from "./Hero"; + +let mockReducedMotion = false; + +jest.mock("framer-motion", () => { + const React = require("react"); + + const motion = new Proxy( + {}, + { + get: (_, tag: string) => { + const Component = ({ + children, + initial, + animate, + whileHover, + whileTap, + ...props + }: { + children?: ReactNode; + initial?: unknown; + animate?: unknown; + whileHover?: unknown; + whileTap?: unknown; + } & Record) => { + const dataProps = { + ...(initial !== undefined + ? { "data-initial": JSON.stringify(initial) } + : {}), + ...(animate !== undefined + ? { "data-animate": JSON.stringify(animate) } + : {}), + ...(whileHover !== undefined + ? { "data-while-hover": JSON.stringify(whileHover) } + : {}), + ...(whileTap !== undefined + ? { "data-while-tap": JSON.stringify(whileTap) } + : {}), + }; + + return React.createElement(tag, { ...props, ...dataProps }, children); + }; + Component.displayName = `motion.${tag}`; + return Component; + }, + }, + ); + + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + motion, + useInView: () => true, + useReducedMotion: () => mockReducedMotion, + }; +}); + +describe("Hero reduced motion", () => { + afterEach(() => { + mockReducedMotion = false; + }); + + it("removes hover/tap scale animations when reduced motion is enabled", () => { + mockReducedMotion = true; + + const { container } = render(); + + const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a"); + const secondaryAction = screen.getByText(/Ver projetos/i).closest("a"); + + expect(primaryAction).not.toHaveAttribute("data-while-hover"); + expect(primaryAction).not.toHaveAttribute("data-while-tap"); + expect(secondaryAction).not.toHaveAttribute("data-while-hover"); + expect(secondaryAction).not.toHaveAttribute("data-while-tap"); + + const rootMotionBlock = container.querySelector( + "div[data-initial][data-animate]", + ); + expect(rootMotionBlock).toBeInTheDocument(); + expect(rootMotionBlock?.getAttribute("data-initial")).toContain( + '"opacity":0', + ); + expect(rootMotionBlock?.getAttribute("data-animate")).toContain( + '"opacity":1', + ); + }); + + it("keeps essential hero content visible with reduced motion enabled", () => { + mockReducedMotion = true; + + render(); + + expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument(); + expect( + screen.getByText(/Available for selected builds/i), + ).toBeInTheDocument(); + expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument(); + }); +}); diff --git a/app/components/Hero.test.tsx b/app/components/Hero.test.tsx new file mode 100644 index 0000000..7f36b0a --- /dev/null +++ b/app/components/Hero.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import Hero from "./Hero"; + +jest.mock("framer-motion", () => { + const React = require("react"); + + const motion = new Proxy( + {}, + { + get: (_, tag: string) => { + const Component = ({ + children, + ...props + }: { children?: ReactNode } & Record) => + React.createElement(tag, props, children); + Component.displayName = `motion.${tag}`; + return Component; + }, + }, + ); + + return { + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, + motion, + useInView: () => true, + useReducedMotion: () => false, + }; +}); + +describe("Hero", () => { + it("renders the upgraded hero content and primary actions", () => { + render(); + + expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument(); + expect( + screen.getByText( + /Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i, + ), + ).toBeInTheDocument(); + + expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument(); + expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument(); + expect(screen.getByText(/Download CV/i)).toBeInTheDocument(); + expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument(); + }); + + it("renders social links and proof content", () => { + render(); + + expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0); + expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0); + expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0); + + expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument(); + expect(screen.getByText(/Especialidade/i)).toBeInTheDocument(); + expect(screen.getByText(/Escopo/i)).toBeInTheDocument(); + expect(screen.getByText(/Formato/i)).toBeInTheDocument(); + expect(screen.getByText(/Scroll/i)).toBeInTheDocument(); + }); +}); diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index efe3a7d..fe748e7 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,8 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Logo } from "./Logo"; +import { motion, useReducedMotion } from "framer-motion"; const SOCIAL_LINKS = [ { @@ -32,102 +30,26 @@ const SOCIAL_LINKS = [ }, ]; -const textToType = "evandro.dev.br"; +const PROOF_ITEMS = [ + { + label: "Especialidade", + value: + "Produtos conectados, interfaces de alta performance e sistemas criticos.", + }, + { + label: "Escopo", + value: + "Da integracao com hardware e APIs complexas ao frontend e operacao.", + }, + { + label: "Formato", + value: + "Consultoria, execucao ponta a ponta e colaboracao remota para squads.", + }, +]; export default function Hero() { - const [phase, setPhase] = useState<"typing" | "idle" | "glitch" | "logo">( - "typing", - ); - const [typedCount, setTypedCount] = useState(0); - - useEffect(() => { - let timeout: NodeJS.Timeout; - - if (phase === "typing") { - if (typedCount < textToType.length) { - timeout = setTimeout(() => { - setTypedCount((c) => c + 1); - }, 120); // typing speed - } else { - timeout = setTimeout(() => { - setPhase("idle"); - }, 1200); // Wait after typing finishes - } - } else if (phase === "idle") { - timeout = setTimeout(() => { - setPhase("glitch"); - }, 100); // trigger glitch - } else if (phase === "glitch") { - timeout = setTimeout(() => { - setPhase("logo"); - }, 600); // Glitch out duration - } else if (phase === "logo") { - timeout = setTimeout(() => { - setTypedCount(0); - setPhase("typing"); - }, 3500); // Logo display and blink duration - } - - return () => clearTimeout(timeout); - }, [phase, typedCount]); - - const renderTypedText = () => { - const chars = []; - for (let i = 0; i < typedCount; i++) { - const char = textToType[i]; - if (i < 7) { - chars.push( - - {char} - , - ); - } else if (i === 7) { - chars.push(
); - chars.push( - - {char} - , - ); - } else if (i < 11) { - chars.push( - - {char} - , - ); - } else if (i === 11) { - chars.push(
); - chars.push( - - {char} - , - ); - } else { - chars.push( - - {char} - , - ); - } - } - // blinking cursor - if (phase === "typing" || phase === "idle") { - chars.push( - - _ - , - ); - } - return chars; - }; + const shouldReduceMotion = useReducedMotion(); const hour = new Date().getHours(); const greeting = @@ -140,157 +62,287 @@ export default function Hero() { return (
-
-
+
+
+
-
+
-
- - +
+ + + Available for selected builds {"//"} Remote worldwide + +
+ + - Systems engineer available -
-
+ Software engineering for products, operations & critical flows + -
- - {(phase === "typing" || phase === "idle") && ( - - {renderTypedText()} - - )} - - {phase === "logo" && ( - - - - )} - -
- -

- Engenheiro de Software especializado em aplicacoes de alta - performance, interfaces de produto e sistemas criticos com Next.js, - React, Tauri e arquitetura escalavel. -

- -
- - Iniciar projeto - + Engenharia de software para produtos conectados, interfaces de + alta performance e sistemas criticos. + - - Download CV - + Da integracao com hardware e APIs complexas ao frontend moderno, + construo sistemas estaveis, operaveis e prontos para crescer sem + sacrificar clareza tecnica. + - + + +
+
+ +
+
+
+ Execution Brief +
+

+ Clareza de arquitetura antes de complexidade. +

+
+
+ 2026 +
+
+ +
+ {PROOF_ITEMS.map((item, index) => ( + +
+ {item.label} +
+

+ {item.value} +

+
))}
-
- -
- Garuva, SC | Remote worldwide -
+ +
+
+ Focus +
+

+ Sistemas com baixa latencia, alta previsibilidade e manutencao + sustentavel. +

+
+
+
+ Availability +
+

+ Aberto a novos projetos com ownership tecnico, discovery e + entrega iterativa. +

+
+
+
+ + + + Scroll + +
+
); } diff --git a/app/components/Logo.tsx b/app/components/Logo.tsx index 070e05f..c3d760c 100644 --- a/app/components/Logo.tsx +++ b/app/components/Logo.tsx @@ -1,5 +1,7 @@ "use client"; +import { motion, useReducedMotion } from "framer-motion"; + interface LogoProps { variant?: "mark" | "wordmark" | "icon"; size?: "sm" | "md" | "lg" | "xl"; @@ -23,11 +25,12 @@ export function Logo({ showTagline = false, }: Readonly) { const dimensions = sizeMap[size]; + const shouldReduceMotion = useReducedMotion(); const colors = { dark: { primary: "#FFFFFF", - slash: "#E8FF00", + slash: "#CCFF00", }, light: { primary: "#0A0A0A", @@ -41,9 +44,22 @@ export function Logo({ const c = colors[theme]; + const hoverMotion = shouldReduceMotion + ? undefined + : { + scale: 1.03, + rotate: -2, + }; + + const hoverTransition = { + type: "spring" as const, + stiffness: 280, + damping: 18, + }; + if (variant === "icon") { return ( - Evandro icon logo @@ -89,13 +107,17 @@ export function Logo({ r="5" fill={theme === "dark" ? "#0A0A0A" : c.primary} /> - + ); } if (variant === "wordmark") { return ( -
+ )}
-
+ ); } return ( - Evandro mark logo @@ -155,6 +179,6 @@ export function Logo({ - + ); } diff --git a/app/components/Preloader.tsx b/app/components/Preloader.tsx deleted file mode 100644 index cca07ca..0000000 --- a/app/components/Preloader.tsx +++ /dev/null @@ -1,102 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Logo } from "./Logo"; - -export function Preloader() { - const [progress, setProgress] = useState(0); - const [complete, setComplete] = useState(false); - - useEffect(() => { - document.body.style.overflow = "hidden"; - window.scrollTo(0, 0); - - const duration = 2500; - const interval = 25; - const steps = duration / interval; - let currentStep = 0; - - const timer = setInterval(() => { - currentStep++; - const newProgress = Math.min(Math.floor((currentStep / steps) * 100), 100); - setProgress(newProgress); - - if (currentStep >= steps) { - clearInterval(timer); - setTimeout(() => { - setComplete(true); - setTimeout(() => { - document.body.style.overflow = "auto"; - }, 1000); // Wait for exit animation - }, 400); // Pause briefly at exactly 100% - } - }, interval); - - return () => { - clearInterval(timer); - document.body.style.overflow = "auto"; - }; - }, []); - - return ( - - {!complete && ( - -
- - - - - - -

- Evandro.dev.br -

-
- - -
- -
-
- {progress}% -
-
- -
-
- )} -
- ); -} diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index 2ba17b7..886ef78 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -1,90 +1,154 @@ "use client"; import { useMemo } from "react"; +import { motion, type Variants, useReducedMotion } from "framer-motion"; import { usePortfolioData } from "@/lib/usePortfolioData"; import { Section } from "./Section"; -import { Reveal } from "./Reveal"; +import { DrawLine } from "./animations/DrawLine"; +import { CounterValue } from "./animations/CounterValue"; +import { SectionLoading } from "./SectionLoading"; export default function Projects() { const { data, loading, error } = usePortfolioData(); + const shouldReduceMotion = useReducedMotion(); const projects = useMemo( () => data?.projects.filter((p) => p.featured) || [], [data], ); - if (loading) - return
Loading featured projects
; - if (error) + if (loading) { + return ; + } + + if (error) { return
Error loading featured projects
; + } + + const gridVariants: Variants = { + hidden: {}, + visible: { + transition: { + delayChildren: 0.08, + staggerChildren: 0.08, + }, + }, + }; + + const cardVariants: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" }, + visible: { + opacity: 1, + y: 0, + clipPath: "inset(0% 0 0 0)", + transition: shouldReduceMotion + ? { duration: 0.25 } + : { duration: 0.8, ease: [0.16, 1, 0.3, 1] }, + }, + }; return (
-
- {projects.map((project, index) => ( - -
-
-
-
{project.category}
-

{project.title}

-

- {project.subtitle} -

-
- {project.status} + + {projects.map((project) => ( + +
+
+
{project.category}
+ +

{project.title}

+

+ {project.subtitle} +

-

{project.description}

-
- {project.githubUrl && ( - - GitHub - - )} - {project.demoUrl && ( - - Live demo - - )} -
-
    - {project.technologies.slice(0, 6).map((tech: string) => ( -
  • - {tech} -
  • - ))} -
-
+ {project.status} +
+ +

{project.description}

+ +
+ {project.githubUrl && ( + + GitHub + + )} + {project.demoUrl && ( + + Live demo + + )} +
+ +
    + {project.technologies.slice(0, 6).map((tech: string) => ( +
  • + {tech} +
  • + ))} +
+ +
+ + Complexity{" "} + + + {project.metrics?.teamSize && ( - Complexity {project.metrics?.complexity || 3}/5 + Team {project.metrics.teamSize} - {project.metrics?.teamSize && ( - - Team {project.metrics.teamSize} - - )} -
-
-
+ )} +
+ ))} -
+
); } diff --git a/app/components/Reveal.tsx b/app/components/Reveal.tsx index 5aaa721..9bac1cb 100644 --- a/app/components/Reveal.tsx +++ b/app/components/Reveal.tsx @@ -1,7 +1,8 @@ "use client"; -import { ReactNode } from "react"; -import { useScrollReveal } from "../hooks/useScrollReveal"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; interface RevealProps { children: ReactNode; @@ -17,20 +18,51 @@ interface RevealProps { export function Reveal({ children, - y, - duration, - delay, - ease, - start, - end, - scrub, + y = 36, + duration = 0.8, + delay = 0, className = "", }: RevealProps) { - const ref = useScrollReveal({ y, duration, delay, ease, start, end, scrub }); + const shouldReduceMotion = useReducedMotion(); + const [animating, setAnimating] = useState(!shouldReduceMotion); return ( -
} className={className}> + { + if (!shouldReduceMotion) { + setAnimating(true); + } + }} + onAnimationComplete={() => setAnimating(false)} + > {children} -
+ ); } diff --git a/app/components/RouteTransition.tsx b/app/components/RouteTransition.tsx new file mode 100644 index 0000000..c9079aa --- /dev/null +++ b/app/components/RouteTransition.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { ReactNode } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { usePathname } from "next/navigation"; + +interface RouteTransitionProps { + children: ReactNode; +} + +export function RouteTransition({ children }: Readonly) { + const pathname = usePathname(); + const shouldReduceMotion = useReducedMotion(); + + return ( + + + {children} + + + ); +} diff --git a/app/components/Section.tsx b/app/components/Section.tsx index 972eefc..2cd195e 100644 --- a/app/components/Section.tsx +++ b/app/components/Section.tsx @@ -1,7 +1,9 @@ "use client"; import type React from "react"; -import { motion, Variants } from "framer-motion"; +import { motion, type Variants, useReducedMotion } from "framer-motion"; +import { DrawLine } from "./animations/DrawLine"; +import { TextDiffuseReveal } from "./animations/TextDiffuseReveal"; import { Reveal } from "./Reveal"; interface SectionProps { @@ -25,11 +27,33 @@ export const Section = ({ children, disableChildrenAnimation = false, }: SectionProps) => { + const shouldReduceMotion = useReducedMotion(); const baseClass = dark ? "ds-section ds-section-dark" : "ds-section"; + const containerVariants: Variants = { + hidden: {}, + visible: { + transition: { + delayChildren: 0.02, + staggerChildren: 0.08, + }, + }, + }; + const itemVariants: Variants = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } } + hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 18 }, + visible: { + opacity: 1, + y: 0, + transition: shouldReduceMotion + ? { duration: 0.25, ease: [0.16, 1, 0.3, 1] } + : { + type: "spring", + stiffness: 60, + damping: 20, + mass: 0.85, + }, + }, }; return ( @@ -39,13 +63,8 @@ export const Section = ({ className="ds-section-header" initial="hidden" whileInView="visible" - viewport={{ once: true, margin: "-100px" }} - variants={{ - hidden: {}, - visible: { - transition: { staggerChildren: 0.15 } - } - }} + viewport={{ once: true, amount: 0.2 }} + variants={containerVariants} >
{eyebrow && ( @@ -53,20 +72,30 @@ export const Section = ({ {eyebrow} )} - - {title} - + +

+ +

+ {description && ( - + {description} )} + +
- {aside && {aside}} + + {aside && ( + {aside} + )} - + {!disableChildrenAnimation ? ( - +
{children}
) : ( diff --git a/app/components/SectionLoading.tsx b/app/components/SectionLoading.tsx new file mode 100644 index 0000000..ffc0626 --- /dev/null +++ b/app/components/SectionLoading.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { TopProgressBar } from "./animations/TopProgressBar"; + +interface SectionLoadingProps { + message: string; + dark?: boolean; + skeletons?: number; +} + +export function SectionLoading({ + message, + dark = false, + skeletons = 2, +}: Readonly) { + const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section"; + + return ( +
+ +
+
{message}
+
+ ); +} diff --git a/app/components/Skills.tsx b/app/components/Skills.tsx index 4f8ed39..e146f89 100644 --- a/app/components/Skills.tsx +++ b/app/components/Skills.tsx @@ -1,46 +1,98 @@ "use client"; +import { motion, type Variants, useReducedMotion } from "framer-motion"; import { usePortfolioData } from "@/lib/usePortfolioData"; import { Section } from "./Section"; -import { Reveal } from "./Reveal"; +import { DrawLine } from "./animations/DrawLine"; +import { CounterValue } from "./animations/CounterValue"; +import { SectionLoading } from "./SectionLoading"; export default function Skills() { const { data, loading, error } = usePortfolioData(); + const shouldReduceMotion = useReducedMotion(); + + if (loading) { + return ; + } - if (loading) return
Loading skill registry
; if (error || !data) { return
Error loading skill registry
; } + const gridVariants: Variants = { + hidden: {}, + visible: { + transition: { + delayChildren: 0.06, + staggerChildren: 0.08, + }, + }, + }; + + const cardVariants: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" }, + visible: { + opacity: 1, + y: 0, + clipPath: "inset(0% 0 0 0)", + transition: shouldReduceMotion + ? { duration: 0.25 } + : { duration: 0.8, ease: [0.16, 1, 0.3, 1] }, + }, + }; + return (
-
+ {data.skills.technical.map((category, index) => ( - -
-
-
ID_0{index + 1}X
-
{category.skills.length} items
+ +
+
ID_0{index + 1}X
+
+
-

{category.category}

-
    - {category.skills.map((skill) => ( -
  • - {skill} -
  • - ))} -
-
-
+
+ +

{category.category}

+
    + {category.skills.map((skill) => ( +
  • + {skill} +
  • + ))} +
+ ))} -
+ ); } diff --git a/app/components/WorkModalities.tsx b/app/components/WorkModalities.tsx index a30dc9f..187714c 100644 --- a/app/components/WorkModalities.tsx +++ b/app/components/WorkModalities.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { motion, type Variants, useReducedMotion } from "framer-motion"; import { Section } from "./Section"; -import { Reveal } from "./Reveal"; +import { DrawLine } from "./animations/DrawLine"; interface Tier { id: string; @@ -24,20 +24,20 @@ const TIERS: Tier[] = [ id: "discovery", badge: "Entrada / Baixo Risco", level: "01", - title: "Auditoria & Diagnóstico Técnico", + title: "Auditoria & Diagnostico Tecnico", tagline: "Fotografia clara do problema antes de qualquer comprometimento.", description: - "Análise profunda de arquitetura, performance ou segurança com entrega de um plano de ação executável. Ideal para quem precisa de direção antes de contratar execução.", + "Analise profunda de arquitetura, performance ou seguranca com entrega de um plano de acao executavel. Ideal para quem precisa de direcao antes de contratar execucao.", deliverables: [ - "Relatório de gargalos e dívida técnica", - "Mapeamento de riscos e dependências", - "Sugestão de stack e cronograma de mitigação", - "Plano de ação priorizado", + "Relatorio de gargalos e divida tecnica", + "Mapeamento de riscos e dependencias", + "Sugestao de stack e cronograma de mitigacao", + "Plano de acao priorizado", ], pricing: "Valor Fixo", - pricingNote: "Contrato único. Entrega em até 5 dias úteis.", + pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.", featured: false, - ctaLabel: "Solicitar diagnóstico", + ctaLabel: "Solicitar diagnostico", ctaHref: "#contact", }, { @@ -47,12 +47,12 @@ const TIERS: Tier[] = [ title: "Ciclos de Engenharia", tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.", description: - "Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.", + "Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.", deliverables: [ - "Features em produção a cada ciclo", - "Refatoração iterativa com rastreabilidade", - "Documentação técnica atualizada", - "Capacidade de pivotar prioridades no início de cada sprint", + "Features em producao a cada ciclo", + "Refatoracao iterativa com rastreabilidade", + "Documentacao tecnica atualizada", + "Capacidade de pivotar prioridades no inicio de cada sprint", ], pricing: "Fixo por Ciclo", pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.", @@ -65,226 +65,148 @@ const TIERS: Tier[] = [ badge: "Premium / High-Ticket", level: "03", title: "Fractional Tech Lead", - tagline: "Liderança técnica senior. Sem o custo de um CTO full-time.", + tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.", description: - "Direção arquitetural, gestão de risco e sustentação de operações críticas. Para empresas que precisam de decisões de alto impacto com responsabilidade contínua.", + "Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.", deliverables: [ - "SLA prioritário Zero-Risk", - "Code review e liderança do time interno", - "Decisões de infra: Cloud, VPS, CI/CD", - "Consultoria estratégica recorrente", + "SLA prioritario Zero-Risk", + "Code review e lideranca do time interno", + "Decisoes de infra: Cloud, VPS, CI/CD", + "Consultoria estrategica recorrente", ], pricing: "Retainer Mensal", - pricingNote: "Renovação mensal. Vagas limitadas.", + pricingNote: "Renovacao mensal. Vagas limitadas.", featured: false, - ctaLabel: "Agendar conversa estratégica", + ctaLabel: "Agendar conversa estrategica", ctaHref: "#contact", }, ]; -function TierCard({ tier }: { tier: Tier }) { - const [hovered, setHovered] = useState(false); +function TierCard({ tier }: Readonly<{ tier: Tier }>) { + const shouldReduceMotion = useReducedMotion(); return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - onFocus={() => setHovered(true)} - onBlur={() => setHovered(false)} + - {/* Top row */} -
- - {tier.badge} - - +
+ {tier.badge} + {tier.level}
- {/* Title */} -
-

- {tier.title} -

-

+ + +

+

{tier.title}

+

{tier.tagline}

- {/* Description */} -

- {tier.description} -

+

{tier.description}

- {/* Deliverables */} -
    - {tier.deliverables.map((d) => ( -
  • - {d} -
  • +
      + {tier.deliverables.map((deliverable) => ( +
    • {deliverable}
    • ))}
    - {/* Pricing */} -
    -
    +
    +
    {tier.pricing}
    -
    +
    {tier.pricingNote}
    - {/* CTA — reveals on hover/focus */} - - {tier.ctaLabel} → - -
+ {tier.ctaLabel} + {" ->"} + + ); } export function WorkModalities() { + const shouldReduceMotion = useReducedMotion(); + + const containerVariants: Variants = { + hidden: {}, + visible: { + transition: { + delayChildren: 0.08, + staggerChildren: 0.08, + }, + }, + }; + + const itemVariants: Variants = { + hidden: shouldReduceMotion + ? { opacity: 0 } + : { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" }, + visible: { + opacity: 1, + y: 0, + clipPath: "inset(0% 0 0 0)", + transition: shouldReduceMotion + ? { duration: 0.25 } + : { duration: 0.75, ease: [0.16, 1, 0.3, 1] }, + }, + }; + return (
-
- {TIERS.map((tier, index) => ( - + {TIERS.map((tier) => ( + - + ))} -
+
); } diff --git a/app/components/animations/CounterValue.tsx b/app/components/animations/CounterValue.tsx new file mode 100644 index 0000000..983ee5d --- /dev/null +++ b/app/components/animations/CounterValue.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useInView, useReducedMotion } from "framer-motion"; + +interface CounterValueProps { + value: number; + className?: string; + duration?: number; + prefix?: string; + suffix?: string; + once?: boolean; + amount?: number; +} + +export function CounterValue({ + value, + className = "", + duration = 1200, + prefix = "", + suffix = "", + once = true, + amount = 0.45, +}: Readonly) { + const shouldReduceMotion = useReducedMotion(); + const ref = useRef(null); + const isInView = useInView(ref, { once, amount }); + const [displayValue, setDisplayValue] = useState(0); + + useEffect(() => { + if (!isInView) return; + + if (shouldReduceMotion) { + setDisplayValue(value); + return; + } + + const start = performance.now(); + let rafId = 0; + + const frame = (now: number) => { + const elapsed = now - start; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setDisplayValue(Math.round(value * eased)); + + if (progress < 1) { + rafId = requestAnimationFrame(frame); + } + }; + + rafId = requestAnimationFrame(frame); + return () => cancelAnimationFrame(rafId); + }, [isInView, value, duration, shouldReduceMotion]); + + return ( + + {prefix} + {displayValue} + {suffix} + + ); +} diff --git a/app/components/animations/DrawLine.tsx b/app/components/animations/DrawLine.tsx new file mode 100644 index 0000000..877e518 --- /dev/null +++ b/app/components/animations/DrawLine.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; + +interface DrawLineProps { + className?: string; + delay?: number; +} + +export function DrawLine({ + className = "", + delay = 0, +}: Readonly) { + const shouldReduceMotion = useReducedMotion(); + const [animating, setAnimating] = useState(!shouldReduceMotion); + + return ( + setAnimating(false)} + /> + ); +} diff --git a/app/components/animations/LazyRevealImage.tsx b/app/components/animations/LazyRevealImage.tsx new file mode 100644 index 0000000..e4da426 --- /dev/null +++ b/app/components/animations/LazyRevealImage.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import Image, { type StaticImageData } from "next/image"; +import { motion, useReducedMotion } from "framer-motion"; + +interface LazyRevealImageProps { + src: string | StaticImageData; + alt: string; + className?: string; + imageClassName?: string; + sizes?: string; +} + +export function LazyRevealImage({ + src, + alt, + className = "", + imageClassName = "object-cover", + sizes = "(max-width: 1024px) 100vw, 50vw", +}: Readonly) { + const shouldReduceMotion = useReducedMotion(); + const [loaded, setLoaded] = useState(false); + const [animating, setAnimating] = useState(!shouldReduceMotion); + + return ( + setAnimating(false)} + > + {alt} { + setAnimating(true); + setLoaded(true); + }} + className={imageClassName} + /> + + ); +} diff --git a/app/components/animations/TextDiffuseReveal.tsx b/app/components/animations/TextDiffuseReveal.tsx new file mode 100644 index 0000000..59cacc6 --- /dev/null +++ b/app/components/animations/TextDiffuseReveal.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { motion, useInView, useReducedMotion } from "framer-motion"; +import { useEffect, useMemo, useRef, useState } from "react"; + +interface TextDiffuseRevealProps { + text: string; + className?: string; + startDelay?: number; + once?: boolean; + amount?: number; +} + +const EXPO_OUT = [0.16, 1, 0.3, 1] as const; + +export function TextDiffuseReveal({ + text, + className = "", + startDelay = 0, + once = true, + amount = 0.3, +}: Readonly) { + const ref = useRef(null); + const shouldReduceMotion = useReducedMotion(); + const isInView = useInView(ref, { once, amount }); + const [staggerDelay, setStaggerDelay] = useState(0.03); + const [completedChars, setCompletedChars] = useState(0); + + useEffect(() => { + const media = window.matchMedia("(max-width: 768px)"); + const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03); + + updateStagger(); + media.addEventListener("change", updateStagger); + return () => media.removeEventListener("change", updateStagger); + }, []); + + const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]); + const animatedChars = useMemo( + () => + Array.from(text).filter((char) => char !== "\n" && char !== " ").length, + [text], + ); + + useEffect(() => { + if (!isInView) { + setCompletedChars(0); + } + }, [isInView]); + + const hasActiveWillChange = + !shouldReduceMotion && isInView && completedChars < animatedChars; + + let animatedIndex = 0; + + return ( + + {tokens.map((token, tokenIndex) => { + if (!token) { + return null; + } + + if (token === "\n") { + return
; + } + + if (/^\s+$/.test(token)) { + return ( + + ); + } + + return ( + + {Array.from(token).map((char, charIndex) => { + const currentIndex = animatedIndex; + animatedIndex += 1; + + return ( + { + if (!shouldReduceMotion) { + setCompletedChars((count) => + count < animatedChars ? count + 1 : count, + ); + } + }} + > + {char} + + ); + })} + + ); + })} +
+ ); +} diff --git a/app/components/animations/TopProgressBar.tsx b/app/components/animations/TopProgressBar.tsx new file mode 100644 index 0000000..8997f42 --- /dev/null +++ b/app/components/animations/TopProgressBar.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; + +interface TopProgressBarProps { + className?: string; +} + +export function TopProgressBar({ + className = "", +}: Readonly) { + const shouldReduceMotion = useReducedMotion(); + + return ( + + ); +} diff --git a/app/components/animations/__mocks__/PointCloud.tsx b/app/components/animations/__mocks__/PointCloud.tsx new file mode 100644 index 0000000..32e75e1 --- /dev/null +++ b/app/components/animations/__mocks__/PointCloud.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +export default function PointCloudScene() { + return
; +} diff --git a/app/globals.css b/app/globals.css index e934e92..00fcf9c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -44,8 +44,8 @@ --paper: #f4f2ed; --paper-dark: #eceae4; --white: #ffffff; - --slash: #e8ff00; - --slash-dim: rgba(232, 255, 0, 0.15); + --slash: #ccff00; + --slash-dim: rgba(204, 255, 0, 0.16); --meta: #777; --border: rgba(10, 10, 10, 0.1); --border-light: rgba(10, 10, 10, 0.06); @@ -158,7 +158,7 @@ textarea { } .ds-section-meta { - max-width: 400px; + max-width: 700px; } .ds-eyebrow, @@ -205,16 +205,21 @@ textarea { .ds-panel { border: 1px solid var(--border); min-width: 0; + height: 100%; } .ds-card { background: rgba(255, 255, 255, 0.55); padding: var(--sp-6); + display: flex; + flex-direction: column; } .ds-panel { background: rgba(10, 10, 10, 0.02); padding: var(--sp-5); + display: flex; + flex-direction: column; } .ds-card-title { @@ -230,23 +235,26 @@ textarea { color: var(--ink-80); font-size: 12px; line-height: 1.8; + flex-grow: 1; } .ds-tags { display: flex; flex-wrap: wrap; - gap: var(--sp-2); + gap: var(--sp-3); list-style: none; + margin-top: auto; + padding-top: var(--sp-8); } .ds-tag { - padding: 6px 10px; + padding: 8px 12px; border: 1px solid var(--border); - border-radius: var(--r-full); background: rgba(10, 10, 10, 0.03); font-size: 10px; - letter-spacing: 0.1em; + letter-spacing: 0.12em; text-transform: uppercase; + font-weight: 500; } .ds-metric { @@ -293,6 +301,7 @@ textarea { padding-left: var(--sp-5); font-size: 12px; line-height: 1.8; + flex-grow: 1; } .ds-list li::marker { @@ -337,6 +346,113 @@ textarea { border-color: rgba(255, 255, 255, 0.12); } +.ds-draw-line { + display: block; + width: 100%; + height: 1px; + margin-top: var(--sp-5); + background: linear-gradient( + 90deg, + rgba(10, 10, 10, 0.55) 0%, + rgba(10, 10, 10, 0.08) 100% + ); +} + +.ds-section-dark .ds-draw-line { + background: linear-gradient( + 90deg, + rgba(204, 255, 0, 0.75) 0%, + rgba(204, 255, 0, 0.1) 100% + ); +} + +@keyframes ds-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +@keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +@keyframes marquee-reverse { + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } +} + +.animate-marquee { + animation: marquee 40s linear infinite; +} + +.animate-marquee-reverse { + animation: marquee-reverse 45s linear infinite; +} + +.ds-skeleton-card { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.ds-skeleton { + position: relative; + overflow: hidden; + border-radius: 4px; + background: #111; +} + +.ds-skeleton::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, #111 0%, #1f1f1f 50%, #111 100%); + background-size: 220% 100%; + animation: ds-shimmer 1.4s linear infinite; +} + +.ds-skeleton-kicker { + height: 10px; + width: 30%; +} + +.ds-skeleton-title { + height: 26px; + width: 72%; +} + +.ds-skeleton-line { + height: 12px; + width: 100%; +} + +.ds-skeleton-line-short { + width: 62%; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} @media (max-width: 1024px) { .ds-section { padding: var(--sp-16) var(--sp-6); diff --git a/app/hooks/useReducedMotion.test.ts b/app/hooks/useReducedMotion.test.ts new file mode 100644 index 0000000..7dbfe34 --- /dev/null +++ b/app/hooks/useReducedMotion.test.ts @@ -0,0 +1,49 @@ +import { renderHook } from "@testing-library/react"; +import { useReducedMotion } from "./useReducedMotion"; + +// Mock do Framer Motion +jest.mock("framer-motion", () => ({ + useReducedMotion: jest.fn(), +})); + +describe("useReducedMotion", () => { + it("deve retornar false quando prefers-reduced-motion não está ativo", () => { + const { useReducedMotion: mockUseReducedMotion } = + jest.requireMock("framer-motion"); + mockUseReducedMotion.mockReturnValue(false); + + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current).toBe(false); + }); + + it("deve retornar true quando prefers-reduced-motion está ativo", () => { + const { useReducedMotion: mockUseReducedMotion } = + jest.requireMock("framer-motion"); + mockUseReducedMotion.mockReturnValue(true); + + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current).toBe(true); + }); + + it("deve retornar false quando o hook do Framer Motion retorna null", () => { + const { useReducedMotion: mockUseReducedMotion } = + jest.requireMock("framer-motion"); + mockUseReducedMotion.mockReturnValue(null); + + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current).toBe(false); + }); + + it("deve retornar false quando o hook do Framer Motion retorna undefined", () => { + const { useReducedMotion: mockUseReducedMotion } = + jest.requireMock("framer-motion"); + mockUseReducedMotion.mockReturnValue(undefined); + + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current).toBe(false); + }); +}); diff --git a/app/hooks/useReducedMotion.ts b/app/hooks/useReducedMotion.ts new file mode 100644 index 0000000..5587667 --- /dev/null +++ b/app/hooks/useReducedMotion.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useReducedMotion as useFramerReducedMotion } from "framer-motion"; + +/** + * Hook que detecta a preferência prefers-reduced-motion do sistema operacional. + * Wrapper sobre o hook useReducedMotion do Framer Motion. + * + * @returns {boolean} true se prefers-reduced-motion está ativo, false caso contrário + * + * @example + * ```tsx + * const reducedMotion = useReducedMotion(); + * + * return ( + * + * ); + * ``` + */ +export function useReducedMotion(): boolean { + return useFramerReducedMotion() ?? false; +} diff --git a/app/hooks/useScrollReveal.ts b/app/hooks/useScrollReveal.ts index a9af952..962375b 100644 --- a/app/hooks/useScrollReveal.ts +++ b/app/hooks/useScrollReveal.ts @@ -14,10 +14,10 @@ export function useScrollReveal(options?: { scrub?: boolean | number; }) { const ref = useRef(null); - + useEffect(() => { if (!ref.current) return; - + // Lazy registration to avoid Next.js hydration mismatch issues gsap.registerPlugin(ScrollTrigger); @@ -37,12 +37,20 @@ export function useScrollReveal(options?: { end: options?.end ?? "top 50%", scrub: options?.scrub ?? false, }, - } + }, ); }, ref); return () => ctx.revert(); - }, [options?.y, options?.duration, options?.delay, options?.ease, options?.start, options?.end, options?.scrub]); + }, [ + options?.y, + options?.duration, + options?.delay, + options?.ease, + options?.start, + options?.end, + options?.scrub, + ]); return ref; } diff --git a/app/layout.tsx b/app/layout.tsx index 5bff897..158db6f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,15 +1,16 @@ import type { Metadata, Viewport } from "next"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import GoogleAnalytics from "./components/GoogleAnalytics"; +import { RouteTransition } from "./components/RouteTransition"; import { SmoothScrollProvider } from "./providers/SmoothScrollProvider"; +import { AnimationProvider } from "./providers/AnimationProvider"; import "./globals.css"; export const metadata: Metadata = { - // ... existing metadata ... title: "Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)", description: - "Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.", + "Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalavel.", keywords: [ "engenheiro full-stack", "engenheiro de software", @@ -18,19 +19,15 @@ export const metadata: Metadata = { "Tauri", "Rust", "Python", - "Next.js specialist", - "React developer", - "Tauri developer", "TypeScript", "PostgreSQL", "MySQL", - "computação em nuvem", + "cloud computing", "IoT", - "automação", + "automacao", "arquitetura de software", - "sistemas escaláveis", - "High Performance", - "System Architecture", + "sistemas escalaveis", + "high performance", ], authors: [{ name: "Evandro Fonseca Junior" }], creator: "Evandro Fonseca Junior", @@ -48,7 +45,7 @@ export const metadata: Metadata = { title: "Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)", description: - "Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.", + "Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalavel.", url: "https://evandro.dev.br", siteName: "Evandro Fonseca Junior", locale: "pt_BR", @@ -58,7 +55,7 @@ export const metadata: Metadata = { url: "/opengraph-image", width: 1200, height: 630, - alt: "Evandro Fonseca Junior - Engenheiro de Software (Next.js, Tauri & Rust)", + alt: "Evandro Fonseca Junior - Engenheiro de Software", }, ], }, @@ -67,7 +64,7 @@ export const metadata: Metadata = { title: "Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)", description: - "Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.", + "Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos.", creator: "@evandrodevbr", images: ["/opengraph-image"], }, @@ -85,7 +82,7 @@ export const metadata: Metadata = { }; export const viewport: Viewport = { - themeColor: "#ffffff", + themeColor: "#000000", width: "device-width", initialScale: 1, maximumScale: 5, @@ -116,20 +113,16 @@ export default function RootLayout({ children }: Readonly) { "Tauri", "Rust", "TypeScript", - "High Performance Computing", "System Architecture", "Cloud Computing", "IoT", - "Automation", - "PostgreSQL", - "MySQL", ], worksFor: { "@type": "Organization", name: "Freelancer / Consultor Independente", }, description: - "Engenheiro de software especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js 15, React 19, Tauri e arquitetura escalável. Experiência em automação, computação em nuvem e sistemas distribuídos.", + "Engenheiro de software especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js 15, React 19, Tauri e arquitetura escalavel.", }, }; @@ -140,12 +133,14 @@ export default function RootLayout({ children }: Readonly) { -