From b791b7de9e22aeba365deab657a596151fe7c403 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Thu, 27 Aug 2026 10:55:21 -0300 Subject: [PATCH] feat: control room hero, SEO/AEO optimization and design system fixes Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages). --- .gitignore | 3 + app/cases/[slug]/page.tsx | 21 +- app/cases/page.tsx | 30 +- app/components/About.tsx | 34 +- .../CompanyLogos.altText.pbt.test.tsx | 8 +- .../CompanyLogos.reducedMotion.pbt.test.tsx | 8 +- app/components/CompanyLogos.test.tsx | 6 +- app/components/CompanyLogos.tsx | 4 +- app/components/Contact.tsx | 8 +- app/components/ContactForm.tsx | 4 +- app/components/ContactInfo.tsx | 4 +- .../Experience.categorization.pbt.test.tsx | 613 +++------------ ...Experience.chronologicalOrder.pbt.test.tsx | 727 +++--------------- .../Experience.headingHierarchy.pbt.test.tsx | 483 +++--------- app/components/Experience.tsx | 10 +- app/components/Footer.ariaLabels.pbt.test.tsx | 2 +- .../Footer.responsiveLayout.pbt.test.tsx | 272 +------ .../Footer.socialLinks.pbt.test.tsx | 2 +- app/components/Footer.test.tsx | 345 ++------- app/components/Footer.tsx | 19 +- app/components/Header.test.tsx | 107 +++ app/components/Header.tsx | 119 +-- app/components/Hero.reducedMotion.test.tsx | 22 +- app/components/Hero.test.tsx | 29 +- app/components/Hero.tsx | 227 +++--- app/components/LighthouseMetrics.tsx | 6 +- app/components/Logo.tsx | 2 +- app/components/Projects.tsx | 6 +- app/components/Reveal.tsx | 2 +- app/components/RouteTransition.tsx | 2 +- app/components/Section.tsx | 2 +- app/components/Skills.tsx | 6 +- app/components/WorkModalities.tsx | 64 +- app/components/animations/CounterValue.tsx | 4 +- app/components/animations/DrawLine.tsx | 2 +- app/components/animations/LazyRevealImage.tsx | 6 +- .../TextDiffuseReveal.reducedMotion.test.tsx | 2 +- .../animations/TextDiffuseReveal.tsx | 2 +- app/components/animations/TopProgressBar.tsx | 2 +- .../animations/__mocks__/PointCloud.tsx | 2 +- .../cinematic/CinematicOverlays.tsx | 20 +- app/components/cinematic/CinematicSection.tsx | 2 +- app/components/cinematic/CinematicText.tsx | 2 +- app/components/cinematic/ControlRoomGlobe.tsx | 202 +++++ app/components/cinematic/CustomCursor.tsx | 4 +- app/components/cinematic/FilmGrain.tsx | 9 +- app/components/cinematic/FloatingElements.tsx | 34 +- app/components/cinematic/HeroScene.tsx | 250 +----- app/components/cinematic/HomeButton.tsx | 6 +- app/components/cinematic/HorizontalScroll.tsx | 10 +- .../cinematic/MagneticButton.test.tsx | 41 + app/components/cinematic/MagneticButton.tsx | 78 +- app/components/cinematic/PageIntro.test.tsx | 10 +- app/components/cinematic/PageIntro.tsx | 13 +- .../cinematic/ParticleBuildText.tsx | 397 ++++++++++ app/components/cinematic/ScrollProgress.tsx | 8 +- .../cinematic/ScrollVelocityText.tsx | 12 +- app/components/cinematic/SectionDivider.tsx | 6 +- app/components/cinematic/TelemetryStrip.tsx | 193 +++++ app/components/ui/NumberIcons.tsx | 10 +- app/curriculo/page.tsx | 8 +- app/globals.css | 151 +++- app/hooks/useReducedMotion.test.ts | 10 +- app/hooks/useReducedMotion.ts | 2 +- app/hooks/useScrollReveal.ts | 2 +- app/layout.tsx | 53 +- app/manifest.ts | 2 +- app/not-found.tsx | 2 +- app/opengraph-image.tsx | 3 +- app/providers/AnimationProvider.tsx | 2 +- app/providers/SmoothScrollProvider.tsx | 8 +- app/robots.ts | 2 +- app/servicos/[slug]/page.tsx | 137 +++- app/servicos/page.tsx | 51 +- app/sitemap.ts | 2 +- components/DotGrid.tsx | 3 +- components/FaultyTerminal.tsx | 5 +- components/RotatingText.tsx | 4 +- components/Squares.tsx | 3 +- components/TextType.tsx | 10 +- components/magicui/dock.tsx | 6 +- components/magicui/dot-pattern.tsx | 5 +- components/magicui/globe.tsx | 2 +- .../plans/2026-08-17-frontend-foundation.md | 70 ++ .../2026-08-17-frontend-foundation-design.md | 32 + jest.config.ts | 2 + jest.setup.ts | 63 +- lib/jsonld.ts | 5 + lib/site-content.ts | 318 +++++--- lib/site-navigation.ts | 11 + next.config.ts | 6 +- package.json | 1 - pnpm-lock.yaml | 3 - tailwind.config.js | 2 +- 94 files changed, 2621 insertions(+), 2889 deletions(-) create mode 100644 app/components/Header.test.tsx create mode 100644 app/components/cinematic/ControlRoomGlobe.tsx create mode 100644 app/components/cinematic/MagneticButton.test.tsx create mode 100644 app/components/cinematic/ParticleBuildText.tsx create mode 100644 app/components/cinematic/TelemetryStrip.tsx create mode 100644 docs/superpowers/plans/2026-08-17-frontend-foundation.md create mode 100644 docs/superpowers/specs/2026-08-17-frontend-foundation-design.md create mode 100644 lib/jsonld.ts create mode 100644 lib/site-navigation.ts diff --git a/.gitignore b/.gitignore index 0949dc3..7726ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ next-env.d.ts *.analysis.json *.tmp.json *.debug.json + +.omo/ +.playwright-mcp/ diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index e3130ab..6dec415 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { safeJsonLd } from "@/lib/jsonld"; import { caseStudies, siteName, siteUrl } from "@/lib/site-content"; interface CasePageProps { @@ -17,11 +18,11 @@ export async function generateMetadata({ const { slug } = await params; const caseStudy = caseStudies.find((item) => item.id === slug); - if (!caseStudy) return { title: `Case não encontrado | ${siteName}` }; + if (!caseStudy) return { title: "Case não encontrado" }; const url = `${siteUrl}/cases/${caseStudy.id}`; return { - title: `${caseStudy.title}: ${caseStudy.subtitle} | ${siteName}`, + title: `${caseStudy.title} | Case de Engenharia de Software`, description: caseStudy.summary, alternates: { canonical: url }, openGraph: { @@ -31,6 +32,11 @@ export async function generateMetadata({ siteName, type: "article", }, + twitter: { + card: "summary_large_image", + title: caseStudy.title, + description: caseStudy.summary, + }, }; } @@ -54,7 +60,7 @@ export default async function CasePage({ params }: CasePageProps) { { "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl }, + { "@type": "ListItem", position: 1, name: "Início", item: siteUrl }, { "@type": "ListItem", position: 2, @@ -74,10 +80,7 @@ export default async function CasePage({ params }: CasePageProps) { return (
-
@@ -117,7 +120,9 @@ export default async function CasePage({ params }: CasePageProps) {
-

Tecnologias e contexto técnico

+

+ Tecnologias e contexto técnico +

Este case demonstra como aplico engenharia de software a produtos com requisitos de performance, integração e operação, para diff --git a/app/cases/page.tsx b/app/cases/page.tsx index a1aff64..f052a95 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -2,19 +2,20 @@ import type { Metadata } from "next"; import Link from "next/link"; import HeroBackground from "@/app/components/cinematic/HeroBackground"; import MagneticButton from "@/app/components/cinematic/MagneticButton"; +import { safeJsonLd } from "@/lib/jsonld"; import { caseStudies, siteName, siteUrl } from "@/lib/site-content"; export const metadata: Metadata = { - title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`, + title: "Cases de Engenharia de Software, Next.js e IoT", description: - "Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.", + "Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos. Portfolio técnico com foco em clareza, performance e execução.", alternates: { canonical: `${siteUrl}/cases`, }, openGraph: { - title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`, + title: "Cases de Engenharia de Software, Next.js e IoT", description: - "Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.", + "Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos.", url: `${siteUrl}/cases`, siteName, type: "website", @@ -31,7 +32,7 @@ export default function CasesPage() { name: "Cases de engenharia de software", url: `${siteUrl}/cases`, description: - "Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.", + "Coleção de cases em Next.js, IoT, integrações e software para operações críticas.", }, { "@type": "ItemList", @@ -45,7 +46,7 @@ export default function CasesPage() { { "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl }, + { "@type": "ListItem", position: 1, name: "Início", item: siteUrl }, { "@type": "ListItem", position: 2, @@ -59,10 +60,7 @@ export default function CasesPage() { return (

-
@@ -70,11 +68,11 @@ export default function CasesPage() {

Selected work

- Cases em software, Next.js, integracoes e produtos conectados. + Cases em software, Next.js, integrações e produtos conectados.

- Esta pagina organiza projetos com leitura mais indexavel e - comercial, conectando stack, contexto tecnico e tipo de problema + Esta página organiza projetos com leitura mais indexável e + comercial, conectando stack, contexto técnico e tipo de problema resolvido.

@@ -83,9 +81,9 @@ export default function CasesPage() {
-
+
{caseStudies.map((caseStudy) => ( -
+
{caseStudy.title}

{caseStudy.subtitle}

{caseStudy.summary}

@@ -130,7 +128,7 @@ export default function CasesPage() { className="ds-link ds-link-accent" strength={0.25} > - Ver servicos + Ver serviços @@ -140,12 +140,12 @@ export default function About() { />
Technical profile
-

Base tecnica e postura de entrega

+

Base técnica e postura de entrega

Sou engenheiro da computação e desenvolvedor de software full-stack, - atuando ha 5 anos com foco em integrar camadas que normalmente - ficam separadas: hardware, servicos, operacao e interface. Meu - trabalho e dar forma clara ao que costuma nascer fragmentado. + atuando ha 5 anos com foco em integrar camadas que normalmente ficam + separadas: hardware, servicos, operacao e interface. Meu trabalho e + dar forma clara ao que costuma nascer fragmentado.

{PROFILE_POINTS.map((point) => ( diff --git a/app/components/CompanyLogos.altText.pbt.test.tsx b/app/components/CompanyLogos.altText.pbt.test.tsx index 5160fb9..c04cf91 100644 --- a/app/components/CompanyLogos.altText.pbt.test.tsx +++ b/app/components/CompanyLogos.altText.pbt.test.tsx @@ -1,12 +1,12 @@ import { render } from "@testing-library/react"; -import CompanyLogos from "./CompanyLogos"; import fc from "fast-check"; +import CompanyLogos from "./CompanyLogos"; -// Mock framer-motion with configurable useReducedMotion +// Mock motion/react with configurable useReducedMotion let mockReducedMotion = false; -jest.mock("framer-motion", () => { - const actual = jest.requireActual("framer-motion"); +jest.mock("motion/react", () => { + const actual = jest.requireActual("motion/react"); return { ...actual, useReducedMotion: () => mockReducedMotion, diff --git a/app/components/CompanyLogos.reducedMotion.pbt.test.tsx b/app/components/CompanyLogos.reducedMotion.pbt.test.tsx index 3e34fd4..f58a2cf 100644 --- a/app/components/CompanyLogos.reducedMotion.pbt.test.tsx +++ b/app/components/CompanyLogos.reducedMotion.pbt.test.tsx @@ -1,12 +1,12 @@ import { render } from "@testing-library/react"; -import CompanyLogos from "./CompanyLogos"; import fc from "fast-check"; +import CompanyLogos from "./CompanyLogos"; -// Mock framer-motion with configurable useReducedMotion +// Mock motion/react with configurable useReducedMotion let mockReducedMotion = false; -jest.mock("framer-motion", () => { - const actual = jest.requireActual("framer-motion"); +jest.mock("motion/react", () => { + const actual = jest.requireActual("motion/react"); return { ...actual, useReducedMotion: () => mockReducedMotion, diff --git a/app/components/CompanyLogos.test.tsx b/app/components/CompanyLogos.test.tsx index e034861..ab65046 100644 --- a/app/components/CompanyLogos.test.tsx +++ b/app/components/CompanyLogos.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from "@testing-library/react"; import CompanyLogos from "./CompanyLogos"; -// Mock framer-motion with configurable useReducedMotion +// Mock motion/react with configurable useReducedMotion let mockReducedMotion = false; -jest.mock("framer-motion", () => { - const actual = jest.requireActual("framer-motion"); +jest.mock("motion/react", () => { + const actual = jest.requireActual("motion/react"); return { ...actual, useReducedMotion: () => mockReducedMotion, diff --git a/app/components/CompanyLogos.tsx b/app/components/CompanyLogos.tsx index 2fd076a..fc8ba08 100644 --- a/app/components/CompanyLogos.tsx +++ b/app/components/CompanyLogos.tsx @@ -9,7 +9,7 @@ * Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8 */ -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import Image from "next/image"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; @@ -97,6 +97,8 @@ export default function CompanyLogos() {
= companies.length ? true : undefined} > @@ -182,7 +182,7 @@ export default function Contact() { - +
diff --git a/app/components/ContactForm.tsx b/app/components/ContactForm.tsx index df44114..171d5ba 100644 --- a/app/components/ContactForm.tsx +++ b/app/components/ContactForm.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState, ChangeEvent, FormEvent, useEffect } from "react"; +import { motion } from "motion/react"; +import { type ChangeEvent, type FormEvent, useEffect, useState } from "react"; import { usePortfolioData } from "@/lib/usePortfolioData"; import { buildWhatsAppUrl } from "@/lib/whatsapp"; -import { motion } from "motion/react"; interface ContactFormProps { formType: "simple" | "advanced"; diff --git a/app/components/ContactInfo.tsx b/app/components/ContactInfo.tsx index 2f8913c..cd10946 100644 --- a/app/components/ContactInfo.tsx +++ b/app/components/ContactInfo.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { motion } from "motion/react"; import { Mail, MessageCircle } from "lucide-react"; +import { motion } from "motion/react"; +import { useEffect, useState } from "react"; import { usePortfolioData } from "@/lib/usePortfolioData"; import { getWhatsAppDisplayNumber } from "@/lib/whatsapp"; import MagneticButton from "./cinematic/MagneticButton"; diff --git a/app/components/Experience.categorization.pbt.test.tsx b/app/components/Experience.categorization.pbt.test.tsx index 4956ed4..b44c9a0 100644 --- a/app/components/Experience.categorization.pbt.test.tsx +++ b/app/components/Experience.categorization.pbt.test.tsx @@ -1,556 +1,127 @@ import { render } from "@testing-library/react"; -import Experience from "./Experience"; import fc from "fast-check"; -import { usePortfolioData } from "@/lib/usePortfolioData"; +import type { HTMLAttributes, ReactNode } from "react"; +import type { PortfolioExperience } from "@/lib/portfolio-data"; +import Experience from "./Experience"; -// Mock the portfolio data hook -jest.mock("@/lib/usePortfolioData"); -const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< - typeof usePortfolioData ->; +type MotionProps = HTMLAttributes & { + initial?: unknown; + whileInView?: unknown; + viewport?: unknown; + variants?: unknown; + whileHover?: unknown; + whileTap?: unknown; + transition?: unknown; +}; + +type SectionMockProps = { + children?: ReactNode; + title?: string; + eyebrow?: string; + description?: string; + disableChildrenAnimation?: boolean; + id?: string; + className?: string; +}; + +jest.mock("motion/react", () => { + const createMotionElement = + (Tag: "div" | "article") => + ({ + children, + initial: _initial, + whileInView: _whileInView, + viewport: _viewport, + variants: _variants, + whileHover: _whileHover, + whileTap: _whileTap, + transition: _transition, + ...props + }: MotionProps & { children?: ReactNode }) => ( + {children} + ); -// 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}
- ), + div: createMotionElement("div"), + article: createMotionElement("article"), }, + useReducedMotion: () => false, }; }); -// Mock Section component jest.mock("./Section", () => ({ - Section: ({ children, ...props }: any) => ( -
{children}
+ Section: ({ + children, + title, + eyebrow: _eyebrow, + description: _description, + disableChildrenAnimation: _disableChildrenAnimation, + ...props + }: SectionMockProps) => ( +
+

{title}

+ {children} +
), })); -// Mock animation components jest.mock("./animations/DrawLine", () => ({ - DrawLine: () =>
, + DrawLine: () => , })); -jest.mock("./animations/CounterValue", () => ({ - CounterValue: ({ value, suffix }: any) => ( - - {value} - {suffix} - - ), -})); +function makeExperience(index: number, featured: boolean): PortfolioExperience { + return { + id: `experience-${index}`, + title: `Experience ${index}`, + company: "Company", + period: `${2020 + index} - Present`, + location: "Remote", + type: index % 2 === 0 ? "Software" : "Hardware", + description: "Description", + achievements: [], + technologies: [], + featured, + }; +} -// 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. - */ +describe("Experience featured filtering", () => { + it("renders exactly the featured 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"), + fc.array(fc.boolean(), { minLength: 1, maxLength: 8 }), + (featuredFlags) => { + const experiences = featuredFlags.map((featured, index) => + makeExperience(index, featured), ); - const hardwareHeading = headings.find((h3) => - h3.textContent?.includes("Hardware"), + const { container, unmount } = render( + , ); + const articles = Array.from(container.querySelectorAll("article")); + const expectedCount = featuredFlags.filter(Boolean).length; - // 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"), + expect(articles).toHaveLength(expectedCount); + const articleText = articles.map( + (article) => article.textContent ?? "", ); - 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), + featuredFlags.forEach((featured, index) => { + const title = `Experience ${index}`; + expect(articleText.some((text) => text.includes(title))).toBe( + featured, ); }); - // Check that all company names are present - const allCompaniesPresent = allExperiences.every((exp) => { - return container.textContent?.includes(exp.company); - }); - - return allTitlesPresent && allCompaniesPresent; + unmount(); }, ), - { numRuns: 100 }, + { numRuns: 25 }, ); }); - 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, - }); + it("renders an empty experience list safely", () => { + const { container } = render(); - 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 }, - ); + expect(container.querySelector("section")).toBeInTheDocument(); + expect(container.querySelectorAll("article")).toHaveLength(0); }); }); diff --git a/app/components/Experience.chronologicalOrder.pbt.test.tsx b/app/components/Experience.chronologicalOrder.pbt.test.tsx index 92d75f2..33800ed 100644 --- a/app/components/Experience.chronologicalOrder.pbt.test.tsx +++ b/app/components/Experience.chronologicalOrder.pbt.test.tsx @@ -1,641 +1,146 @@ import { render } from "@testing-library/react"; -import Experience from "./Experience"; import fc from "fast-check"; -import { usePortfolioData } from "@/lib/usePortfolioData"; +import type { HTMLAttributes, ReactNode } from "react"; +import type { PortfolioExperience } from "@/lib/portfolio-data"; +import Experience from "./Experience"; -// Mock the portfolio data hook -jest.mock("@/lib/usePortfolioData"); -const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< - typeof usePortfolioData ->; +type MotionProps = HTMLAttributes & { + initial?: unknown; + whileInView?: unknown; + viewport?: unknown; + variants?: unknown; + whileHover?: unknown; + whileTap?: unknown; + transition?: unknown; +}; + +type SectionMockProps = { + children?: ReactNode; + title?: string; + eyebrow?: string; + description?: string; + disableChildrenAnimation?: boolean; + id?: string; + className?: string; +}; + +jest.mock("motion/react", () => { + const createMotionElement = + (Tag: "div" | "article") => + ({ + children, + initial: _initial, + whileInView: _whileInView, + viewport: _viewport, + variants: _variants, + whileHover: _whileHover, + whileTap: _whileTap, + transition: _transition, + ...props + }: MotionProps & { children?: ReactNode }) => ( + {children} + ); -// 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}
- ), + div: createMotionElement("div"), + article: createMotionElement("article"), }, + useReducedMotion: () => false, }; }); -// Mock Section component jest.mock("./Section", () => ({ - Section: ({ children, ...props }: any) => ( -
{children}
+ Section: ({ + children, + title, + eyebrow: _eyebrow, + description: _description, + disableChildrenAnimation: _disableChildrenAnimation, + ...props + }: SectionMockProps) => ( +
+

{title}

+ {children} +
), })); -// Mock animation components jest.mock("./animations/DrawLine", () => ({ - 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)), - ); - }); +function makeExperience( + index: number, + year: number, + period = `${year} - Present`, +): PortfolioExperience { + return { + id: `experience-${index}`, + title: `Experience ${index}`, + company: "Company", + period, + location: "Remote", + type: "Software", + description: "Description", + achievements: [], + technologies: [], + featured: true, }; +} - 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. - */ +describe("Experience chronological ordering", () => { + it("sorts featured experiences by the first year in descending order", () => { 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, + fc.array(fc.integer({ min: 1990, max: 2035 }), { + minLength: 1, + maxLength: 8, }), (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"), + const experiences = years.map((year, index) => + makeExperience(index, year), ); + const { container, unmount } = render( + , + ); + const renderedYears = Array.from( + container.querySelectorAll("article .ds-kicker"), + ).map((element) => Number.parseInt(element.textContent ?? "0", 10)); + const expectedExperiences = [...experiences].sort( + (a, b) => + Number.parseInt(b.period, 10) - Number.parseInt(a.period, 10), + ); + const expectedYears = expectedExperiences.map((experience) => + Number.parseInt(experience.period, 10), + ); + const renderedTitles = Array.from( + container.querySelectorAll("article h3"), + ).map((element) => element.textContent); - // 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; + expect(renderedYears).toEqual(expectedYears); + expect(renderedTitles).toEqual( + expectedExperiences.map((experience) => experience.title), + ); + unmount(); }, ), - { numRuns: 100 }, + { numRuns: 25 }, ); }); + + it("sorts a period range by its first year", () => { + const experiences = [ + makeExperience(1, 2022, "2022 - 2024"), + makeExperience(2, 2024, "2024 - Present"), + makeExperience(3, 2021, "2021 - 2023"), + ]; + const { container } = render(); + + expect( + Array.from(container.querySelectorAll("article .ds-kicker")).map( + (element) => element.textContent, + ), + ).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]); + }); }); diff --git a/app/components/Experience.headingHierarchy.pbt.test.tsx b/app/components/Experience.headingHierarchy.pbt.test.tsx index 1f4a6ee..0eb4062 100644 --- a/app/components/Experience.headingHierarchy.pbt.test.tsx +++ b/app/components/Experience.headingHierarchy.pbt.test.tsx @@ -1,407 +1,132 @@ import { render } from "@testing-library/react"; -import Experience from "./Experience"; import fc from "fast-check"; -import { usePortfolioData } from "@/lib/usePortfolioData"; +import type { HTMLAttributes, ReactNode } from "react"; +import type { PortfolioExperience } from "@/lib/portfolio-data"; +import Experience from "./Experience"; -// Mock the portfolio data hook -jest.mock("@/lib/usePortfolioData"); -const mockUsePortfolioData = usePortfolioData as jest.MockedFunction< - typeof usePortfolioData ->; +type MotionProps = HTMLAttributes & { + initial?: unknown; + whileInView?: unknown; + viewport?: unknown; + variants?: unknown; + whileHover?: unknown; + whileTap?: unknown; + transition?: unknown; +}; + +type SectionMockProps = { + children?: ReactNode; + title?: string; + eyebrow?: string; + description?: string; + disableChildrenAnimation?: boolean; + id?: string; + className?: string; +}; + +jest.mock("motion/react", () => { + const createMotionElement = + (Tag: "div" | "article") => + ({ + children, + initial: _initial, + whileInView: _whileInView, + viewport: _viewport, + variants: _variants, + whileHover: _whileHover, + whileTap: _whileTap, + transition: _transition, + ...props + }: MotionProps & { children?: ReactNode }) => ( + {children} + ); -// 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}
- ), + div: createMotionElement("div"), + article: createMotionElement("article"), }, + useReducedMotion: () => false, }; }); -// Mock Section component jest.mock("./Section", () => ({ - Section: ({ children, ...props }: any) => ( -
{children}
+ Section: ({ + children, + title, + eyebrow: _eyebrow, + description: _description, + disableChildrenAnimation: _disableChildrenAnimation, + ...props + }: SectionMockProps) => ( +
+

{title}

+ {children} +
), })); -// Mock animation components jest.mock("./animations/DrawLine", () => ({ - DrawLine: () =>
, + DrawLine: () => , })); -jest.mock("./animations/CounterValue", () => ({ - CounterValue: ({ value, suffix }: any) => ( - - {value} - {suffix} - - ), -})); +function makeExperience(index: number): PortfolioExperience { + return { + id: `experience-${index}`, + title: `Experience ${index}`, + company: "Company", + period: `${2020 + index} - Present`, + location: "Remote", + type: index % 2 === 0 ? "Software" : "Hardware", + description: "Description", + achievements: [], + technologies: [], + featured: true, + }; +} -// 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. - */ +describe("Experience heading hierarchy", () => { + it("renders one h2 section heading and h3 headings for featured cards", () => { 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, - }); + fc.property(fc.integer({ min: 1, max: 8 }), (count) => { + const experiences = Array.from({ length: count }, (_, index) => + makeExperience(index), + ); + const { container, unmount } = render( + , + ); - const { container } = render(); + const headings = Array.from( + container.querySelectorAll("h1, h2, h3, h4, h5, h6"), + ); - // Get all heading elements in order - const headings = Array.from( - container.querySelectorAll("h1, h2, h3, h4, h5, h6"), - ); + expect(headings[0]?.tagName).toBe("H2"); + expect(container.querySelector("section > h2")).toHaveTextContent( + "Experiência em engenharia de software e sistemas críticos", + ); + expect(container.querySelectorAll("section > h2")).toHaveLength(1); + expect(container.querySelectorAll("article h3")).toHaveLength(count); + expect( + new Set( + Array.from(container.querySelectorAll("article h3")).map( + (heading) => heading.textContent, + ), + ), + ).toEqual(new Set(experiences.map((experience) => experience.title))); + expect(headings.some((heading) => heading.tagName === "H4")).toBe( + false, + ); - 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 }, + unmount(); + }), + { numRuns: 25 }, ); }); - 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, - }); + it("renders no card headings when no experiences are supplied", () => { + const { container } = render(); - 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 }, - ); + expect(container.querySelector("section > h2")).toBeInTheDocument(); + expect(container.querySelectorAll("article h3")).toHaveLength(0); }); }); diff --git a/app/components/Experience.tsx b/app/components/Experience.tsx index 950bcfc..0d354ad 100644 --- a/app/components/Experience.tsx +++ b/app/components/Experience.tsx @@ -1,15 +1,15 @@ "use client"; -import { motion, type Variants } from "framer-motion"; +import { motion, type Variants } from "motion/react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import type { PortfolioExperience } from "@/lib/portfolio-data"; import { DrawLine } from "./animations/DrawLine"; import { Section } from "./Section"; export default function Experience({ - experiences, + experiences = [], }: { - experiences: PortfolioExperience[]; + experiences?: PortfolioExperience[]; }) { const shouldReduceMotion = useReducedMotion(); const sortedExperiences = [...experiences] @@ -48,8 +48,8 @@ export default function Experience({
{ it("should have descriptive aria-label attributes for all social media links", () => { diff --git a/app/components/Footer.responsiveLayout.pbt.test.tsx b/app/components/Footer.responsiveLayout.pbt.test.tsx index a916d67..8fd6938 100644 --- a/app/components/Footer.responsiveLayout.pbt.test.tsx +++ b/app/components/Footer.responsiveLayout.pbt.test.tsx @@ -1,264 +1,36 @@ import { render } from "@testing-library/react"; -import Footer from "./Footer"; import fc from "fast-check"; +import Footer from "./Footer"; -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. - */ +describe("Footer responsive structure", () => { + it("keeps the responsive design-system structure across renders", () => { fc.assert( - fc.property( - fc.constant(null), // No random input needed, testing static component - () => { - const { container } = render(
); + fc.property(fc.integer({ min: 1, max: 5 }), (renderCount) => { + for (let index = 0; index < renderCount; index += 1) { + const { container, unmount } = render(
); + const footer = container.querySelector("footer"); + const grid = footer?.querySelector(".ds-grid-2"); - // Find the main grid container - const gridContainer = container.querySelector("footer .grid"); + expect(footer).toHaveClass("ds-section"); + expect( + footer?.querySelector(".ds-section-shell"), + ).toBeInTheDocument(); + expect(grid).toBeInTheDocument(); + expect(grid?.querySelectorAll(":scope > .ds-card")).toHaveLength(2); - // 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; + unmount(); } - - 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 }, + { numRuns: 25 }, ); }); - 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(
); + it("uses the mobile-first grid primitive instead of the legacy grid contract", () => { + const { container } = render(
); + const grid = container.querySelector(".ds-grid-2"); - // 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 }, - ); + expect(grid).toBeInTheDocument(); + expect(grid).not.toHaveClass("grid-cols-1"); + expect(grid).not.toHaveClass("lg:grid-cols-3"); }); }); diff --git a/app/components/Footer.socialLinks.pbt.test.tsx b/app/components/Footer.socialLinks.pbt.test.tsx index 8e0a7a5..270e055 100644 --- a/app/components/Footer.socialLinks.pbt.test.tsx +++ b/app/components/Footer.socialLinks.pbt.test.tsx @@ -1,6 +1,6 @@ import { render } from "@testing-library/react"; -import Footer from "./Footer"; import fc from "fast-check"; +import Footer from "./Footer"; describe("Property 2: Social Links Open in New Tab", () => { it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => { diff --git a/app/components/Footer.test.tsx b/app/components/Footer.test.tsx index 392c10d..a9a751f 100644 --- a/app/components/Footer.test.tsx +++ b/app/components/Footer.test.tsx @@ -1,326 +1,97 @@ import { render, screen } from "@testing-library/react"; import Footer from "./Footer"; -describe("Footer Component - Contact Information", () => { - it("should render contact section heading", () => { +describe("Footer contact and navigation", () => { + it("renders the contact card and a mailto link", () => { render(
); - const contactHeading = screen.getByText("Contato direto"); - expect(contactHeading).toBeInTheDocument(); - expect(contactHeading.tagName).toBe("H3"); - }); + expect(screen.getByText("Contato")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { + name: "Engenharia com menos ruído e mais clareza de execução", + }), + ).toBeInTheDocument(); - it("should render email address", () => { - render(
); - - const emailLink = screen.getByText("evandrofonsecajunior@gmail.com"); - expect(emailLink).toBeInTheDocument(); - expect(emailLink.tagName).toBe("A"); - }); - - it("should have mailto link for email", () => { - render(
); - - const emailLink = screen.getByText("evandrofonsecajunior@gmail.com"); + const emailLink = screen.getByRole("link", { + name: "evandrofonsecajunior@gmail.com", + }); expect(emailLink).toHaveAttribute( "href", "mailto:evandrofonsecajunior@gmail.com", ); + expect(emailLink).not.toHaveAttribute("target", "_blank"); }); - it("should apply correct styling classes to email link", () => { + it("renders quick links as same-tab navigation", () => { render(
); - const emailLink = screen.getByText("evandrofonsecajunior@gmail.com"); - const classes = emailLink.className; - - expect(classes).toContain("text-[12px]"); - expect(classes).toContain("hover:text-[var(--slash)]"); + const servicesLink = screen.getByRole("link", { name: "Hub de serviços" }); + expect(servicesLink).toHaveAttribute("href", "/servicos"); + expect(servicesLink).not.toHaveAttribute("target", "_blank"); }); }); -describe("Footer Component - Social Links", () => { - it("should render social links section heading", () => { +describe("Footer social links", () => { + it("renders each social link with an accessible name", () => { render(
); - const connectHeading = screen.getByText("Redes profissionais"); - expect(connectHeading).toBeInTheDocument(); - expect(connectHeading.tagName).toBe("H3"); + const socialLinks = Array.from( + document.querySelectorAll("footer a[aria-label]"), + ); + + expect(socialLinks.map((link) => link.getAttribute("aria-label"))).toEqual([ + "Visit GitHub profile", + "Visit LinkedIn profile", + "Visit Instagram profile", + ]); + expect( + new Set(socialLinks.map((link) => link.getAttribute("aria-label"))).size, + ).toBe(socialLinks.length); }); - it("should render GitHub social link", () => { + it("opens social links securely in a new tab", () => { render(
); - const githubLink = screen.getByLabelText("Visit GitHub profile"); - expect(githubLink).toBeInTheDocument(); - expect(githubLink.tagName).toBe("A"); - }); + const socialLinks = Array.from( + document.querySelectorAll("footer a[aria-label]"), + ); - it("should render LinkedIn social link", () => { - render(
); + for (const link of socialLinks) { + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(link.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); + } - 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( + expect(screen.getByLabelText("Visit GitHub profile")).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"); + expect( + screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"), + ).toContain("https://www.linkedin.com/in/evandrodevbr/"); }); }); -describe("Footer Component - Copyright", () => { - it("should render copyright section heading", () => { - render(
); +describe("Footer structure", () => { + it("uses the current design-system shell and two-card grid", () => { + const { container } = render(
); + const footer = container.querySelector("footer"); - const copyrightHeading = screen.getByText("Informacoes legais"); - expect(copyrightHeading).toBeInTheDocument(); - expect(copyrightHeading.tagName).toBe("H3"); + expect(footer).toHaveClass("ds-section"); + expect(footer?.querySelector(".ds-section-shell")).toBeInTheDocument(); + expect(footer?.querySelector(".ds-grid-2")).toBeInTheDocument(); + expect(footer?.querySelectorAll(":scope .ds-card")).toHaveLength(2); + expect(footer?.querySelectorAll("h3")).toHaveLength(2); }); - it("should display current year in copyright text", () => { + it("renders the current year without hardcoded 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(); + expect( + screen.getByText( + new RegExp(`Copyright ${currentYear} Evandro Fonseca Junior`), + ), + ).toBeInTheDocument(); }); }); diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 8b6f331..55dd4a6 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -16,22 +16,22 @@ const socialLinks: SocialLink[] = [ 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", + ariaLabel: "Visit LinkedIn profile", }, { 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", + ariaLabel: "Visit Instagram profile", }, ]; const quickLinks = [ - { label: "Hub de servicos", href: "/servicos" }, + { label: "Hub de serviços", href: "/servicos" }, { label: "Cases", href: "/cases" }, { label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" }, { - label: "Integracao hardware + software", + label: "Integração hardware + software", href: "/servicos/integracao-hardware-software-iot", }, ]; @@ -50,11 +50,11 @@ export default function Footer() {
Contato

- Engenharia com menos ruido e mais clareza de execucao + Engenharia com menos ruído e mais clareza de execução

- Se voce precisa de alguem para conectar produto, integracao e - entrega com mais criterio tecnico, este portfolio e o ponto de + Se você precisa de alguém para conectar produto, integração e + entrega com mais critério técnico, este portfólio é o ponto de partida.

@@ -79,14 +79,15 @@ export default function Footer() { Garuva, SC | Remote worldwide

- Copyright {currentYear} Evandro Fonseca Junior. All rights reserved. + Copyright {currentYear} Evandro Fonseca Junior. All rights + reserved.

-
Presenca digital
+
Presença digital

Canais profissionais

diff --git a/app/components/Header.test.tsx b/app/components/Header.test.tsx new file mode 100644 index 0000000..65b5f7a --- /dev/null +++ b/app/components/Header.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { Header } from "./Header"; + +jest.mock("motion/react", () => { + const React = require("react"); + + const motion = new Proxy( + {}, + { + get: (_, tag: string) => { + const Component = ({ + children, + animate: _animate, + transition: _transition, + whileHover: _whileHover, + ...props + }: { children?: ReactNode } & Record) => + React.createElement(tag, props, children); + Component.displayName = `motion.${tag}`; + return Component; + }, + }, + ); + + return { motion, useReducedMotion: () => false }; +}); + +describe("Header", () => { + it("exposes the primary navigation and the existing CV asset", () => { + render(
); + + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/#home", + ); + expect(screen.getByRole("link", { name: "Casos" })).toHaveAttribute( + "href", + "/cases", + ); + expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute( + "href", + "/servicos", + ); + expect(screen.getByRole("link", { name: "Currículo" })).toHaveAttribute( + "href", + "/curriculo", + ); + + const cvLink = screen.getByRole("link", { name: "Download CV" }); + expect(cvLink).toHaveAttribute("href", "/Profile.pdf"); + expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf"); + expect( + screen.getByRole("navigation", { name: "Navegação principal" }), + ).toHaveClass("site-header-nav"); + }); + + it("announces and controls the mobile menu", () => { + render(
); + + const menuButton = screen.getByRole("button", { name: "Abrir menu" }); + expect(menuButton).not.toHaveAttribute("aria-controls"); + expect(menuButton).toHaveAttribute("aria-expanded", "false"); + expect(menuButton).toHaveClass("site-header-menu-toggle"); + expect( + screen.queryByRole("navigation", { name: "Navegação mobile" }), + ).not.toBeInTheDocument(); + + fireEvent.click(menuButton); + + expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute( + "aria-controls", + "site-navigation-menu", + ); + expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute( + "aria-expanded", + "true", + ); + const mobileNavigation = screen.getByRole("navigation", { + name: "Navegação mobile", + }); + expect(mobileNavigation).toHaveAttribute("id", "site-navigation-menu"); + expect(mobileNavigation).toHaveAttribute("aria-hidden", "false"); + + const closeButton = screen.getByRole("button", { name: "Fechar menu" }); + fireEvent.keyDown(closeButton, { key: "Escape" }); + const openButton = screen.getByRole("button", { name: "Abrir menu" }); + expect(openButton).toHaveAttribute("aria-expanded", "false"); + expect(document.activeElement).toBe(openButton); + }); + + it("returns focus after closing the menu from a mobile link", () => { + render(
); + + fireEvent.click(screen.getByRole("button", { name: "Abrir menu" })); + const mobileNavigation = screen.getByRole("navigation", { + name: "Navegação mobile", + }); + fireEvent.click( + within(mobileNavigation).getByRole("link", { name: "Home" }), + ); + + const openButton = screen.getByRole("button", { name: "Abrir menu" }); + expect(openButton).toHaveAttribute("aria-expanded", "false"); + expect(document.activeElement).toBe(openButton); + }); +}); diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 75a281f..f4b3fae 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,35 +1,48 @@ "use client"; -import { useState } from "react"; -import { Logo } from "./Logo"; +import { type KeyboardEvent, useRef, useState } from "react"; +import { + CV_DOWNLOAD_NAME, + CV_URL, + NAVIGATION_ITEMS, +} from "@/lib/site-navigation"; import MagneticButton from "./cinematic/MagneticButton"; +import { Logo } from "./Logo"; -const navItems = [ - { href: "#home", label: "Home" }, - { href: "#about", label: "Sobre" }, - { href: "#skills", label: "Habilidades" }, - { href: "#experience", label: "Experiencia" }, - { href: "#projects", label: "Projetos" }, - { href: "#modalities", label: "Contratacao" }, - { href: "#contact", label: "Contato" }, -]; +const MENU_ID = "site-navigation-menu"; export function Header() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + + const closeMobileMenu = () => { + setMobileMenuOpen(false); + menuButtonRef.current?.focus(); + }; + + const handleMenuKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + + event.preventDefault(); + closeMobileMenu(); + }; return ( -
+
- + evandro.dev.br -
+ ) : null}
); } diff --git a/app/components/Hero.reducedMotion.test.tsx b/app/components/Hero.reducedMotion.test.tsx index 8fa06f3..ab8fc7e 100644 --- a/app/components/Hero.reducedMotion.test.tsx +++ b/app/components/Hero.reducedMotion.test.tsx @@ -11,7 +11,7 @@ jest.mock("next/dynamic", () => () => { return DynamicComponent; }); -jest.mock("framer-motion", () => { +jest.mock("motion/react", () => { const React = require("react"); const motion = new Proxy( @@ -54,9 +54,29 @@ jest.mock("framer-motion", () => { motion, useInView: () => true, useReducedMotion: () => true, + useMotionValue: (initial: number) => ({ + get: () => initial, + set: jest.fn(), + }), + useSpring: ( + value: { get: () => number; set: (v: number) => void }, + _config: unknown, + ) => value, + useTransform: ( + value: { get: () => number; set: (v: number) => void }, + _input: number[], + _output: number[], + ) => value, }; }); +jest.mock("cobe", () => ({ + __esModule: true, + default: jest.fn(() => ({ + destroy: jest.fn(), + })), +})); + function mockMatchMedia(matches: boolean) { Object.defineProperty(window, "matchMedia", { writable: true, diff --git a/app/components/Hero.test.tsx b/app/components/Hero.test.tsx index fa73b4f..15911ee 100644 --- a/app/components/Hero.test.tsx +++ b/app/components/Hero.test.tsx @@ -11,7 +11,7 @@ jest.mock("next/dynamic", () => () => { return DynamicComponent; }); -jest.mock("framer-motion", () => { +jest.mock("motion/react", () => { const React = require("react"); const motion = new Proxy( @@ -40,9 +40,29 @@ jest.mock("framer-motion", () => { motion, useInView: () => true, useReducedMotion: () => false, + useMotionValue: (initial: number) => ({ + get: () => initial, + set: jest.fn(), + }), + useSpring: ( + value: { get: () => number; set: (v: number) => void }, + _config: unknown, + ) => value, + useTransform: ( + value: { get: () => number; set: (v: number) => void }, + _input: number[], + _output: number[], + ) => value, }; }); +jest.mock("cobe", () => ({ + __esModule: true, + default: jest.fn(() => ({ + destroy: jest.fn(), + })), +})); + function mockMatchMedia(matches = false) { Object.defineProperty(window, "matchMedia", { writable: true, @@ -92,7 +112,10 @@ describe("Hero", () => { expect( screen.getByRole("link", { name: /ver casos selecionados/i }), ).toHaveAttribute("href", "/cases"); - expect(screen.getByText(/Download CV/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /download cv/i })).toHaveAttribute( + "href", + "/Profile.pdf", + ); expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(); }); @@ -109,7 +132,7 @@ describe("Hero", () => { expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument(); - expect(screen.getByText(/Operacao/i)).toBeInTheDocument(); + expect(screen.getByText(/Operação/i)).toBeInTheDocument(); expect(screen.getByText(/Clareza/i)).toBeInTheDocument(); expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0); diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index b643ed4..552ecea 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,11 +1,15 @@ -"use client"; +"use client"; -import { motion, type Variants } from "framer-motion"; +import { motion, type Variants } from "motion/react"; import dynamic from "next/dynamic"; import { useEffect, useState } from "react"; import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene"; -import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp"; +import { CV_URL } from "@/lib/site-navigation"; +import { buildWhatsAppUrl } from "@/lib/whatsapp"; +import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe"; import MagneticButton from "./cinematic/MagneticButton"; +import { ParticleBuildText } from "./cinematic/ParticleBuildText"; +import { TelemetryStrip } from "./cinematic/TelemetryStrip"; const HeroScene = dynamic(() => import("./cinematic/HeroScene"), { ssr: false, @@ -94,16 +98,16 @@ const HERO_VARIANTS: HeroVariant[] = [ ], proof: [ { - label: "Operacao", - value: "Fluxo pensado para discovery, build, deploy e sustentacao.", + label: "Operação", + value: "Fluxo pensado para discovery, build, deploy e sustentação.", }, { label: "Clareza", - value: "Menos ruído entre produto, engenharia e decisao tecnica.", + value: "Menos ruído entre produto, engenharia e decisão técnica.", }, { label: "Resultado", - value: "Base pronta para operar, evoluir e receber proximo ciclo.", + value: "Base pronta para operar, evoluir e receber próximo ciclo.", }, ], }, @@ -114,27 +118,27 @@ const HERO_VARIANTS: HeroVariant[] = [ layout: "visual-first", status: "Launch sequence ready", chip: "Discovery → build → deploy", - eyebrow: "MVP, produto e entrega em producao", + eyebrow: "MVP, produto e entrega em produção", headline: - "Tire seu MVP do plano e coloque em producao com base tecnica real.", + "Tire seu MVP do plano e coloque em produção com base técnica real.", subtitle: - "Transformo escopo solto em sequencia de lancamento: o que validar, o que construir primeiro e como publicar sem criar uma divida tecnica logo no inicio.", - cta: "Lancar meu MVP", + "Transformo escopo solto em sequência de lançamento: o que validar, o que construir primeiro e como publicar sem criar uma dívida técnica logo no início.", + cta: "Lançar meu MVP", whatsappMessage: - "Ola! Quero lancar um MVP com uma base tecnica segura. Podemos conversar?", - sideLabel: "Sequencia de lancamento", + "Olá! Quero lançar um MVP com uma base técnica segura. Podemos conversar?", + sideLabel: "Sequência de lançamento", sideTitle: "Do primeiro corte ao deploy.", sideText: - "A conversa define prioridade, risco e o menor caminho confiavel para colocar a primeira versao no ar.", + "A conversa define prioridade, risco e o menor caminho confiável para colocar a primeira versão no ar.", points: [ "Recorte de MVP sem inflar escopo.", - "Entrega incremental com criterio de producao.", - "Deploy e continuidade pensados desde o inicio.", + "Entrega incremental com critério de produção.", + "Deploy e continuidade pensados desde o início.", ], proof: [ - { label: "01", value: "Diagnostico do objetivo e gargalo principal." }, - { label: "02", value: "Plano de construcao com risco visivel." }, - { label: "03", value: "Publicacao e proximo ciclo sem perder contexto." }, + { label: "01", value: "Diagnóstico do objetivo e gargalo principal." }, + { label: "02", value: "Plano de construção com risco visível." }, + { label: "03", value: "Publicação e próximo ciclo sem perder contexto." }, ], }, { @@ -144,22 +148,22 @@ const HERO_VARIANTS: HeroVariant[] = [ layout: "split", status: "Rescue mode available", chip: "Legado, instabilidade e projeto travado", - eyebrow: "Rescue tecnico para sistemas que precisam voltar a andar", + eyebrow: "Rescue técnico para sistemas que precisam voltar a andar", headline: - "Codigo confuso, entrega travada ou sistema instavel? Vamos organizar.", + "Código confuso, entrega travada ou sistema instável? Vamos organizar.", subtitle: - "Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos tecnico em plano executavel.", + "Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos técnico em plano executável.", cta: "Destravar meu projeto", whatsappMessage: - "Ola! Tenho um projeto travado ou instavel e quero destravar. Podemos conversar?", - sideLabel: "Antes / depois tecnico", + "Olá! Tenho um projeto travado ou instável e quero destravar. Podemos conversar?", + sideLabel: "Antes / depois técnico", sideTitle: "Do caos operacional para uma rota clara.", sideText: - "O primeiro objetivo nao e reescrever tudo. E encontrar o menor corte seguro que devolve controle e previsibilidade.", + "O primeiro objetivo não é reescrever tudo. É encontrar o menor corte seguro que devolve controle e previsibilidade.", points: [ - "Leitura rapida de fluxo, risco e acoplamento.", - "Priorizacao do que destrava sem reescrever tudo.", - "Correcoes com criterio para nao trocar um bug por outro.", + "Leitura rápida de fluxo, risco e acoplamento.", + "Priorização do que destrava sem reescrever tudo.", + "Correções com critério para não trocar um bug por outro.", ], proof: [ { @@ -167,8 +171,8 @@ const HERO_VARIANTS: HeroVariant[] = [ value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.", }, { - label: "Intervencao", - value: "Mapa de risco, cortes pequenos e decisao tecnica clara.", + label: "Intervenção", + value: "Mapa de risco, cortes pequenos e decisão técnica clara.", }, { label: "Depois", value: "Entrega volta a andar com menos improviso." }, ], @@ -179,23 +183,23 @@ const HERO_VARIANTS: HeroVariant[] = [ scene: "signal", layout: "default", status: "Signal map active", - chip: "Hardware + software + operacao", - eyebrow: "Produtos conectados, telemetria e integracoes criticas", + chip: "Hardware + software + operação", + eyebrow: "Produtos conectados, telemetria e integrações críticas", headline: - "Conecte dispositivo, backend e interface em uma operacao confiavel.", + "Conecte dispositivo, backend e interface em uma operação confiável.", subtitle: - "Atuo na fronteira que costuma quebrar primeiro: sinal fisico, API, painel, dados e rotina operacional funcionando como um unico produto.", + "Atuo na fronteira que costuma quebrar primeiro: sinal físico, API, painel, dados e rotina operacional funcionando como um único produto.", cta: "Conectar meu produto", whatsappMessage: - "Ola! Quero conectar hardware, software e operacao em um produto confiavel. Podemos conversar?", + "Olá! Quero conectar hardware, software e operação em um produto confiável. Podemos conversar?", sideLabel: "Mapa de sinais", sideTitle: "Dados, comandos e painel no mesmo fluxo.", sideText: - "A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integracao em produto.", + "A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integração em produto.", points: [ "Contratos claros entre dispositivo, API e interface.", "Telemetria e dashboards com contexto operacional.", - "Base pronta para automacao, IA ou analytics.", + "Base pronta para automação, IA ou analytics.", ], proof: [ { @@ -207,8 +211,8 @@ const HERO_VARIANTS: HeroVariant[] = [ value: "APIs, WebSockets, filas, banco e observabilidade.", }, { - label: "Saida", - value: "Painel, operacao, alerta e decisao de produto.", + label: "Saída", + value: "Painel, operação, alerta e decisão de produto.", }, ], }, @@ -218,22 +222,22 @@ const HERO_VARIANTS: HeroVariant[] = [ scene: "diagnostic", layout: "report", status: "Diagnostic report open", - chip: "Arquitetura antes de mais codigo", - eyebrow: "Diagnostico tecnico para decidir melhor", - headline: "Antes de escrever mais codigo, descubra o proximo passo certo.", + chip: "Arquitetura antes de mais código", + eyebrow: "Diagnóstico técnico para decidir melhor", + headline: "Antes de escrever mais código, descubra o próximo passo certo.", subtitle: - "Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da proxima decisao cara.", - cta: "Agendar diagnostico", + "Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da próxima decisão cara.", + cta: "Agendar diagnóstico", whatsappMessage: - "Ola! Quero um diagnostico tecnico para decidir o proximo passo do meu sistema. Podemos conversar?", - sideLabel: "Relatorio tecnico", - sideTitle: "Risco, arquitetura e rota de acao.", + "Olá! Quero um diagnóstico técnico para decidir o próximo passo do meu sistema. Podemos conversar?", + sideLabel: "Relatório técnico", + sideTitle: "Risco, arquitetura e rota de ação.", sideText: - "Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho tecnico.", + "Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho técnico.", points: [ - "Mapa claro dos gargalos e dependencias.", - "Trade-offs traduzidos para decisao de negocio.", - "Plano de transicao sem promessa magica.", + "Mapa claro dos gargalos e dependências.", + "Trade-offs traduzidos para decisão de negócio.", + "Plano de transição sem promessa mágica.", ], proof: [ { label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." }, @@ -241,7 +245,7 @@ const HERO_VARIANTS: HeroVariant[] = [ label: "Arquitetura", value: "Onde simplificar, separar ou estabilizar primeiro.", }, - { label: "Acao", value: "Proximo corte de implementacao com criterio." }, + { label: "Ação", value: "Próximo corte de implementação com critério." }, ], }, { @@ -251,34 +255,34 @@ const HERO_VARIANTS: HeroVariant[] = [ layout: "portrait", status: "Direct technical partner", chip: "Conversa direta com quem implementa", - eyebrow: "Engenharia senior, ownership e execucao", - headline: "Um parceiro tecnico para transformar complexidade em entrega.", + eyebrow: "Engenharia sênior, ownership e execução", + headline: "Um parceiro técnico para transformar complexidade em entrega.", subtitle: - "Voce nao precisa apenas de tela bonita ou codigo isolado. Precisa de alguem que entenda contexto, escolha o corte certo e acompanhe ate o sistema operar.", + "Você não precisa apenas de tela bonita ou código isolado. Precisa de alguém que entenda contexto, escolha o corte certo e acompanhe até o sistema operar.", cta: "Falar com Evandro", whatsappMessage: - "Ola Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.", + "Olá Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.", sideLabel: "Founder-led engineering", sideTitle: "Menos repasse, mais ownership.", sideText: - "A conversa e comigo: contexto tecnico, prioridade, risco e caminho de entrega sem perder informacao entre camadas.", + "A conversa é comigo: contexto técnico, prioridade, risco e caminho de entrega sem perder informação entre camadas.", points: [ - "Atendimento consultivo com leitura tecnica real.", + "Atendimento consultivo com leitura técnica real.", "Do produto ao backend sem perder contexto.", - "Execucao com criterio, nao so codigo por demanda.", + "Execução com critério, não só código por demanda.", ], proof: [ { label: "Perfil", - value: "Engenharia full-stack com base em sistemas e integracoes.", + value: "Engenharia full-stack com base em sistemas e integrações.", }, { label: "Postura", - value: "Clareza, autonomia e decisao tecnica pragmatica.", + value: "Clareza, autonomia e decisão técnica pragmática.", }, { label: "Acesso", - value: "WhatsApp direto para conversa tecnica inicial.", + value: "WhatsApp direto para conversa técnica inicial.", }, ], }, @@ -286,14 +290,15 @@ const HERO_VARIANTS: HeroVariant[] = [ const LAYOUT_CLASSES: Record = { default: - "grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10", + "grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10", "visual-first": - "grid items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10", - split: "grid items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10", + "grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10", + split: + "grid grid-cols-[minmax(0,1fr)] items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10", report: - "grid items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10", + "grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10", portrait: - "grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10", + "grid grid-cols-[minmax(0,1fr)] items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10", }; function getGreeting(message: string) { @@ -303,7 +308,25 @@ function getGreeting(message: string) { return message; } -function SideVisual({ variant }: { variant: HeroVariant }) { +function SideVisual({ + variant, + shouldReduceMotion, +}: { + variant: HeroVariant; + shouldReduceMotion: boolean; +}) { + if (variant.scene === "command") { + return ( +
+ {/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */} + + {/* autoRotate off em reduced-motion: globo vira âncora estática */} + + +
+ ); + } + if (variant.scene === "launch") { return (
@@ -355,7 +378,11 @@ function SideVisual({ variant }: { variant: HeroVariant }) { if (variant.scene === "signal") { return (
- +
-
+ {/* pt >= altura do header fixo (52px): conteúdo do hero nunca nasce sob a barra */} +
{variant.headline} @@ -644,7 +670,7 @@ export default function Hero() { - +
@@ -736,7 +765,16 @@ export default function Hero() { onClick={handleWhatsAppClick} className="mt-sp-5 inline-flex w-full items-center justify-center bg-[var(--slash)] px-sp-5 py-sp-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]" > - Agendar no WhatsApp: {whatsAppDisplay} + {/* Ícone WhatsApp inline: lucide não tem brand icons */} + + Agendar no WhatsApp
@@ -776,20 +814,29 @@ export default function Hero() { animate={{ opacity: 1, y: 0 }} transition={{ delay: 2, duration: 1 }} > - - - Scroll - -
- + {shouldReduceMotion ? ( +
); diff --git a/app/components/LighthouseMetrics.tsx b/app/components/LighthouseMetrics.tsx index 11e74fc..c18d42e 100644 --- a/app/components/LighthouseMetrics.tsx +++ b/app/components/LighthouseMetrics.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { Section } from "./Section"; @@ -48,7 +48,9 @@ export default function LighthouseMetrics() { diff --git a/app/components/Reveal.tsx b/app/components/Reveal.tsx index 514af96..8b10922 100644 --- a/app/components/Reveal.tsx +++ b/app/components/Reveal.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import type { ReactNode } from "react"; import { useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; diff --git a/app/components/RouteTransition.tsx b/app/components/RouteTransition.tsx index 94e2236..a5d3c2a 100644 --- a/app/components/RouteTransition.tsx +++ b/app/components/RouteTransition.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion } from "motion/react"; import { usePathname } from "next/navigation"; import type { ReactNode } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; diff --git a/app/components/Section.tsx b/app/components/Section.tsx index 1bb6bf5..a7a1f10 100644 --- a/app/components/Section.tsx +++ b/app/components/Section.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, type Variants } from "framer-motion"; +import { motion, type Variants } from "motion/react"; import type React from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { DrawLine } from "./animations/DrawLine"; diff --git a/app/components/Skills.tsx b/app/components/Skills.tsx index b017ad0..3c3df25 100644 --- a/app/components/Skills.tsx +++ b/app/components/Skills.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, type Variants } from "framer-motion"; +import { motion, type Variants } from "motion/react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import type { PortfolioSkillCategory } from "@/lib/portfolio-data"; import { DrawLine } from "./animations/DrawLine"; @@ -41,8 +41,8 @@ export default function Skills({
diff --git a/app/components/WorkModalities.tsx b/app/components/WorkModalities.tsx index aa3d111..e07bc27 100644 --- a/app/components/WorkModalities.tsx +++ b/app/components/WorkModalities.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, type Variants } from "framer-motion"; +import { motion, type Variants } from "motion/react"; import Link from "next/link"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import { serviceHubLinks } from "@/lib/site-content"; @@ -28,20 +28,20 @@ const TIERS: Tier[] = [ id: "discovery", badge: "Entrada / Baixo Risco", level: "01", - title: "Auditoria & Diagnostico Tecnico", + title: "Auditoria & Diagnóstico Técnico", tagline: "Fotografia clara do problema antes de qualquer comprometimento.", description: - "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.", + "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.", deliverables: [ - "Relatorio de gargalos e divida tecnica", - "Mapeamento de riscos e dependencias", - "Sugestao de stack e cronograma de mitigacao", - "Plano de acao priorizado", + "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", ], pricing: "Valor Fixo", - pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.", + pricingNote: "Contrato único. Entrega em até 5 dias úteis.", featured: false, - ctaLabel: "Solicitar diagnostico", + ctaLabel: "Solicitar diagnóstico", ctaHref: "#contact", }, { @@ -51,12 +51,12 @@ const TIERS: Tier[] = [ title: "Ciclos de Engenharia", tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.", description: - "Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.", + "Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.", deliverables: [ - "Features em producao a cada ciclo", - "Refatoracao iterativa com rastreabilidade", - "Documentacao tecnica atualizada", - "Capacidade de pivotar prioridades no inicio de cada sprint", + "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", ], pricing: "Fixo por Ciclo", pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.", @@ -69,19 +69,19 @@ const TIERS: Tier[] = [ badge: "Premium / High-Ticket", level: "03", title: "Fractional Tech Lead", - tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.", + tagline: "Liderança técnica sênior. Sem o custo de um CTO full-time.", description: - "Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.", + "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.", deliverables: [ - "SLA prioritario Zero-Risk", + "SLA prioritário Zero-Risk", "Code review e lideranca do time interno", "Decisoes de infra: Cloud, VPS, CI/CD", "Consultoria estrategica recorrente", ], pricing: "Retainer Mensal", - pricingNote: "Renovacao mensal. Vagas limitadas.", + pricingNote: "Renovação mensal. Vagas limitadas.", featured: false, - ctaLabel: "Agendar conversa estrategica", + ctaLabel: "Agendar conversa estratégica", ctaHref: "#contact", }, ]; @@ -120,7 +120,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
-

+

{tier.title}

@@ -194,22 +194,30 @@ export function WorkModalities() {

-
Paginas de servico
-

Especialidades organizadas por intencao de busca

+
Páginas de serviço
+

+ Especialidades organizadas por intenção de busca +

- Se voce chegou buscando uma stack, um tipo de consultoria ou um contexto tecnico especifico, - aqui esta o caminho mais direto para a pagina certa. + Se você chegou buscando uma stack, um tipo de consultoria ou um + contexto técnico específico, aqui está o caminho mais direto para + a página certa.

- - Ver hub de servicos + + Ver hub de serviços
diff --git a/app/components/animations/CounterValue.tsx b/app/components/animations/CounterValue.tsx index 343dabc..b5cce28 100644 --- a/app/components/animations/CounterValue.tsx +++ b/app/components/animations/CounterValue.tsx @@ -1,6 +1,6 @@ "use client"; -import { useInView } from "framer-motion"; +import { useInView } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; @@ -42,7 +42,7 @@ export function CounterValue({ const frame = (now: number) => { const elapsed = now - start; const progress = Math.min(elapsed / duration, 1); - const eased = 1 - Math.pow(1 - progress, 3); + const eased = 1 - (1 - progress) ** 3; setDisplayValue(Math.round(value * eased)); if (progress < 1) { diff --git a/app/components/animations/DrawLine.tsx b/app/components/animations/DrawLine.tsx index 0d130cc..cde55e3 100644 --- a/app/components/animations/DrawLine.tsx +++ b/app/components/animations/DrawLine.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; diff --git a/app/components/animations/LazyRevealImage.tsx b/app/components/animations/LazyRevealImage.tsx index d9cbc6a..291574b 100644 --- a/app/components/animations/LazyRevealImage.tsx +++ b/app/components/animations/LazyRevealImage.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import Image, { type StaticImageData } from "next/image"; import { useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; @@ -27,9 +27,7 @@ export function LazyRevealImage({ }> = []; -jest.mock("framer-motion", () => { +jest.mock("motion/react", () => { const React = require("react"); return { diff --git a/app/components/animations/TextDiffuseReveal.tsx b/app/components/animations/TextDiffuseReveal.tsx index 1f44dc7..471452e 100644 --- a/app/components/animations/TextDiffuseReveal.tsx +++ b/app/components/animations/TextDiffuseReveal.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion, useInView } from "framer-motion"; +import { motion, useInView } from "motion/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; diff --git a/app/components/animations/TopProgressBar.tsx b/app/components/animations/TopProgressBar.tsx index 69341c7..c6ae023 100644 --- a/app/components/animations/TopProgressBar.tsx +++ b/app/components/animations/TopProgressBar.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useReducedMotion } from "@/app/hooks/useReducedMotion"; interface TopProgressBarProps { diff --git a/app/components/animations/__mocks__/PointCloud.tsx b/app/components/animations/__mocks__/PointCloud.tsx index 32e75e1..49d0c14 100644 --- a/app/components/animations/__mocks__/PointCloud.tsx +++ b/app/components/animations/__mocks__/PointCloud.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; export default function PointCloudScene() { return
; } diff --git a/app/components/cinematic/CinematicOverlays.tsx b/app/components/cinematic/CinematicOverlays.tsx index 10dd7e8..6559a3d 100644 --- a/app/components/cinematic/CinematicOverlays.tsx +++ b/app/components/cinematic/CinematicOverlays.tsx @@ -1,21 +1,29 @@ "use client"; import dynamic from "next/dynamic"; +import { usePathname } from "next/navigation"; import PageIntro from "./PageIntro"; const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false }); const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false }); -const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false }); -const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false }); +const ScrollProgress = dynamic(() => import("./ScrollProgress"), { + ssr: false, +}); export default function CinematicOverlays() { + const pathname = usePathname(); + const isHome = pathname === "/"; + return ( <> - - - - + {isHome ? ( + <> + + + + + ) : null} ); } diff --git a/app/components/cinematic/CinematicSection.tsx b/app/components/cinematic/CinematicSection.tsx index 054b576..c077adf 100644 --- a/app/components/cinematic/CinematicSection.tsx +++ b/app/components/cinematic/CinematicSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useEffect, type ReactNode } from "react"; +import { type ReactNode, useEffect, useRef } from "react"; interface CinematicSectionProps { children: ReactNode; diff --git a/app/components/cinematic/CinematicText.tsx b/app/components/cinematic/CinematicText.tsx index bda7651..c66a827 100644 --- a/app/components/cinematic/CinematicText.tsx +++ b/app/components/cinematic/CinematicText.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useEffect } from "react"; +import { useEffect, useRef } from "react"; interface CinematicTextProps { text: string; diff --git a/app/components/cinematic/ControlRoomGlobe.tsx b/app/components/cinematic/ControlRoomGlobe.tsx new file mode 100644 index 0000000..fe6b472 --- /dev/null +++ b/app/components/cinematic/ControlRoomGlobe.tsx @@ -0,0 +1,202 @@ +"use client"; + +import createGlobe, { type COBEOptions } from "cobe"; +import { useMotionValue, useSpring } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +const MOVEMENT_DAMPING = 1400; + +const JOINVILLE_LAT = -26.3044; +const JOINVILLE_LNG = -48.8456; +// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville) +const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180; +const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180; + +const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = { + width: 800, + height: 800, + onRender: () => {}, + devicePixelRatio: 2, + phi: JOINVILLE_LNG_RAD, + theta: TROPIC_OF_CAPRICORN_RAD, + dark: 0, + diffuse: 1.2, + mapSamples: 16000, + mapBrightness: 6, + baseColor: [0.32, 0.32, 0.32], + markerColor: [204 / 255, 255 / 255, 0 / 255], + glowColor: [204 / 255, 255 / 255, 0 / 255], + markers: [ + { location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 }, + { location: [-25.4284, -49.2733], size: 0.06 }, + { location: [-26.0167, -48.5], size: 0.05 }, + { location: [-25.9333, -48.7833], size: 0.04 }, + ], +} as const satisfies COBEOptions; + +export function ControlRoomGlobe({ + className, + config = CONTROL_ROOM_GLOBE_CONFIG, + autoRotate = true, + rotationSpeed = 0.003, +}: { + className?: string; + config?: COBEOptions; + autoRotate?: boolean; + rotationSpeed?: number; +}) { + const [width, setWidth] = useState(0); + const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD); + // Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical + const thetaPhaseRef = useRef(0); + const thetaDragRef = useRef(0); + const canvasRef = useRef(null); + const pointerInteracting = useRef(null); + const pointerYOrigin = useRef(null); + const isVisibleRef = useRef(true); + + const r = useMotionValue(0); + const rs = useSpring(r, { + mass: 1, + damping: 30, + stiffness: 100, + }); + + const updatePointerInteraction = (value: number | null) => { + pointerInteracting.current = value; + if (canvasRef.current) { + canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab"; + } + }; + + const updateMovement = (clientX: number, clientY?: number) => { + if (pointerInteracting.current !== null) { + const delta = clientX - pointerInteracting.current; + r.set(r.get() + delta / MOVEMENT_DAMPING); + // Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo) + if (clientY !== undefined && pointerYOrigin.current !== null) { + const deltaY = clientY - pointerYOrigin.current; + thetaDragRef.current = Math.max( + -0.9, + Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING), + ); + pointerYOrigin.current = clientY; + } + } + }; + + useEffect(() => { + const onResize = () => { + if (canvasRef.current) { + setWidth(canvasRef.current.offsetWidth); + } + }; + + window.addEventListener("resize", onResize); + onResize(); + + const observer = new IntersectionObserver( + ([entry]) => { + isVisibleRef.current = entry.isIntersecting; + }, + { rootMargin: "100px" }, + ); + + if (canvasRef.current) { + observer.observe(canvasRef.current); + } + + const handleVisibilityChange = () => { + isVisibleRef.current = !document.hidden; + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + const canvas = canvasRef.current; + if (!canvas) { + return () => { + window.removeEventListener("resize", onResize); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + observer.disconnect(); + }; + } + const globe = createGlobe(canvas, { + ...config, + width: width * 2, + height: width * 2, + onRender: (state) => { + if (!isVisibleRef.current || document.hidden) return; + if (autoRotate && !pointerInteracting.current) { + phiRef.current += rotationSpeed; + thetaPhaseRef.current += rotationSpeed * 0.9; + } + state.phi = phiRef.current + rs.get(); + // Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical + state.theta = + (config.theta ?? TROPIC_OF_CAPRICORN_RAD) + + Math.sin(thetaPhaseRef.current) * 0.35 + + thetaDragRef.current; + state.width = width * 2; + state.height = width * 2; + }, + }); + + setTimeout(() => { + if (canvasRef.current) { + canvasRef.current.style.opacity = "1"; + } + }, 0); + + return () => { + globe.destroy(); + window.removeEventListener("resize", onResize); + document.removeEventListener("visibilitychange", handleVisibilityChange); + observer.disconnect(); + }; + }, [rs, config, autoRotate, rotationSpeed, width]); + + return ( + + ); +} diff --git a/app/components/cinematic/CustomCursor.tsx b/app/components/cinematic/CustomCursor.tsx index d3c80b4..6719ceb 100644 --- a/app/components/cinematic/CustomCursor.tsx +++ b/app/components/cinematic/CustomCursor.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useRef, useState, useCallback } from "react"; -import { motion, useMotionValue } from "framer-motion"; +import { motion, useMotionValue } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; export default function CustomCursor() { const [isHovering, setIsHovering] = useState(false); diff --git a/app/components/cinematic/FilmGrain.tsx b/app/components/cinematic/FilmGrain.tsx index 0483459..fca8a93 100644 --- a/app/components/cinematic/FilmGrain.tsx +++ b/app/components/cinematic/FilmGrain.tsx @@ -9,6 +9,12 @@ export default function FilmGrain() { const canvas = canvasRef.current; if (!canvas) return; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + const coarsePointer = window.matchMedia("(pointer: coarse)").matches; + if (reducedMotion || coarsePointer) return; + const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -57,9 +63,8 @@ export default function FilmGrain() { return (