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).
This commit is contained in:
Evandro Fonseca Junior committed 2026-08-27 10:55:21 -03:00
1 parent 0920370f30
commit b791b7de9e
94 files changed
+2621 -2889

No files matched your search

+3
View File
@@ -53,3 +53,6 @@ next-env.d.ts
*.analysis.json
*.tmp.json
*.debug.json
.omo/
.playwright-mcp/
+13 -8
View File
@@ -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 (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
@@ -117,7 +120,9 @@ export default async function CasePage({ params }: CasePageProps) {
</section>
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Tecnologias e contexto técnico</h2>
<h2 className="ds-section-title !leading-snug">
Tecnologias e contexto técnico
</h2>
<p className="ds-section-description">
Este case demonstra como aplico engenharia de software a produtos
com requisitos de performance, integração e operação, para
+14 -16
View File
@@ -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 (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
@@ -70,11 +68,11 @@ export default function CasesPage() {
<div className="max-w-[860px]">
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integracoes e produtos conectados.
Cases em software, Next.js, integrações e produtos conectados.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
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.
</p>
</div>
@@ -83,9 +81,9 @@ export default function CasesPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-grid-2 items-start">
{caseStudies.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<article key={caseStudy.id} className="ds-card !h-auto">
<div className="ds-kicker">{caseStudy.title}</div>
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
<p className="ds-copy">{caseStudy.summary}</p>
@@ -130,7 +128,7 @@ export default function CasesPage() {
className="ds-link ds-link-accent"
strength={0.25}
>
Ver servicos
Ver serviços
</MagneticButton>
<MagneticButton
as="a"
+17 -17
View File
@@ -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 profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
@@ -10,39 +10,39 @@ import { Section } from "./Section";
const STORY_DATA = [
{
label: "Hardware + software",
title: "Do sinal fisico ao produto digital",
title: "Do sinal físico ao produto digital",
content:
"Construo a ponte entre dispositivos, protocolos de baixo nivel, backend e interface para que o sistema inteiro pareca uma unica experiencia coerente.",
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.",
},
{
label: "Execution",
title: "Entrega clara mesmo sob restricao",
title: "Entrega clara mesmo sob restrição",
content:
"Trabalho bem em contextos com legado, integracoes complexas e pouca margem para erro, priorizando estabilidade, leitura tecnica e evolucao sustentavel.",
"Trabalho bem em contextos com legado, integrações complexas e pouca margem para erro, priorizando estabilidade, leitura técnica e evolução sustentável.",
},
{
label: "Production mindset",
title: "Do codigo ao sistema no ar",
title: "Do código ao sistema no ar",
content:
"Penso alem da feature: deploy, observabilidade, manutencao e riscos de operacao entram na decisao tecnica desde o primeiro corte.",
"Penso além da feature: deploy, observabilidade, manutenção e riscos de operação entram na decisão técnica desde o primeiro corte.",
},
];
const PROFILE_POINTS = [
{
title: "Arquitetura pragmatica",
title: "Arquitetura pragmática",
description:
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
},
{
title: "Integracao profunda",
title: "Integração profunda",
description:
"Capacidade de atravessar camada fisica, servicos e interface sem perder consistencia.",
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
},
{
title: "Ownership ponta a ponta",
description:
"Responsabilidade que cobre discovery, implementacao, qualidade e operacao.",
"Responsabilidade que cobre discovery, implementação, qualidade e operação.",
},
];
@@ -87,8 +87,8 @@ export default function About() {
<Section
id="about"
eyebrow="Operating context"
title="Engenharia de software, arquitetura e integracoes complexas"
description="Atuo na intersecao entre arquitetura de software, integracoes de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
title="Engenharia de software, arquitetura e integrações complexas"
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
disableChildrenAnimation
aside={
<div className="mt-sp-4 ds-metric">
@@ -140,12 +140,12 @@ export default function About() {
/>
<div className="ds-kicker">Technical profile</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
<p className="ds-copy">
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.
</p>
<div className="mt-sp-6 space-y-sp-4">
{PROFILE_POINTS.map((point) => (
@@ -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,
@@ -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,
+3 -3
View File
@@ -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,
+3 -1
View File
@@ -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() {
<div
key={`${logo.name}-${index}`}
className="flex-shrink-0 flex items-center justify-center"
// Second half is a visual duplicate for seamless looping; hide from AT
aria-hidden={index >= companies.length ? true : undefined}
>
<Image
src={logo.imagePath}
+4 -4
View File
@@ -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 { useEffect, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -64,8 +64,8 @@ export default function Contact() {
<Section
id="contact"
eyebrow="Contact"
title="Consultoria tecnica para software, arquitetura e integracoes criticas"
description="Fale comigo se voce precisa de engenharia de software, consultoria tecnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
title="Consultoria técnica para software, arquitetura e integrações críticas"
description="Fale comigo se você precisa de engenharia de software, consultoria técnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
dark
disableChildrenAnimation
>
@@ -182,7 +182,7 @@ export default function Contact() {
<option value="full-stack">Desenvolvimento Full-Stack</option>
<option value="cloud">Cloud & Infrastructure</option>
<option value="iot">Internet of Things (IoT)</option>
<option value="consultancy">Consultoria Tecnica Senior</option>
<option value="consultancy">Consultoria Técnica Sênior</option>
</select>
</div>
<div>
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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";
@@ -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<HTMLElement> & {
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 }) => (
<Tag {...props}>{children}</Tag>
);
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
DrawLine: () => <span data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
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) => <div>{message}</div>,
}));
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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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(
<Experience experiences={experiences} />,
);
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(<Experience />);
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(<Experience />);
// 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(<Experience experiences={[]} />);
const { container } = render(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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);
});
});
@@ -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<HTMLElement> & {
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 }) => (
<Tag {...props}>{children}</Tag>
);
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
DrawLine: () => <span data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
// Mock SectionLoading
jest.mock("./SectionLoading", () => ({
SectionLoading: ({ message }: any) => <div>{message}</div>,
}));
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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
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(<Experience />);
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(
<Experience experiences={experiences} />,
);
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(<Experience experiences={experiences} />);
expect(
Array.from(container.querySelectorAll("article .ds-kicker")).map(
(element) => element.textContent,
),
).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]);
});
});
@@ -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<HTMLElement> & {
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 }) => (
<Tag {...props}>{children}</Tag>
);
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
Section: ({
children,
title,
eyebrow: _eyebrow,
description: _description,
disableChildrenAnimation: _disableChildrenAnimation,
...props
}: SectionMockProps) => (
<section {...props}>
<h2>{title}</h2>
{children}
</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div data-testid="draw-line" />,
DrawLine: () => <span data-testid="draw-line" />,
}));
jest.mock("./animations/CounterValue", () => ({
CounterValue: ({ value, suffix }: any) => (
<span>
{value}
{suffix}
</span>
),
}));
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) => <div>{message}</div>,
}));
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(
<Experience experiences={experiences} />,
);
const { container } = render(<Experience />);
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(<Experience />);
const { container } = render(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
// 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(<Experience />);
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);
});
});
+5 -5
View File
@@ -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({
<Section
id="experience"
eyebrow="Selected experience"
title="Experiencia em engenharia de software e sistemas criticos"
description="Projetos e contextos em que combinei arquitetura de software, integracoes complexas e entrega com responsabilidade operacional."
title="Experiência em engenharia de software e sistemas críticos"
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
disableChildrenAnimation
>
<motion.div
@@ -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 9: ARIA Labels for Links", () => {
it("should have descriptive aria-label attributes for all social media links", () => {
@@ -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(<Footer />);
fc.property(fc.integer({ min: 1, max: 5 }), (renderCount) => {
for (let index = 0; index < renderCount; index += 1) {
const { container, unmount } = render(<Footer />);
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(<Footer />);
// 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(<Footer />);
it("uses the mobile-first grid primitive instead of the legacy grid contract", () => {
const { container } = render(<Footer />);
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(<Footer />);
// 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(<Footer />);
// 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(<Footer />);
// 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(<Footer />);
// 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");
});
});
@@ -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', () => {
+58 -287
View File
@@ -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(<Footer />);
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(<Footer />);
const emailLink = screen.getByText("[email protected]");
expect(emailLink).toBeInTheDocument();
expect(emailLink.tagName).toBe("A");
});
it("should have mailto link for email", () => {
render(<Footer />);
const emailLink = screen.getByText("[email protected]");
const emailLink = screen.getByRole("link", {
name: "[email protected]",
});
expect(emailLink).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("should apply correct styling classes to email link", () => {
it("renders quick links as same-tab navigation", () => {
render(<Footer />);
const emailLink = screen.getByText("[email protected]");
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
describe("Footer structure", () => {
it("uses the current design-system shell and two-card grid", () => {
const { container } = render(<Footer />);
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(<Footer />);
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(<Footer />);
// 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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
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(<Footer />);
// 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(<Footer />);
const kickers = document.querySelectorAll(".ds-kicker");
expect(kickers.length).toBeGreaterThan(0);
});
it("should have ds-section class on footer element", () => {
const { container } = render(<Footer />);
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(<Footer />);
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(<Footer />);
const shell = container.querySelector(".ds-section-shell");
expect(shell).toBeInTheDocument();
});
});
describe("Footer Component - Structure", () => {
it("should render as footer semantic element", () => {
const { container } = render(<Footer />);
const footer = container.querySelector("footer");
expect(footer).toBeInTheDocument();
});
it("should have three main sections", () => {
render(<Footer />);
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(<Footer />);
const innerContainer = container.querySelector(".ds-section-shell");
expect(innerContainer).toBeInTheDocument();
expect(
screen.getByText(
new RegExp(`Copyright ${currentYear} Evandro Fonseca Junior`),
),
).toBeInTheDocument();
});
});
+10 -9
View File
@@ -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() {
<div className="ds-kicker">Contato</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">
Engenharia com menos ruido e mais clareza de execucao
Engenharia com menos ruído e mais clareza de execução
</h3>
<p className="ds-copy max-w-[48ch]">
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.
</p>
@@ -79,14 +79,15 @@ export default function Footer() {
Garuva, SC | Remote worldwide
</div>
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
Copyright {currentYear} Evandro Fonseca Junior. All rights reserved.
Copyright {currentYear} Evandro Fonseca Junior. All rights
reserved.
</p>
</div>
</div>
<div className="ds-card flex flex-col justify-between">
<div>
<div className="ds-kicker">Presenca digital</div>
<div className="ds-kicker">Presença digital</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">Canais profissionais</h3>
+107
View File
@@ -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<string, unknown>) =>
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(<Header />);
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(<Header />);
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(<Header />);
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);
});
});
+69 -50
View File
@@ -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<HTMLButtonElement>(null);
const closeMobileMenu = () => {
setMobileMenuOpen(false);
menuButtonRef.current?.focus();
};
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMobileMenu();
};
return (
<header className="ds-section-dark fixed inset-x-0 top-0 z-50 h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-6 backdrop-blur-xl lg:px-sp-16">
<header className="ds-section-dark fixed inset-x-0 top-0 z-[9000] h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-8 backdrop-blur-xl lg:px-sp-16">
<div className="ds-section-shell flex h-full items-center justify-between">
<a href="#home" className="flex items-center gap-3 no-underline">
<a href="/#home" className="flex items-center gap-3 no-underline">
<Logo variant="mark" size="sm" theme="dark" className="shrink-0" />
<span className="font-display text-sm font-extrabold tracking-tight text-white">
evandro<span className="text-[var(--slash)]">.dev.br</span>
</span>
</a>
<nav className="hidden items-center lg:flex">
<nav
aria-label="Navegação principal"
className="site-header-nav items-center"
>
<ul className="flex list-none items-center gap-sp-2">
{navItems.map((item) => (
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<MagneticButton
as="a"
@@ -45,24 +58,24 @@ export function Header() {
</nav>
<div className="flex items-center gap-sp-4">
<span className="ds-metric hidden rounded-full border border-white/10 px-[10px] py-1 text-white/40 lg:inline-flex">
v1.0 / 2026
</span>
<MagneticButton
as="a"
href="/Profile.pdf"
download="Evandro_Fonseca_Junior_CV.pdf"
className="ds-link ds-link-accent hidden rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)] lg:inline-flex"
href={CV_URL}
download={CV_DOWNLOAD_NAME}
className="site-header-cv ds-link ds-link-accent rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)]"
strength={0.2}
>
Download CV
</MagneticButton>
<button
ref={menuButtonRef}
type="button"
className="flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent lg:hidden"
className="site-header-menu-toggle flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Menu"
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
onKeyDown={handleMenuKeyDown}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
@@ -77,40 +90,46 @@ export function Header() {
</div>
</div>
<div
className={`absolute inset-x-0 top-[52px] overflow-hidden border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl transition-all duration-300 lg:hidden lg:px-sp-16 ${mobileMenuOpen ? "max-h-[420px] opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="ds-section-shell flex flex-col py-sp-6">
<nav className="mb-sp-6">
<ul className="flex list-none flex-col gap-sp-2">
{navItems.map((item) => (
<li key={item.href}>
{mobileMenuOpen ? (
<div className="site-header-mobile-panel absolute inset-x-0 top-[52px] border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl lg:px-sp-16">
<div className="ds-section-shell flex flex-col py-sp-6">
<nav
id={MENU_ID}
aria-hidden={!mobileMenuOpen}
aria-label="Navegação mobile"
onKeyDown={handleMenuKeyDown}
>
<ul className="flex list-none flex-col gap-sp-2">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<MagneticButton
as="a"
href={item.href}
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-[padding,color] hover:pl-sp-2 hover:text-white"
onClick={closeMobileMenu}
strength={0.15}
>
{item.label}
</MagneticButton>
</li>
))}
<li>
<MagneticButton
as="a"
href={item.href}
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-all hover:pl-sp-2 hover:text-white"
onClick={() => setMobileMenuOpen(false)}
href={CV_URL}
download={CV_DOWNLOAD_NAME}
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
onClick={closeMobileMenu}
strength={0.15}
>
{item.label}
Download CV
</MagneticButton>
</li>
))}
<li>
<MagneticButton
as="a"
href="/Profile.pdf"
download="Evandro_Fonseca_Junior_CV.pdf"
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
strength={0.15}
>
Download CV
</MagneticButton>
</li>
</ul>
</nav>
</ul>
</nav>
</div>
</div>
</div>
) : null}
</header>
);
}
+21 -1
View File
@@ -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,
+26 -3
View File
@@ -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);
+137 -90
View File
@@ -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<HeroVariant["layout"], string> = {
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 (
<div className="mb-sp-6 space-y-sp-4">
{/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */}
<ParticleBuildText text={variant.headline} />
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
<TelemetryStrip reducedMotion={shouldReduceMotion} />
</div>
);
}
if (variant.scene === "launch") {
return (
<div className="mb-sp-6 grid gap-sp-3">
@@ -355,7 +378,11 @@ function SideVisual({ variant }: { variant: HeroVariant }) {
if (variant.scene === "signal") {
return (
<div className="relative mb-sp-6 h-40 overflow-hidden border border-[rgba(255,255,255,.08)] bg-[rgba(255,255,255,.03)]">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 420 160">
<svg
aria-hidden="true"
className="absolute inset-0 h-full w-full"
viewBox="0 0 420 160"
>
<path
d="M28 116 C108 18 190 142 284 56 S380 36 400 28"
fill="none"
@@ -430,7 +457,6 @@ export default function Hero() {
const variant = HERO_VARIANTS[0];
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
const reducedMotion = window.matchMedia(
@@ -438,7 +464,6 @@ export default function Hero() {
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
setShouldReduceMotion(reducedMotion);
setWhatsAppDisplay(getWhatsAppDisplayNumber());
if (
!shouldLoadHeroScene({
@@ -524,7 +549,8 @@ export default function Hero() {
/>
<div className="pointer-events-none absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_50%,transparent_40%,rgba(10,10,10,0.6)_100%)]" />
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 py-sp-16 lg:px-sp-16 lg:py-sp-12">
{/* pt >= altura do header fixo (52px): conteúdo do hero nunca nasce sob a barra */}
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 pt-sp-16 pb-sp-16 lg:max-h-none lg:px-sp-16 lg:pt-sp-14 lg:pb-sp-12">
<motion.div
initial="hidden"
animate="visible"
@@ -578,7 +604,7 @@ export default function Hero() {
</motion.p>
<motion.h1
className="max-w-[940px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
className="max-w-[940px] font-display text-[clamp(1.9rem,5.2vw,4.75rem)] font-extrabold leading-[0.95] tracking-[-0.055em] lg:leading-[0.9] lg:text-balance"
variants={heroItemSlow}
>
{variant.headline}
@@ -644,7 +670,7 @@ export default function Hero() {
<MagneticButton
as="a"
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
href={CV_URL}
download
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.76)] no-underline transition-colors duration-300 hover:text-[var(--slash)]"
strength={0.2}
@@ -719,7 +745,10 @@ export default function Hero() {
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
/>
<SideVisual variant={variant} />
<SideVisual
variant={variant}
shouldReduceMotion={shouldReduceMotion}
/>
<div className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
@@ -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 */}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
>
<path d="M12.04 2a9.88 9.88 0 0 0-8.41 4.7A9.88 9.88 0 0 0 2.1 12.6a9.8 9.8 0 0 0 1.34 5.02L2 22l4.53-1.38a9.86 9.86 0 0 0 5.51 1.66h.01a9.87 9.87 0 0 0 7.02-2.9 9.79 9.79 0 0 0 2.89-6.98 9.73 9.73 0 0 0-.29-2.35A9.88 9.88 0 0 0 12.04 2Zm5.77 13.94c-.24.68-1.42 1.3-1.96 1.35-.5.05-1.13.24-3.8-.98-3.18-1.44-5.15-4.72-5.31-4.93-.15-.22-1.25-1.75-1.16-3.29.09-1.54.92-2.28 1.23-2.59.31-.31.66-.37.88-.36h.63c.2 0 .47-.03.71.58.26.65.88 2.25.96 2.41.08.17.13.36.02.57-.11.22-.17.35-.33.54-.17.19-.35.43-.5.58-.16.16-.33.34-.15.66.18.32.81 1.42 1.75 2.3 1.2 1.14 2.21 1.49 2.52 1.66.31.17.5.14.68-.09.18-.22.78-.99.99-1.33.21-.34.42-.27.7-.16.29.11 1.82.95 2.13 1.12.31.17.52.26.6.4.07.15.07.83-.17 1.51Z" />
</svg>
Agendar no WhatsApp
</button>
</div>
@@ -776,20 +814,29 @@ export default function Hero() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 1 }}
>
<motion.div
className="flex flex-col items-center gap-2"
animate={{ y: [0, 8, 0] }}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
</motion.div>
{shouldReduceMotion ? (
<div className="flex flex-col items-center gap-2" aria-hidden="true">
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
</div>
) : (
<motion.div
className="flex flex-col items-center gap-2"
animate={{ y: [0, 8, 0] }}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}}
>
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
</motion.div>
)}
</motion.div>
</section>
);
+4 -2
View File
@@ -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() {
<motion.article
key={metric.label}
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
initial={
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }
}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface LogoProps {
+3 -3
View File
@@ -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 { PortfolioProject } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
@@ -42,8 +42,8 @@ export default function Projects({
<Section
id="projects"
eyebrow="Selected work"
title="Projetos em Next.js, Tauri, Rust e integracao de sistemas"
description="Cases e produtos que mostram minha atuacao em engenharia de software, consultoria tecnica e construcao de sistemas de alta exigencia."
title="Projetos em Next.js, Tauri, Rust e integração de sistemas"
description="Cases e produtos que mostram minha atuação em engenharia de software, consultoria técnica e construção de sistemas de alta exigência."
dark
disableChildrenAnimation
>
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -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({
<Section
id="skills"
eyebrow="Core stack"
title="Next.js, Tauri, Rust e stack para produtos criticos"
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integracoes complexas."
title="Next.js, Tauri, Rust e stack para produtos críticos"
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
dark
disableChildrenAnimation
>
+36 -28
View File
@@ -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 }>) {
<DrawLine delay={0.04} />
<div className="mt-sp-4 min-w-0">
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(22px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
{tier.title}
</h3>
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
@@ -194,22 +194,30 @@ export function WorkModalities() {
<Section
id="modalities"
eyebrow="Working models"
title="Servicos de engenharia de software e consultoria tecnica."
description="Formatos de contratacao para arquitetura de software, integracoes criticas, produtos conectados e evolucao de sistemas em producao."
title="Serviços de engenharia de software e consultoria técnica."
description="Formatos de contratação para arquitetura de software, integrações críticas, produtos conectados e evolução de sistemas em produção."
disableChildrenAnimation
>
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]">
<div className="ds-kicker">Paginas de servico</div>
<h3 className="ds-card-title !mt-sp-3">Especialidades organizadas por intencao de busca</h3>
<div className="ds-kicker">Páginas de serviço</div>
<h3 className="ds-card-title !mt-sp-3">
Especialidades organizadas por intenção de busca
</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
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.
</p>
</div>
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.2}>
Ver hub de servicos
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-accent"
strength={0.2}
>
Ver hub de serviços
</MagneticButton>
</div>
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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";
@@ -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({
<motion.div
className={`relative ${className}`.trim()}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 20, scale: 1.02 }
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
}
animate={
loaded
@@ -6,7 +6,7 @@ import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockReducedMotion = false;
let mockInView = true;
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
jest.mock("framer-motion", () => {
jest.mock("motion/react", () => {
const React = require("react");
return {
@@ -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";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TopProgressBarProps {
@@ -1,4 +1,4 @@
import React from 'react';
import React from "react";
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}
+14 -6
View File
@@ -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 (
<>
<PageIntro />
<CustomCursor />
<FilmGrain />
<ScrollProgress />
<HomeButton />
{isHome ? (
<>
<CustomCursor />
<FilmGrain />
<ScrollProgress />
</>
) : null}
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
interface CinematicSectionProps {
children: ReactNode;
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useEffect, useRef } from "react";
interface CinematicTextProps {
text: string;
@@ -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<HTMLCanvasElement>(null);
const pointerInteracting = useRef<number | null>(null);
const pointerYOrigin = useRef<number | null>(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 (
<div
className={cn(
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
className,
)}
aria-hidden="true"
>
<canvas
className={cn(
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
)}
ref={canvasRef}
onPointerDown={(e) => {
pointerInteracting.current = e.clientX;
pointerYOrigin.current = e.clientY;
updatePointerInteraction(e.clientX);
}}
onPointerUp={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onPointerOut={() => updatePointerInteraction(null)}
onPointerLeave={() => {
updatePointerInteraction(null);
pointerYOrigin.current = null;
}}
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
onTouchMove={(e) =>
e.touches[0] &&
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
}
/>
<span className="sr-only">
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
indicando presença operacional na região Norte de SC e Curitiba.
</span>
</div>
);
}
+2 -2
View File
@@ -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);
+7 -2
View File
@@ -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 (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
aria-hidden="true"
/>
);
}
+28 -6
View File
@@ -1,11 +1,21 @@
"use client";
import { useRef, useMemo } from "react";
import {
Float,
MeshDistortMaterial,
MeshTransmissionMaterial,
} from "@react-three/drei";
import { Canvas, useFrame } from "@react-three/fiber";
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
import { useMemo, useRef } from "react";
import * as THREE from "three";
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
function GlassSphere({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
@@ -32,7 +42,13 @@ function GlassSphere({ position, scale = 1 }: { position: [number, number, numbe
);
}
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
function WireframeRing({
position,
scale = 1,
}: {
position: [number, number, number];
scale?: number;
}) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
@@ -80,7 +96,9 @@ function DataStream({ count = 50 }: { count?: number }) {
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
const posAttr = pointsRef.current.geometry.getAttribute(
"position",
) as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
@@ -127,7 +145,11 @@ export default function FloatingElements({
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
gl={{
antialias: false,
alpha: true,
powerPreference: "high-performance",
}}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
+7 -243
View File
@@ -1,8 +1,7 @@
"use client";
import { Float, MeshDistortMaterial } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
type HeroSceneVariant =
@@ -13,236 +12,6 @@ type HeroSceneVariant =
| "diagnostic"
| "portrait";
function ParticleField({ count = 2000 }: { count?: number }) {
const mesh = useRef<THREE.Points>(null);
const mouseRef = useRef({ x: 0, y: 0 });
const [positions, velocities] = useMemo(() => {
const pos = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
pos[i3] = (Math.random() - 0.5) * 20;
pos[i3 + 1] = (Math.random() - 0.5) * 20;
pos[i3 + 2] = (Math.random() - 0.5) * 15;
vel[i3] = (Math.random() - 0.5) * 0.002;
vel[i3 + 1] = (Math.random() - 0.5) * 0.002;
vel[i3 + 2] = (Math.random() - 0.5) * 0.001;
}
return [pos, vel];
}, [count]);
const sizes = useMemo(() => {
const s = new Float32Array(count);
for (let i = 0; i < count; i++) {
s[i] = Math.random() * 2 + 0.5;
}
return s;
}, [count]);
const handlePointerMove = useCallback(
(e: { clientX: number; clientY: number }) => {
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
},
[],
);
useFrame((state) => {
if (!mesh.current) return;
const geo = mesh.current.geometry;
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
const time = state.clock.elapsedTime;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3] += velocities[i3] + Math.sin(time * 0.3 + i) * 0.001;
arr[i3 + 1] += velocities[i3 + 1] + Math.cos(time * 0.2 + i) * 0.001;
arr[i3 + 2] += velocities[i3 + 2];
// Mouse influence
const dx = mouseRef.current.x * 5 - arr[i3];
const dy = mouseRef.current.y * 5 - arr[i3 + 1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 3) {
const force = (3 - dist) * 0.0003;
arr[i3] += dx * force;
arr[i3 + 1] += dy * force;
}
// Wrap around
if (arr[i3] > 10) arr[i3] = -10;
if (arr[i3] < -10) arr[i3] = 10;
if (arr[i3 + 1] > 10) arr[i3 + 1] = -10;
if (arr[i3 + 1] < -10) arr[i3 + 1] = 10;
if (arr[i3 + 2] > 7.5) arr[i3 + 2] = -7.5;
if (arr[i3 + 2] < -7.5) arr[i3 + 2] = 7.5;
}
posAttr.needsUpdate = true;
// Slow rotation
mesh.current.rotation.y = time * 0.02;
mesh.current.rotation.x = Math.sin(time * 0.01) * 0.1;
});
useEffect(() => {
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [handlePointerMove]);
return (
<points ref={mesh}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
<bufferAttribute
attach="attributes-size"
args={[sizes, 1]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.03}
transparent
opacity={0.6}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
function FloatingGeometry({ variant }: { variant: HeroSceneVariant }) {
const meshRef = useRef<THREE.Mesh>(null);
const mesh2Ref = useRef<THREE.Mesh>(null);
const mesh3Ref = useRef<THREE.Mesh>(null);
const layout = {
command: {
a: [4, 1.5, -3] as [number, number, number],
b: [-5, -2, -4] as [number, number, number],
c: [2, -3, -5] as [number, number, number],
scale: [1.2, 0.8, 1.5] as [number, number, number],
},
launch: {
a: [3.8, 2.1, -3] as [number, number, number],
b: [0, -1.4, -4] as [number, number, number],
c: [-3.8, -2.6, -5] as [number, number, number],
scale: [0.9, 0.9, 1.8] as [number, number, number],
},
rescue: {
a: [-3.6, 1.2, -3] as [number, number, number],
b: [3.6, 1.2, -4] as [number, number, number],
c: [0, -2.8, -5] as [number, number, number],
scale: [1, 1, 1.4] as [number, number, number],
},
signal: {
a: [-4.2, -1, -3] as [number, number, number],
b: [0.2, 1.6, -4] as [number, number, number],
c: [4.2, -0.6, -5] as [number, number, number],
scale: [0.7, 0.7, 1.2] as [number, number, number],
},
diagnostic: {
a: [4.6, 0.6, -3] as [number, number, number],
b: [-3.5, 1.8, -4] as [number, number, number],
c: [-1.2, -2.8, -5] as [number, number, number],
scale: [1.4, 0.7, 1.1] as [number, number, number],
},
portrait: {
a: [3.2, 0.4, -3] as [number, number, number],
b: [4.8, 2.1, -4] as [number, number, number],
c: [-3.6, -2.4, -5] as [number, number, number],
scale: [1.8, 0.5, 1.1] as [number, number, number],
},
}[variant];
useFrame((state) => {
const t = state.clock.elapsedTime;
if (meshRef.current) {
meshRef.current.rotation.x = t * 0.15;
meshRef.current.rotation.y = t * 0.1;
meshRef.current.position.y = layout.a[1] + Math.sin(t * 0.4) * 0.3;
}
if (mesh2Ref.current) {
mesh2Ref.current.rotation.x = t * 0.1;
mesh2Ref.current.rotation.z = t * 0.12;
mesh2Ref.current.position.y = layout.b[1] + Math.cos(t * 0.3) * 0.5;
}
if (mesh3Ref.current) {
mesh3Ref.current.rotation.y = t * 0.08;
mesh3Ref.current.rotation.z = t * 0.06;
}
});
return (
<>
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
{variant === "diagnostic" ? (
<boxGeometry args={[1.4, 1.8, 0.08, 3, 3, 1]} />
) : variant === "portrait" ? (
<capsuleGeometry args={[0.55, 1.25, 6, 12]} />
) : (
<icosahedronGeometry args={[1, 1]} />
)}
<MeshDistortMaterial
color="#ccff00"
wireframe
transparent
opacity={variant === "portrait" ? 0.1 : 0.15}
distort={0.3}
speed={2}
/>
</mesh>
</Float>
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
{variant === "launch" ? (
<coneGeometry args={[0.8, 1.6, 4]} />
) : variant === "signal" ? (
<sphereGeometry args={[0.55, 16, 16]} />
) : (
<octahedronGeometry args={[1, 0]} />
)}
<MeshDistortMaterial
color="#ffffff"
wireframe
transparent
opacity={0.08}
distort={0.2}
speed={1.5}
/>
</mesh>
</Float>
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
{variant === "rescue" ? (
<boxGeometry args={[1.4, 1.4, 1.4, 2, 2, 2]} />
) : variant === "signal" ? (
<torusKnotGeometry args={[0.7, 0.16, 80, 8]} />
) : (
<torusGeometry args={[1, 0.3, 16, 32]} />
)}
<meshStandardMaterial
color="#ccff00"
wireframe
transparent
opacity={0.06}
/>
</mesh>
</Float>
</>
);
}
function GridFloor() {
const meshRef = useRef<THREE.Mesh>(null);
@@ -334,11 +103,8 @@ function CameraRig() {
return null;
}
export default function HeroScene({
variant = "command",
}: {
variant?: HeroSceneVariant;
}) {
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
void variant;
return (
<div
className="absolute inset-0 z-0"
@@ -362,18 +128,16 @@ export default function HeroScene({
display: "block",
}}
>
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
<ambientLight intensity={0.3} />
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
<ambientLight intensity={0.25} />
<directionalLight
position={[5, 5, 5]}
intensity={0.5}
intensity={0.4}
color="#ccff00"
/>
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
<CameraRig />
<ParticleField count={1500} />
<FloatingGeometry variant={variant} />
<GridFloor />
</Canvas>
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
@@ -29,7 +29,8 @@ export default function HomeButton() {
<span
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{
boxShadow: "inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
boxShadow:
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
}}
/>
@@ -63,4 +64,3 @@ export default function HomeButton() {
</AnimatePresence>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
import { type ReactNode, useEffect, useRef } from "react";
interface HorizontalScrollProps {
children: ReactNode;
@@ -70,13 +70,17 @@ export default function HorizontalScroll({
end: "left 30%",
scrub: true,
},
}
},
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
if (
st.trigger === container ||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
tween
) {
st.kill();
}
});
@@ -0,0 +1,41 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import MagneticButton from "./MagneticButton";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
describe("MagneticButton semantics", () => {
it("renders a list item directly when used inside a list", () => {
const { container } = render(
<ul>
<MagneticButton as="li" className="ds-tag">
TypeScript
</MagneticButton>
</ul>,
);
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
});
});
+51 -27
View File
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, type ReactNode } from "react";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { type MouseEvent, type ReactNode, useState } from "react";
interface MagneticButtonProps {
children: ReactNode;
@@ -34,14 +34,10 @@ export default function MagneticButton({
wrapperClassName = "",
"aria-label": ariaLabel,
}: MagneticButtonProps) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouse = (e: React.MouseEvent) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const handleMouse = (e: MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
@@ -55,31 +51,59 @@ export default function MagneticButton({
setPosition({ x: 0, y: 0 });
};
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
const Tag =
as === "a"
? motion.a
: as === "button"
? motion.button
: as === "div"
? motion.div
: as === "span"
? motion.span
: motion.li;
const linkProps = as === "a" ? { href, target, rel, download } : {};
const buttonProps = as === "button" ? { type } : {};
return (
<div
ref={ref}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
className={`inline-block ${wrapperClassName}`.trim()}
>
const motionProps = {
animate: {
x: position.x,
y: position.y,
},
transition: {
type: "spring" as const,
stiffness: 350,
damping: 15,
mass: 0.5,
},
};
if (as === "li") {
return (
<Tag
className={className}
className={`${className} ${wrapperClassName}`.trim()}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
animate={{
x: position.x,
y: position.y,
}}
transition={{
type: "spring",
stiffness: 350,
damping: 15,
mass: 0.5,
}}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
);
}
return (
<div className={`inline-block ${wrapperClassName}`.trim()}>
<Tag
className={className}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
data-cursor={dataCursor}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
+5 -5
View File
@@ -8,7 +8,7 @@ jest.mock("react-dom", () => ({
createPortal: (children: ReactNode) => children,
}));
jest.mock("framer-motion", () => {
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
@@ -31,9 +31,7 @@ describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(
screen.getByRole("heading", { name: /EVANDRO.*DEV/ }),
).toBeInTheDocument();
expect(screen.getByText(/EVANDRO.*DEV/)).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
@@ -47,7 +45,9 @@ describe("PageIntro", () => {
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
expect(
screen.queryByText("Preparando o portfólio"),
).not.toBeInTheDocument();
jest.useRealTimers();
});
});
+7 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
@@ -42,7 +42,7 @@ export default function PageIntro() {
aria-label="Preparando o portfólio"
>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
@@ -53,8 +53,9 @@ export default function PageIntro() {
>
SINAL DE ENTRADA
</motion.p>
<motion.h1
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
<motion.p
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
@@ -64,7 +65,7 @@ export default function PageIntro() {
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.h1>
</motion.p>
<motion.div
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
@@ -94,7 +95,7 @@ export default function PageIntro() {
</motion.div>
</div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
@@ -0,0 +1,397 @@
"use client";
import { useEffect, useRef } from "react";
const DEFAULT_TEXT =
"Engenharia de software para sistemas que precisam operar de verdade.";
const MAX_DPR = 1.5;
const ASSEMBLE_MS = 2000;
const REPEL_RADIUS_PX = 80;
const MIN_PARTICLES = 1200;
const MAX_PARTICLES = 2500;
const OFFSCREEN_POINTER = -100000;
type Particle = {
sx: number;
sy: number;
tx: number;
ty: number;
ox: number;
oy: number;
delay: number;
color: string;
size: number;
phase: number;
speed: number;
amp: number;
};
type TrackingCapableContext = CanvasRenderingContext2D & {
letterSpacing?: string;
};
type Rgb = [number, number, number];
const FALLBACK_SLASH: Rgb = [204, 255, 0];
const FALLBACK_PAPER: Rgb = [244, 242, 237];
function hexToRgb(hex: string): Rgb | null {
const value = hex.trim().replace("#", "");
const full =
value.length === 3
? value
.split("")
.map((char) => char + char)
.join("")
: value;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
];
}
function easeOutQuint(progress: number) {
return 1 - (1 - progress) ** 5;
}
function wrapLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
) {
const lines: string[] = [];
let current = "";
for (const word of text.split(" ")) {
const candidate = current ? `${current} ${word}` : word;
if (!current || ctx.measureText(candidate).width <= maxWidth) {
current = candidate;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
return lines;
}
export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let rafId = 0;
let running = false;
let cancelled = false;
let resampleTimer = 0;
let particles: Particle[] = [];
let hasAssembled = false;
let elapsed = 0;
let lastTime = 0;
let dpr = 1;
let intersecting = true;
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
let rect = container.getBoundingClientRect();
const rootStyles = getComputedStyle(document.documentElement);
const slashRgb =
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
const paperRgb =
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
// next/font gera nome de família com hash: resolver pela classe font-display real
const probe = document.createElement("span");
probe.className = "font-display";
probe.style.position = "absolute";
probe.style.opacity = "0";
document.body.appendChild(probe);
const fontFamily = getComputedStyle(probe).fontFamily || "sans-serif";
document.body.removeChild(probe);
const fontsReady =
typeof document.fonts === "undefined"
? Promise.resolve()
: document.fonts.ready;
const sample = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (width < 60 || height < 60) return;
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
rect = container.getBoundingClientRect();
const offscreen = document.createElement("canvas");
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const offCtx = offscreen.getContext("2d");
if (!offCtx) return;
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
offCtx.scale(dpr, dpr);
const maxWidth = width * 0.92;
let fontSize = Math.min(height * 0.22, width / 7);
let lines: string[] = [];
while (fontSize >= 12) {
offCtx.font = `800 ${fontSize}px ${fontFamily}`;
const trackingCtx = offCtx as TrackingCapableContext;
if (trackingCtx.letterSpacing !== undefined) {
trackingCtx.letterSpacing = "-0.04em";
}
lines = wrapLines(offCtx, text, maxWidth);
if (lines.length * fontSize * 0.98 <= height * 0.8) break;
fontSize *= 0.94;
}
fontSize = Math.max(fontSize, 12);
const lineHeight = fontSize * 0.98;
const startY = (height - lines.length * lineHeight) / 2 + fontSize * 0.74;
offCtx.fillStyle = "#ffffff";
offCtx.textBaseline = "alphabetic";
lines.forEach((line, index) => {
const lineWidth = offCtx.measureText(line).width;
offCtx.fillText(
line,
(width - lineWidth) / 2,
startY + index * lineHeight,
);
});
const pixels = offCtx.getImageData(
0,
0,
offscreen.width,
offscreen.height,
).data;
const targets: Array<[number, number]> = [];
const stride = 3;
for (let py = 0; py < offscreen.height; py += stride) {
for (let px = 0; px < offscreen.width; px += stride) {
if (pixels[(py * offscreen.width + px) * 4 + 3] > 128) {
targets.push([px, py]);
}
}
}
if (targets.length === 0) return;
const desired = Math.round(
Math.min(
MAX_PARTICLES,
Math.max(MIN_PARTICLES, (width * height) / 180),
),
);
particles = Array.from({ length: desired }, () => {
const target = targets[Math.floor(Math.random() * targets.length)];
const useSlash = Math.random() < 0.72;
const rgb = useSlash ? slashRgb : paperRgb;
const alpha = useSlash
? 0.28 + Math.random() * 0.22
: 0.25 + Math.random() * 0.15;
return {
tx: target[0],
ty: target[1],
sx: hasAssembled
? target[0]
: (Math.random() * 1.3 - 0.15) * canvas.width,
sy: hasAssembled
? target[1]
: (Math.random() * 1.3 - 0.15) * canvas.height,
ox: 0,
oy: 0,
delay: Math.random() * 0.35,
color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`,
size: (Math.random() < 0.85 ? 1 : 2) * dpr,
phase: Math.random() * Math.PI * 2,
speed: 0.0005 + Math.random() * 0.0007,
amp: (0.6 + Math.random()) * dpr,
};
});
};
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const pointerX = (pointer.x - rect.left) * dpr;
const pointerY = (pointer.y - rect.top) * dpr;
const radius = REPEL_RADIUS_PX * dpr;
const radiusSq = radius * radius;
let allSettled = true;
for (const particle of particles) {
const progress = Math.min(
Math.max(
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
0,
),
1,
);
if (progress < 1) allSettled = false;
const eased = easeOutQuint(progress);
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
const driftX =
Math.sin(elapsed * particle.speed + particle.phase) *
particle.amp *
progress;
const driftY =
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
particle.amp *
progress;
const dx = baseX + driftX - pointerX;
const dy = baseY + driftY - pointerY;
const distSq = dx * dx + dy * dy;
if (distSq < radiusSq) {
const dist = Math.sqrt(distSq) || 1;
const force = (1 - dist / radius) * 10 * dpr;
particle.ox += (dx / dist) * force;
particle.oy += (dy / dist) * force;
}
particle.ox *= 0.86;
particle.oy *= 0.86;
ctx.fillStyle = particle.color;
ctx.fillRect(
baseX + driftX + particle.ox,
baseY + driftY + particle.oy,
particle.size,
particle.size,
);
}
if (allSettled && particles.length > 0) hasAssembled = true;
};
const drawStaticFrame = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const particle of particles) {
ctx.fillStyle = particle.color;
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
}
};
const tick = (now: number) => {
elapsed += Math.min(now - lastTime, 50);
lastTime = now;
render();
};
const syncLoop = () => {
const shouldRun = !reducedMotion && intersecting && !document.hidden;
if (shouldRun && !running) {
running = true;
lastTime = performance.now();
rafId = window.requestAnimationFrame(tick);
} else if (!shouldRun && running) {
running = false;
window.cancelAnimationFrame(rafId);
}
};
const scheduleResample = () => {
window.clearTimeout(resampleTimer);
resampleTimer = window.setTimeout(() => {
sample();
if (reducedMotion) drawStaticFrame();
}, 150);
};
const handlePointerMove = (event: PointerEvent) => {
pointer.x = event.clientX;
pointer.y = event.clientY;
};
const handlePointerLeave = () => {
pointer.x = OFFSCREEN_POINTER;
pointer.y = OFFSCREEN_POINTER;
};
const handleScroll = () => {
rect = container.getBoundingClientRect();
};
const handleVisibilityChange = () => {
syncLoop();
};
const resizeObserver = new ResizeObserver(scheduleResample);
resizeObserver.observe(container);
if (reducedMotion) {
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
fontsReady.then(() => {
if (cancelled) return;
hasAssembled = true;
sample();
drawStaticFrame();
});
return () => {
cancelled = true;
window.clearTimeout(resampleTimer);
resizeObserver.disconnect();
};
}
const intersectionObserver = new IntersectionObserver((entries) => {
intersecting = entries.some((entry) => entry.isIntersecting);
syncLoop();
});
intersectionObserver.observe(container);
window.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
document.documentElement.addEventListener(
"pointerleave",
handlePointerLeave,
);
window.addEventListener("scroll", handleScroll, { passive: true });
document.addEventListener("visibilitychange", handleVisibilityChange);
fontsReady.then(() => {
if (cancelled) return;
sample();
syncLoop();
});
return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
window.clearTimeout(resampleTimer);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener("pointermove", handlePointerMove);
document.documentElement.removeEventListener(
"pointerleave",
handlePointerLeave,
);
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [text]);
return (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}
+6 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
import { motion, useScroll, useSpring, useTransform } from "motion/react";
export default function ScrollProgress() {
const { scrollYProgress } = useScroll();
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
restDelta: 0.001,
});
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
const opacity = useTransform(
scrollYProgress,
[0, 0.02, 0.98, 1],
[0, 1, 1, 0],
);
return (
<>
@@ -1,14 +1,14 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useTransform,
useSpring,
useScroll,
} from "framer-motion";
useSpring,
useTransform,
} from "motion/react";
import { useEffect, useRef, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
@@ -89,7 +89,9 @@ export default function ScrollVelocityText({
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
>
{text}
<span className="mx-8 inline-block text-[var(--slash)]">&#x2022;</span>
<span className="mx-8 inline-block text-[var(--slash)]">
&#x2022;
</span>
</span>
))}
</motion.div>
+4 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useRef, useEffect } from "react";
import { useEffect, useRef } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
@@ -84,7 +84,9 @@ export default function SectionDivider({
if (variant === "pulse") {
return (
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
<div
className={`flex items-center justify-center gap-2 py-8 ${className}`}
>
{[...Array(5)].map((_, i) => (
<div
key={i}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface TelemetryMetric {
label: string;
value: number;
unit: string;
precision?: number;
min?: number;
max?: number;
}
const METRICS_CONFIG: TelemetryMetric[] = [
{
label: "UPTIME",
value: 99.97,
unit: "%",
precision: 2,
min: 99.9,
max: 100,
},
{
label: "INTEGRAÇÕES",
value: 12,
unit: " ativas",
precision: 0,
min: 8,
max: 16,
},
{
label: "BUILDS",
value: 247,
unit: " /dia",
precision: 0,
min: 200,
max: 300,
},
{ label: "LATÊNCIA", value: 12, unit: " ms", precision: 0, min: 8, max: 28 },
];
const UPDATE_INTERVAL = 3000;
const springConfigs = [
{ stiffness: 300, damping: 30, mass: 0.8 },
{ stiffness: 200, damping: 25, mass: 1 },
{ stiffness: 150, damping: 20, mass: 1.2 },
{ stiffness: 150, damping: 20, mass: 1.2 },
];
export function TelemetryStrip({
className,
reducedMotion = false,
}: {
className?: string;
reducedMotion?: boolean;
}) {
const [_metrics, setMetrics] = useState<TelemetryMetric[]>(METRICS_CONFIG);
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const isHovering = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const metricValue0 = useSpring(
useMotionValue(METRICS_CONFIG[0].value),
springConfigs[0],
);
const metricValue1 = useSpring(
useMotionValue(METRICS_CONFIG[1].value),
springConfigs[1],
);
const metricValue2 = useSpring(
useMotionValue(METRICS_CONFIG[2].value),
springConfigs[2],
);
const metricValue3 = useSpring(
useMotionValue(METRICS_CONFIG[3].value),
springConfigs[3],
);
const metricValues = [metricValue0, metricValue1, metricValue2, metricValue3];
// Parallax sutil em px, sinal alternado por métrica — assinatura "reage ao ponteiro"
const pointerShiftX = useTransform(pointerX, [-1, 1], [-8, 8]);
const pointerShiftY = useTransform(pointerY, [-1, 1], [-5, 5]);
const pointerShiftXNeg = useTransform(pointerShiftX, (v) => -v);
const pointerShiftYNeg = useTransform(pointerShiftY, (v) => -v);
const generateVariation = useRef((metric: TelemetryMetric): number => {
const range =
(metric.max ?? metric.value * 1.1) - (metric.min ?? metric.value * 0.9);
const variation = (Math.random() - 0.5) * range * 0.15;
const newValue = metric.value + variation;
return Math.max(
metric.min ?? -Infinity,
Math.min(metric.max ?? Infinity, newValue),
);
});
const updateMetrics = useRef(() => {
setMetrics((prev) =>
prev.map((metric) => ({
...metric,
value: generateVariation.current(metric),
})),
);
});
useEffect(() => {
if (reducedMotion) return;
intervalRef.current = setInterval(updateMetrics.current, UPDATE_INTERVAL);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [reducedMotion]);
useEffect(() => {
const handlePointerMove = (e: MouseEvent) => {
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
pointerX.set(x);
pointerY.set(y);
};
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [pointerX, pointerY]);
return (
<section
className={cn(
"cinematic-glass border-[rgba(204,255,0,0.18)] bg-[rgba(10,10,10,0.82)] backdrop-blur-[24px] rounded-[var(--r-lg)] p-sp-4 lg:p-sp-5 grid grid-cols-2 gap-sp-4 lg:gap-sp-6",
className,
)}
aria-label="Telemetria operacional em tempo real"
onPointerEnter={() => {
isHovering.current = true;
}}
onPointerLeave={() => {
isHovering.current = false;
}}
>
{METRICS_CONFIG.map((metric, index) => (
<motion.div
key={metric.label}
className="relative flex flex-col items-start gap-1"
style={
reducedMotion
? undefined
: {
x: index % 2 === 0 ? pointerShiftX : pointerShiftXNeg,
y: index % 2 === 0 ? pointerShiftY : pointerShiftYNeg,
}
}
>
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.7)]">
{metric.label}
</div>
<div
className="whitespace-nowrap font-display font-extrabold text-[clamp(1.35rem,2.6vw,1.875rem)] leading-none text-[var(--paper)] tabular-nums"
aria-hidden="true"
>
{reducedMotion
? metric.value.toFixed(metric.precision ?? 0)
: metricValues[index].get().toFixed(metric.precision ?? 0)}
<span className="font-mono text-[11px] font-normal text-[rgba(244,242,237,.58)]">
{metric.unit}
</span>
</div>
<div
className="h-px w-full bg-[linear-gradient(90deg,var(--slash),transparent)] opacity-30"
style={{
transformOrigin: "left center",
transform: reducedMotion
? "scaleX(1)"
: `scaleX(${Math.max(0.15, Math.min(1, metric.value / (metric.max ?? 100)))})`,
}}
aria-hidden="true"
/>
</motion.div>
))}
<span className="sr-only">
Painel de telemetria operacional: uptime 99,97 por cento, doze
integrações ativas, duzentos e quarenta e sete builds por dia, latência
doze milissegundos. Valores decorativos simulando uma sala de controle
real.
</span>
</section>
);
}
+5 -5
View File
@@ -1,5 +1,5 @@
// src/components/ui/NumberIcons.tsx
import React from "react";
import type React from "react";
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
@@ -65,7 +65,7 @@ export const NumberOne = ({
<text
x="40"
y="54"
fontFamily="Syne, sans-serif"
fontFamily="Space Grotesk, sans-serif"
fontSize="40"
fontWeight="800"
textAnchor="middle"
@@ -94,7 +94,7 @@ export const NumberTwo = ({
<text
x="40"
y="54"
fontFamily="Syne, sans-serif"
fontFamily="Space Grotesk, sans-serif"
fontSize="40"
fontWeight="800"
textAnchor="middle"
@@ -123,7 +123,7 @@ export const NumberThree = ({
<text
x="40"
y="54"
fontFamily="Syne, sans-serif"
fontFamily="Space Grotesk, sans-serif"
fontSize="40"
fontWeight="800"
textAnchor="middle"
@@ -152,7 +152,7 @@ export const NumberFour = ({
<text
x="40"
y="54"
fontFamily="Syne, sans-serif"
fontFamily="Space Grotesk, sans-serif"
fontSize="40"
fontWeight="800"
textAnchor="middle"
+3 -5
View File
@@ -1,12 +1,13 @@
import type { Metadata } from "next";
import Link from "next/link";
import { safeJsonLd } from "@/lib/jsonld";
import { portfolio } from "@/lib/portfolio-data";
import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/curriculo`;
export const metadata: Metadata = {
title: `Currículo | Engenheiro da Computação e Desenvolvedor de Software em Garuva, Joinville e Curitiba | ${siteName}`,
title: "Currículo — Engenheiro e Desenvolvedor de Software",
description:
"Currículo de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba. Experiência em Next.js, TypeScript, Node.js, Rust, IoT e sistemas críticos.",
alternates: { canonical: pageUrl },
@@ -46,10 +47,7 @@ export default function CurriculoPage() {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<p className="ds-eyebrow">Currículo profissional</p>
+120 -31
View File
@@ -1,20 +1,20 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@config "../tailwind.config.js";
@theme {
--spacing-sp-1: var(--sp-1);
--spacing-sp-2: var(--sp-2);
--spacing-sp-3: var(--sp-3);
--spacing-sp-4: var(--sp-4);
--spacing-sp-5: var(--sp-5);
--spacing-sp-6: var(--sp-6);
--spacing-sp-8: var(--sp-8);
--spacing-sp-10: var(--sp-10);
--spacing-sp-12: var(--sp-12);
--spacing-sp-16: var(--sp-16);
--spacing-sp-20: var(--sp-20);
--spacing-sp-24: var(--sp-24);
/* Literal values only: var() indirection here stops Tailwind v4 from generating *-sp-* utilities. */
--spacing-sp-1: 4px;
--spacing-sp-2: 8px;
--spacing-sp-3: 12px;
--spacing-sp-4: 16px;
--spacing-sp-5: 20px;
--spacing-sp-6: 24px;
--spacing-sp-8: 32px;
--spacing-sp-10: 40px;
--spacing-sp-12: 48px;
--spacing-sp-16: 64px;
--spacing-sp-20: 80px;
--spacing-sp-24: 96px;
}
:root {
@@ -47,19 +47,22 @@
--r-md: 8px;
--r-lg: 16px;
--r-full: 999px;
--font-display: var(--font-syne), sans-serif;
--font-display: var(--font-space-grotesk), sans-serif;
--font-hero: var(--font-monument-extended), sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
/* Layered so Tailwind utilities (later layer) can override the zero-reset. */
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
html,
@@ -72,6 +75,79 @@ body {
html {
scroll-behavior: smooth;
scroll-padding-top: 68px;
}
html [id] {
scroll-margin-top: 68px;
}
.site-header-nav,
.site-header-cv,
.site-header-version {
display: none;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: flex;
}
.site-header-menu-toggle {
width: 32px;
height: 32px;
flex: 0 0 32px;
}
@media (min-width: 1025px) {
.site-header-nav {
display: flex;
}
.site-header-cv,
.site-header-version {
display: inline-flex;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: none;
}
}
.skip-link {
position: fixed;
top: 8px;
left: 16px;
z-index: 10000;
transform: translateY(-160%);
border: 1px solid var(--slash);
background: var(--ink);
color: var(--white);
padding: 10px 14px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
transition: transform 0.2s var(--ease-out);
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid var(--slash);
outline-offset: 3px;
}
:where(a, button, input, select, textarea):focus-visible {
outline: 2px solid var(--slash);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
.film-grain {
display: none;
}
}
body {
@@ -181,7 +257,7 @@ textarea {
.ds-section-title {
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 44px);
font-size: clamp(24px, 4vw, 44px);
line-height: 1.02;
font-weight: 800;
letter-spacing: -0.035em;
@@ -212,7 +288,6 @@ textarea {
.ds-panel {
border: 1px solid var(--border);
min-width: 0;
height: 100%;
}
.ds-card {
@@ -231,7 +306,7 @@ textarea {
.ds-card-title {
font-family: var(--font-display);
font-size: clamp(20px, 2.4vw, 30px);
font-size: clamp(16px, 2.4vw, 30px);
line-height: 1.08;
letter-spacing: -0.025em;
margin-top: var(--sp-4);
@@ -502,14 +577,29 @@ textarea {
*,
*::before,
*::after {
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
animation-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
animation-iteration-count: 1 !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component transition styles. */
transition-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override smooth scrolling. */
scroll-behavior: auto !important;
}
}
@media (max-width: 1024px) {
.site-header-nav,
.site-header-cv,
.site-header-version {
display: none;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: flex;
}
.ds-section-header {
flex-direction: column;
gap: var(--sp-4);
@@ -527,7 +617,11 @@ textarea {
}
.ds-section-title {
font-size: clamp(26px, 8vw, 34px);
font-size: clamp(24px, 8vw, 34px);
}
.ds-card-title {
font-size: clamp(16px, 5vw, 24px);
}
.ds-section-description,
@@ -727,11 +821,6 @@ textarea {
0 0 1px rgba(204, 255, 0, 0.1);
}
/* Smooth scroll overrides for cinematic feel */
html {
scroll-behavior: auto;
}
/* Cinematic section reveal transforms */
.cinematic-reveal {
will-change: transform, opacity;
+5 -5
View File
@@ -2,14 +2,14 @@ import { renderHook } from "@testing-library/react";
import { useReducedMotion } from "./useReducedMotion";
// Mock do Framer Motion
jest.mock("framer-motion", () => ({
jest.mock("motion/react", () => ({
useReducedMotion: jest.fn(),
}));
describe("useReducedMotion", () => {
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
const { useReducedMotion: mockUseReducedMotion } =
jest.requireMock("framer-motion");
jest.requireMock("motion/react");
mockUseReducedMotion.mockReturnValue(false);
const { result } = renderHook(() => useReducedMotion());
@@ -19,7 +19,7 @@ describe("useReducedMotion", () => {
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
const { useReducedMotion: mockUseReducedMotion } =
jest.requireMock("framer-motion");
jest.requireMock("motion/react");
mockUseReducedMotion.mockReturnValue(true);
const { result } = renderHook(() => useReducedMotion());
@@ -29,7 +29,7 @@ describe("useReducedMotion", () => {
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
const { useReducedMotion: mockUseReducedMotion } =
jest.requireMock("framer-motion");
jest.requireMock("motion/react");
mockUseReducedMotion.mockReturnValue(null);
const { result } = renderHook(() => useReducedMotion());
@@ -39,7 +39,7 @@ describe("useReducedMotion", () => {
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
const { useReducedMotion: mockUseReducedMotion } =
jest.requireMock("framer-motion");
jest.requireMock("motion/react");
mockUseReducedMotion.mockReturnValue(undefined);
const { result } = renderHook(() => useReducedMotion());
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
import { useReducedMotion as useFramerReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
/**
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
+1 -1
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useEffect, useRef } from "react";
export function useScrollReveal(options?: {
y?: number;
+31 -22
View File
@@ -1,26 +1,27 @@
import type { Metadata, Viewport } from "next";
import { Geist_Mono, Syne } from "next/font/google";
import { Geist_Mono, Space_Grotesk } from "next/font/google";
import localFont from "next/font/local";
import type { ReactNode } from "react";
import { safeJsonLd } from "@/lib/jsonld";
import { regionCities, siteUrl } from "@/lib/site-content";
import { getWhatsAppNumber } from "@/lib/whatsapp";
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import { RouteTransition } from "./components/RouteTransition";
import { AnimationProvider } from "./providers/AnimationProvider";
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
import "./globals.css";
const seoTitle =
"Engenheiro da Computação e Desenvolvedor de Software em Garuva, Joinville e Curitiba | Evandro Fonseca Junior";
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
const seoDescription =
"Evandro Fonseca Junior é engenheiro da computação, desenvolvedor de software e criação de sites para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Norte de Santa Catarina e Curitiba. Atua com Next.js, TypeScript, Rust, IoT, integração de sistemas e produtos críticos.";
const syne = Syne({
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
weight: ["400", "500", "600", "700"],
display: "swap",
variable: "--font-syne",
variable: "--font-space-grotesk",
});
const geistMono = Geist_Mono({
@@ -48,7 +49,10 @@ const monumentExtended = localFont({
});
export const metadata: Metadata = {
title: seoTitle,
title: {
default: defaultTitle,
template: "%s | Evandro Fonseca Junior",
},
description: seoDescription,
keywords: [
"desenvolvedor de software em Garuva",
@@ -92,7 +96,7 @@ export const metadata: Metadata = {
telephone: false,
},
openGraph: {
title: seoTitle,
title: defaultTitle,
description: seoDescription,
url: siteUrl,
siteName: "Evandro.dev.br",
@@ -109,7 +113,7 @@ export const metadata: Metadata = {
},
twitter: {
card: "summary_large_image",
title: seoTitle,
title: defaultTitle,
description: seoDescription,
creator: "@evandrodevbr",
images: ["/opengraph-image"],
@@ -209,7 +213,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
{
"@type": "ProfessionalService",
"@id": `${siteUrl}/#services`,
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Tecnica",
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Técnica",
url: siteUrl,
image: `${siteUrl}/opengraph-image`,
telephone: `+${getWhatsAppNumber()}`,
@@ -226,7 +230,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
longitude: -48.8467,
},
description:
"Servico de engenharia de software, criacao de sites, arquitetura de software, integracoes criticas e consultoria tecnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
"Serviço de engenharia de software, criação de sites, arquitetura de software, integrações críticas e consultoria técnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
areaServed: [
...regionCities.map((name) => ({ "@type": "City", name })),
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
@@ -249,18 +253,18 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
serviceType: [
"Engenharia de Software",
"Criação de Sites",
"Consultoria Tecnica",
"Consultoria Técnica",
"Arquitetura de Software",
"Integracao hardware + software",
"Integração hardware + software",
"Desenvolvimento Next.js",
"Desenvolvimento Tauri",
"Desenvolvimento Rust",
"IoT",
"Sistemas Criticos",
"Sistemas Críticos",
],
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Modelos de contratacao",
name: "Modelos de contratação",
itemListElement: [
{
"@type": "Offer",
@@ -273,7 +277,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Auditoria e diagnostico tecnico",
name: "Auditoria e diagnóstico técnico",
},
},
{
@@ -299,17 +303,22 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
return (
<html
lang="pt-BR"
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
>
<body>
<SmoothScrollProvider>
<AnimationProvider>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<a className="skip-link" href="#main-content">
Pular para o conteúdo
</a>
<Header />
<CinematicOverlays />
<RouteTransition>{children}</RouteTransition>
<RouteTransition>
<div id="main-content" tabIndex={-1}>
{children}
</div>
</RouteTransition>
<GoogleAnalytics />
</AnimationProvider>
</SmoothScrollProvider>
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next";
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
+1 -1
View File
@@ -1,5 +1,5 @@
import { ArrowLeft, Code2, Home } from "lucide-react";
import Link from "next/link";
import { Home, ArrowLeft, Code2 } from "lucide-react";
export default function NotFound() {
return (
+2 -1
View File
@@ -1,6 +1,7 @@
import { ImageResponse } from "next/og";
export const alt = "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
export const alt =
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
export const size = {
width: 1200,
height: 630,
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { createContext, type ReactNode, useContext } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { animationConfig } from "@/app/lib/animations/config";
+2 -6
View File
@@ -3,11 +3,7 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
export function SmoothScrollProvider({
children,
}: {
children: ReactNode;
}) {
export function SmoothScrollProvider({ children }: { children: ReactNode }) {
const teardownRef = useRef<() => void>(() => {});
useEffect(() => {
@@ -38,7 +34,7 @@ export function SmoothScrollProvider({
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
smoothWheel: true,
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next";
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
+104 -33
View File
@@ -1,6 +1,8 @@
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import {
getRelatedCases,
getServicePage,
@@ -9,8 +11,6 @@ import {
siteName,
siteUrl,
} from "@/lib/site-content";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
interface ServicePageProps {
params: Promise<{
@@ -30,7 +30,7 @@ export async function generateMetadata({
if (!service) {
return {
title: `Servico nao encontrado | ${siteName}`,
title: `Serviço não encontrado`,
};
}
@@ -102,13 +102,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
{
"@type": "ListItem",
position: 1,
name: "Inicio",
name: "Início",
item: siteUrl,
},
{
"@type": "ListItem",
position: 2,
name: "Servicos",
name: "Serviços",
item: `${siteUrl}/servicos`,
},
{
@@ -124,10 +124,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
@@ -141,10 +138,20 @@ export default async function ServicePage({ params }: ServicePageProps) {
{service.intro}
</p>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href={service.ctaHref} className="ds-link ds-link-accent" strength={0.25}>
<MagneticButton
as="a"
href={service.ctaHref}
className="ds-link ds-link-accent"
strength={0.25}
>
{service.ctaLabel}
</MagneticButton>
<MagneticButton as="a" href="/cases" className="ds-link ds-link-muted" strength={0.2}>
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver casos relacionados
</MagneticButton>
</div>
@@ -157,7 +164,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-grid-2">
<article className="ds-card">
<div className="ds-kicker">Para quem faz sentido</div>
<h2 className="ds-card-title">Contextos em que esse servico encaixa melhor</h2>
<h2 className="ds-card-title">
Contextos em que esse serviço encaixa melhor
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{service.audience.map((item) => (
<li key={item}>{item}</li>
@@ -167,7 +176,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
<article className="ds-card">
<div className="ds-kicker">O que muda</div>
<h2 className="ds-card-title">Resultados que eu busco destravar</h2>
<h2 className="ds-card-title">
Resultados que eu busco destravar
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{service.outcomes.map((item) => (
<li key={item}>{item}</li>
@@ -186,7 +197,10 @@ export default async function ServicePage({ params }: ServicePageProps) {
<h2 className="ds-card-title">Processo de trabalho</h2>
<ol className="list-none mt-sp-5 space-y-sp-4 text-[14px] leading-8 text-[var(--ink-80)]">
{service.process.map((step, index) => (
<li key={step} className="border-t border-[var(--border)] pt-sp-4 first:border-t-0 first:pt-0">
<li
key={step}
className="border-t border-[var(--border)] pt-sp-4 first:border-t-0 first:pt-0"
>
<span className="mr-sp-3 inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-[var(--border)] text-[11px] font-semibold text-[var(--meta)]">
{index + 1}
</span>
@@ -197,15 +211,22 @@ export default async function ServicePage({ params }: ServicePageProps) {
</article>
<article className="ds-card">
<div className="ds-kicker">Stack e repertorio</div>
<h2 className="ds-card-title">Tecnologias e capacidades envolvidas</h2>
<div className="ds-kicker">Stack e repertório</div>
<h2 className="ds-card-title">
Tecnologias e capacidades envolvidas
</h2>
<p className="ds-copy">
A stack entra como meio para resolver o problema certo, com criterio de manutencao,
observabilidade e evolucao do produto.
A stack entra como meio para resolver o problema certo, com
critério de manutenção, observabilidade e evolução do produto.
</p>
<ul className="ds-tags">
{service.stack.map((item) => (
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
<MagneticButton
key={item}
as="li"
className="ds-tag"
strength={0.15}
>
{item}
</MagneticButton>
))}
@@ -220,9 +241,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title">Projetos que reforcam esse tipo de entrega</h2>
<h2 className="ds-section-title">
Projetos que reforçam esse tipo de entrega
</h2>
<p className="ds-section-description">
Alguns exemplos de contexto tecnico em que esse servico se conecta a entregas reais.
Alguns exemplos de contexto técnico em que esse serviço se
conecta a entregas reais.
</p>
</div>
</div>
@@ -245,13 +269,23 @@ export default async function ServicePage({ params }: ServicePageProps) {
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl ? (
<a href={caseStudy.demoUrl} className="ds-link ds-link-accent" target="_blank" rel="noreferrer">
<a
href={caseStudy.demoUrl}
className="ds-link ds-link-accent"
target="_blank"
rel="noreferrer"
>
Abrir projeto
</a>
) : null}
{caseStudy.githubUrl ? (
<a href={caseStudy.githubUrl} className="ds-link ds-link-muted" target="_blank" rel="noreferrer">
Ver codigo
<a
href={caseStudy.githubUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
>
Ver código
</a>
) : null}
</div>
@@ -261,28 +295,65 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<section className="ds-section ds-section-dark">
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell">
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
<div className="ds-kicker">Proximo passo</div>
<div className="ds-kicker">Próximo passo</div>
<h2 className="ds-card-title text-white">
Se esse servico parece aderente ao seu contexto, a conversa inicial ja pode ser objetiva.
Se esse serviço parece aderente ao seu contexto, a conversa
inicial já pode ser objetiva.
</h2>
<p className="ds-copy !text-[rgba(255,255,255,0.76)]">
Traga o sistema, o gargalo e o objetivo de negocio. Eu estruturo a leitura tecnica e
indico o caminho mais seguro para avancar.
Traga o sistema, o gargalo e o objetivo de negócio. Eu estruturo a
leitura técnica e indico o caminho mais seguro para avançar.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href={service.ctaHref} className="ds-link ds-link-accent" strength={0.25}>
<MagneticButton
as="a"
href={service.ctaHref}
className="ds-link ds-link-accent"
strength={0.25}
>
{service.ctaLabel}
</MagneticButton>
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-muted" strength={0.2}>
Ver todos os servicos
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver todos os serviços
</MagneticButton>
</div>
</div>
</div>
</section>
{service.faq && service.faq.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de
cada projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{service.faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
) : null}
</main>
);
}
+30 -21
View File
@@ -1,20 +1,25 @@
import Link from "next/link";
import type { Metadata } from "next";
import { serviceHubLinks, servicePages, siteName, siteUrl } from "@/lib/site-content";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import {
serviceHubLinks,
servicePages,
siteName,
siteUrl,
} from "@/lib/site-content";
export const metadata: Metadata = {
title: `Servicos de Engenharia de Software e Consultoria Tecnica | ${siteName}`,
title: "Serviços de Engenharia de Software e Consultoria Técnica",
description:
"Servicos de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integracao hardware + software para produtos e sistemas criticos.",
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
alternates: {
canonical: `${siteUrl}/servicos`,
},
openGraph: {
title: `Servicos de Engenharia de Software e Consultoria Tecnica | ${siteName}`,
title: "Serviços de Engenharia de Software e Consultoria Técnica",
description:
"Servicos de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integracao hardware + software para produtos e sistemas criticos.",
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
url: `${siteUrl}/servicos`,
siteName,
type: "website",
@@ -28,10 +33,10 @@ export default function ServicesIndexPage() {
{
"@type": "CollectionPage",
"@id": `${siteUrl}/servicos#page`,
name: "Servicos de Engenharia de Software e Consultoria Tecnica",
name: "Serviços de Engenharia de Software e Consultoria Técnica",
url: `${siteUrl}/servicos`,
description:
"Hub de servicos para engenharia de software, consultoria tecnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
"Hub de serviços para engenharia de software, consultoria técnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
},
{
"@type": "ItemList",
@@ -45,11 +50,11 @@ export default function ServicesIndexPage() {
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Servicos",
name: "Serviços",
item: `${siteUrl}/servicos`,
},
],
@@ -59,22 +64,21 @@ export default function ServicesIndexPage() {
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<p className="ds-eyebrow">Hub de servicos</p>
<p className="ds-eyebrow">Hub de serviços</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Servicos de engenharia de software para contextos tecnicos de alta exigencia.
Serviços de engenharia de software para contextos técnicos de
alta exigência.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Organizei as paginas de servico por intencao real de compra: engenharia de software,
consultoria Next.js, Tauri e Rust, arquitetura de software e integracoes hardware + software.
Organizei as páginas de serviço por intenção real de compra:
engenharia de software, consultoria Next.js, Tauri e Rust,
arquitetura de software e integrações hardware + software.
</p>
</div>
</div>
@@ -85,12 +89,17 @@ export default function ServicesIndexPage() {
<div className="ds-grid-2">
{serviceHubLinks.map((service) => (
<article key={service.href} className="ds-card">
<div className="ds-kicker">Servico especializado</div>
<div className="ds-kicker">Serviço especializado</div>
<h2 className="ds-card-title">{service.label}</h2>
<p className="ds-copy">{service.description}</p>
<div className="mt-sp-6">
<MagneticButton as="a" href={service.href} className="ds-link ds-link-accent" strength={0.25}>
Abrir pagina
<MagneticButton
as="a"
href={service.href}
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir página
</MagneticButton>
</div>
</article>
+1 -1
View File
@@ -1,4 +1,4 @@
import { MetadataRoute } from "next";
import type { MetadataRoute } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
export default function sitemap(): MetadataRoute.Sitemap {
+2 -1
View File
@@ -1,7 +1,8 @@
"use client";
import React, { useRef, useEffect, useCallback, useMemo } from "react";
import { gsap } from "gsap";
import { InertiaPlugin } from "gsap/InertiaPlugin";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
gsap.registerPlugin(InertiaPlugin);
+3 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { Renderer, Program, Mesh, Color, Triangle } from "ogl";
import React, { useEffect, useRef, useMemo, useCallback } from "react";
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
type Vec2 = [number, number];
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface RotatingTextProps {
+2 -1
View File
@@ -1,5 +1,6 @@
"use client";
import React, { useRef, useEffect } from "react";
import type React from "react";
import { useEffect, useRef } from "react";
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
+5 -5
View File
@@ -1,15 +1,15 @@
"use client";
import { gsap } from "gsap";
import {
ElementType,
createElement,
type ElementType,
useCallback,
useEffect,
useMemo,
useRef,
useState,
createElement,
useMemo,
useCallback,
} from "react";
import { gsap } from "gsap";
interface TextTypeProps {
className?: string;
+3 -3
View File
@@ -1,15 +1,15 @@
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import type { MotionProps } from "motion/react";
import {
type MotionValue,
motion,
MotionValue,
useMotionValue,
useSpring,
useTransform,
} from "motion/react";
import type { MotionProps } from "motion/react";
import React, { PropsWithChildren, useRef } from "react";
import React, { type PropsWithChildren, useRef } from "react";
import { cn } from "@/lib/utils";
+3 -2
View File
@@ -1,8 +1,9 @@
"use client";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import React, { useEffect, useId, useRef, useState } from "react";
import type React from "react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/**
* DotPattern Component Props
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import createGlobe, { COBEOptions } from "cobe";
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
@@ -0,0 +1,70 @@
# Plano: frontend foundation e conversão
## Estado inicial
- Branch atual: `feature/portrait-cyber-effects`.
- Workspace limpo antes da implementação.
- `pnpm run build` era o baseline verde conhecido.
- Testes e Biome já possuíam falhas preexistentes; a validação distinguirá regressões dos problemas anteriores.
## Execução
### 1. Regressões primeiro
Arquivos:
- `app/components/Header.test.tsx`
- `app/components/Hero.test.tsx`
- `app/components/cinematic/MagneticButton.test.tsx`
Adicionar asserções para:
- links e atributos ARIA do header;
- download do CV em `/Profile.pdf`;
- `li` renderizado diretamente sob uma lista.
Verificação: rodar as suítes específicas e observar falhas antes da implementação.
### 2. Contratos compartilhados
Arquivo novo:
- `lib/site-navigation.ts`
Exportar `CV_URL` e os itens de navegação usados pelo header. O teste do Hero e o componente devem usar o mesmo contrato.
### 3. Navegação global
Arquivos:
- `app/layout.tsx`
- `app/components/Header.tsx`
- `app/components/cinematic/CinematicOverlays.tsx`
Adicionar header no layout, skip link, navegação desktop, disclosure mobile e remover o botão Home redundante dos overlays.
### 4. Correções de semântica e performance
Arquivos:
- `app/components/Hero.tsx`
- `app/components/cinematic/MagneticButton.tsx`
- `app/components/cinematic/FilmGrain.tsx`
- `app/globals.css`
Usar `CV_URL`, renderizar `li` sem wrapper inválido, respeitar `prefers-reduced-motion`/pointer coarse no grain e adicionar foco/scroll global.
### 5. Validação e revisão
Executar:
```bash
pnpm exec jest --runInBand app/components/Header.test.tsx app/components/Hero.test.tsx app/components/cinematic/MagneticButton.test.tsx
pnpm run test -- --runInBand
pnpm run lint
pnpm run build
pnpm run format
pnpm exec biome check --write app/components/Header.tsx app/components/Hero.tsx app/components/cinematic/MagneticButton.tsx app/components/cinematic/CinematicOverlays.tsx app/components/cinematic/FilmGrain.tsx app/layout.tsx lib/site-navigation.ts
```
Revisar `git diff --check`, `git diff --stat` e o diff completo. Não criar commit sem solicitação explícita.
@@ -0,0 +1,32 @@
# Design: frontend foundation and conversion pass
## Objetivo
Corrigir os pontos que impedem o portfólio de funcionar como produto: navegação global ausente, download de CV quebrado, foco de teclado inconsistente e efeitos cinematográficos executando fora do contexto necessário.
## Decisões
- Reutilizar o `Header` existente no layout global, sem adicionar dependências.
- Centralizar URLs e itens de navegação em `lib/site-navigation.ts` para evitar caminhos divergentes.
- Manter a identidade visual escura/cinematográfica, mas deixar o conteúdo principal e as ações acessíveis sem depender de animação.
- Usar um disclosure mobile simples e acessível: `aria-expanded`, `aria-controls`, `hidden` e fechamento por `Escape`.
- Fazer o `MagneticButton` renderizar `li` diretamente quando solicitado, eliminando `div > li` inválido.
- Limitar cursor customizado, grain e progresso de scroll à home; páginas internas continuam com navegação normal e menos trabalho de renderização.
- Adicionar skip link, `scroll-padding` e foco visível global.
## Não incluído neste lote
- Redesign completo da hero.
- Migração de animações ou troca de biblioteca.
- Alteração de backend, conteúdo editorial ou arquitetura de rotas.
- Correção geral dos 152 diagnósticos do Biome; apenas os arquivos tocados serão saneados.
## Critérios de aceite
1. O header aparece na home e nas rotas internas.
2. O menu mobile informa e controla seu estado, fecha com `Escape` e não deixa links ocultos no fluxo de tabulação.
3. Todos os CTAs de CV apontam para `/Profile.pdf`, arquivo existente.
4. Nenhum uso de `MagneticButton as="li"` produz um elemento intermediário inválido.
5. O alvo do skip link existe e elementos focáveis têm indicação visual clara.
6. Testes de regressão passam para header, CV e semântica de lista.
7. Build de produção continua passando.
+2
View File
@@ -3,6 +3,8 @@ import type { Config } from "jest";
const config: Config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
roots: ["<rootDir>/app", "<rootDir>/lib"],
modulePathIgnorePatterns: ["<rootDir>/.worktrees/"],
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
+61 -2
View File
@@ -1,6 +1,65 @@
import "@testing-library/jest-dom";
import "./__mocks__/resizeObserver";
// Mock IntersectionObserver if not available in JSDOM
if (typeof global.IntersectionObserver === "undefined") {
class IntersectionObserver {
root: Element | Document | null = null;
rootMargin: string = "";
thresholds: ReadonlyArray<number> = [];
private callback: IntersectionObserverCallback;
private elements: Set<Element> = new Set();
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
) {
this.callback = callback;
this.root = options?.root || null;
this.rootMargin = options?.rootMargin || "";
const threshold = options?.threshold ?? 0;
this.thresholds = Array.isArray(threshold) ? threshold : [threshold];
}
observe(target: Element) {
this.elements.add(target);
setTimeout(() => {
this.callback(
[
{
target,
isIntersecting: true,
intersectionRatio: 1,
boundingClientRect: target.getBoundingClientRect(),
intersectionRect: target.getBoundingClientRect(),
rootBounds:
this.root instanceof Element
? this.root.getBoundingClientRect()
: null,
time: Date.now(),
} as IntersectionObserverEntry,
],
this,
);
}, 0);
}
unobserve(target: Element) {
this.elements.delete(target);
}
disconnect() {
this.elements.clear();
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
global.IntersectionObserver =
IntersectionObserver as typeof global.IntersectionObserver;
}
// Mock PointerEvent if not available in JSDOM
if (typeof global.PointerEvent === "undefined") {
class PointerEvent extends MouseEvent {
@@ -24,8 +83,8 @@ Object.defineProperty(window, "matchMedia", {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
+5
View File
@@ -0,0 +1,5 @@
export function safeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e");
}
+224 -94
View File
@@ -27,6 +27,7 @@ export interface ServicePageContent {
ctaLabel: string;
ctaHref: string;
relatedCaseIds: string[];
faq?: { question: string; answer: string }[];
}
export interface CaseStudyContent {
@@ -45,8 +46,7 @@ export const servicePages: ServicePageContent[] = [
slug: "criacao-de-sites",
navLabel: "Criação de Sites",
eyebrow: "Presença digital",
seoTitle:
"Criação de Sites em Garuva, Joinville e Curitiba | Evandro Fonseca Junior",
seoTitle: "Criação de Sites em Garuva, Joinville e Curitiba",
metaDescription:
"Criação de sites institucionais e sistemas web para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul e Curitiba. Sites rápidos, responsivos e otimizados para SEO com Next.js.",
headline:
@@ -74,103 +74,167 @@ export const servicePages: ServicePageContent[] = [
ctaLabel: "Quero um site para minha empresa",
ctaHref: "/#contact",
relatedCaseIds: ["sc-plus", "nutriscan"],
faq: [
{
question: "Quanto custa um site profissional?",
answer:
"O custo de um site profissional depende do escopo: número de páginas, funcionalidades, integrações e exigências de SEO técnico e performance. Um site institucional enxuto custa menos do que um sistema web completo. Como desenvolvedor de sites em Joinville e região, eu prefiro definir o orçamento depois de entender o objetivo do projeto, em uma conversa inicial sem compromisso.",
},
{
question: "Site sob medida ou WordPress: qual escolher?",
answer:
"Escolha WordPress quando o conteúdo muda toda semana e alguém da equipe precisa publicar sem ajuda técnica; escolha um site sob medida quando performance, SEO técnico e evolução futura pesam mais. Sites feitos sob medida em Next.js carregam mais rápido, têm menos superfície de manutenção e escalam para sistema web sem migração. Para muitos negócios locais, o sob medida compensa pela velocidade e pela longevidade.",
},
{
question: "Você faz manutenção de site depois do lançamento?",
answer:
"Sim. Além do lançamento, eu estruturo projetos pensando na manutenção do site: atualizações de dependências, ajustes de conteúdo, monitoramento de performance e evolução incremental de funcionalidades. Um site profissional não termina na publicação; ele precisa continuar rápido, seguro e bem posicionado. Por isso a entrega inclui orientação sobre indexação no Google e uma base de código organizada, fácil de evoluir.",
},
{
question: "Você atende empresas só de Joinville ou de outras cidades?",
answer:
"Eu atendo empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Balneário Barra do Sul, Barra Velha e Curitiba, além de projetos remotos em todo o Brasil. O trabalho funciona igual nos dois formatos: conversa inicial, levantamento de escopo e entregas acompanhadas de perto. Para buscas locais, cada site já sai com SEO técnico pensado para a cidade e o segmento do cliente.",
},
],
},
{
slug: "engenharia-de-software",
navLabel: "Engenharia de Software",
eyebrow: "Servico principal",
seoTitle:
"Engenharia de Software para Produtos e Sistemas Criticos | Evandro Fonseca Junior",
eyebrow: "Serviço principal",
seoTitle: "Engenharia de Software para Produtos e Sistemas Críticos",
metaDescription:
"Engenharia de software para evolucao de produtos, integracoes complexas e sistemas criticos. Entrega ponta a ponta com foco em arquitetura clara, estabilidade e velocidade de execucao.",
"Engenharia de software para evolução de produtos, integrações complexas e sistemas críticos. Entrega ponta a ponta com foco em arquitetura clara, estabilidade e velocidade de execução.",
headline:
"Engenharia de software para evoluir produto sem aumentar o caos tecnico.",
"Engenharia de software para evoluir produto sem aumentar o caos técnico.",
intro:
"Atuo em discovery, arquitetura, implementacao e sustentacao de software para empresas que precisam entregar com previsibilidade, sem sacrificar clareza tecnica nem operacao.",
"Atuo em discovery, arquitetura, implementação e sustentação de software para empresas de Joinville, Curitiba e de todo o Brasil que precisam entregar com previsibilidade, sem sacrificar clareza técnica nem operação.",
audience: [
"Times que ja tem produto em operacao e precisam voltar a ganhar velocidade.",
"Empresas que sofrem com integracoes, retrabalho e backlog tecnico acumulado.",
"Operacoes em que confiabilidade e continuidade pesam mais do que velocidade teatral.",
"Times que já tem produto em operação e precisam voltar a ganhar velocidade.",
"Empresas que sofrem com integrações, retrabalho e backlog técnico acumulado.",
"Operações em que confiabilidade e continuidade pesam mais do que velocidade teatral.",
],
outcomes: [
"Arquitetura mais legivel para manter e escalar.",
"Arquitetura mais legível para manter e escalar.",
"Entrega incremental com menos retrabalho entre produto e engenharia.",
"Integracoes mais robustas entre sistemas, APIs e fluxos internos.",
"Base tecnica preparada para crescer sem depender de improviso constante.",
"Integrações mais robustas entre sistemas, APIs e fluxos internos.",
"Base técnica preparada para crescer sem depender de improviso constante.",
],
process: [
"Diagnostico do contexto tecnico, do risco e do gargalo principal.",
"Definicao do recorte de entrega com prioridades e trade-offs claros.",
"Implementacao com rastreabilidade, code review e criterio de producao.",
"Handoff ou continuidade com documentacao e proximo ciclo recomendado.",
"Diagnóstico do contexto técnico, do risco e do gargalo principal.",
"Definição do recorte de entrega com prioridades e trade-offs claros.",
"Implementação com rastreabilidade, code review e critério de produção.",
"Handoff ou continuidade com documentação e próximo ciclo recomendado.",
],
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
ctaLabel: "Agendar conversa sobre engenharia",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
faq: [
{
question:
"Como contratar um desenvolvedor para integração de sistemas?",
answer:
"Procure um desenvolvedor que comece pelo diagnóstico: entender os dois sistemas, os dados envolvidos e o impacto de falha antes de escrever código. Peça clareza sobre contratos de API, tratamento de erros e observabilidade, não apenas prazo. Na integração de sistemas, o critério principal é reduzir risco operacional; um profissional que explica trade-offs com honestidade costuma entregar com mais previsibilidade.",
},
{
question: "Quanto custa desenvolver um sistema sob medida?",
answer:
"O investimento em um sistema sob medida acompanha a complexidade: regras de negócio, integrações, volume de usuários e requisitos de confiabilidade. Antes de qualquer proposta, eu faço um diagnóstico do contexto para dimensionar o recorte certo e evitar pagar por complexidade desnecessária. Assim o orçamento reflete o problema real, e não uma estimativa genérica por hora ou por tela.",
},
{
question: "Você trabalha com sistemas legados?",
answer:
"Sim. Parte do trabalho de engenharia de software é justamente evoluir sistemas legados sem parar a operação: mapear acoplamentos, estabilizar pontos críticos e migrar aos poucos, com testes e rastreabilidade. Nem todo legado precisa ser reescrito; muitas vezes a decisão certa é isolar o que funciona e modernizar o que trava a entrega. O diagnóstico define esse recorte antes de qualquer refatoração.",
},
{
question: "Como funciona o início do trabalho com você?",
answer:
"Tudo começa com uma conversa objetiva sobre o contexto: o produto, o gargalo e o risco. Depois disso eu proponho um recorte de entrega com prioridades e trade-offs claros, e a implementação segue com code review e critério de produção. Você acompanha o progresso em ciclos curtos, com documentação e handoff estruturado ao final de cada etapa.",
},
],
},
{
slug: "consultoria-nextjs",
navLabel: "Consultoria Next.js",
eyebrow: "Stack especializada",
seoTitle:
"Consultoria Next.js para Produtos, SEO e Performance | Evandro Fonseca Junior",
seoTitle: "Consultoria Next.js para Produtos, SEO e Performance",
metaDescription:
"Consultoria Next.js para App Router, SEO tecnico, performance, arquitetura e evolucao de produto. Ideal para projetos que precisam escalar sem perder clareza e Core Web Vitals.",
"Consultoria Next.js para App Router, SEO técnico, performance, arquitetura e evolução de produto. Ideal para projetos que precisam escalar sem perder clareza e Core Web Vitals.",
headline:
"Consultoria Next.js para produto, performance e SEO tecnico sem gambiarra.",
"Consultoria Next.js para produto, performance e SEO técnico sem gambiarra.",
intro:
"Ajudo equipes a usar Next.js com mais criterio arquitetural, melhor separacao entre server e client, e foco real em performance, indexacao e experiencia de produto.",
"Ajudo equipes a usar Next.js com mais critério arquitetural, melhor separação entre server e client, e foco real em performance, indexação e experiência de produto.",
audience: [
"Produtos em Next.js que perderam clareza com o crescimento da base.",
"Sites e plataformas que dependem de SEO tecnico e performance para converter.",
"Sites e plataformas que dependem de SEO técnico e performance para converter.",
"Times que precisam migrar, estabilizar ou revisar arquitetura App Router.",
],
outcomes: [
"Estrutura mais consistente para App Router, Server Components e dados.",
"Melhoras praticas em title, metadata, schema, semantica e links internos.",
"Reducao de gargalos em renderizacao, bundle e fetch.",
"Base mais pronta para crescer sem regressao de SEO ou UX.",
"Melhoras práticas em title, metadata, schema, semântica e links internos.",
"Redução de gargalos em renderização, bundle e fetch.",
"Base mais pronta para crescer sem regressão de SEO ou UX.",
],
process: [
"Auditoria de arquitetura, rotas, metadata e performance.",
"Priorizacao das correcoes de maior impacto comercial e tecnico.",
"Implementacao ou acompanhamento do time interno nas partes criticas.",
"Validacao final com build, semantica e consistencia entre paginas.",
"Priorização das correções de maior impacto comercial e técnico.",
"Implementação ou acompanhamento do time interno nas partes críticas.",
"Validação final com build, semântica e consistência entre páginas.",
],
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO tecnico"],
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO técnico"],
ctaLabel: "Solicitar consultoria Next.js",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
faq: [
{
question: "Next.js vale a pena para site institucional?",
answer:
"Vale, principalmente quando o site institucional precisa competir por busca orgânica. Next.js entrega renderização no servidor, Core Web Vitals bons e controle fino de metadata e dados estruturados, fatores que pesam no ranking. Para um site simples demais, pode ser mais stack do que o necessário; para quem quer performance, SEO técnico e caminho para evoluir a plataforma, é uma escolha sólida.",
},
{
question: "Quando faz sentido contratar uma consultoria de Next.js?",
answer:
"Faz sentido quando o projeto cresceu e o time perdeu clareza: rotas confusas, mistura excessiva entre client e server, queda de performance ou regressão de SEO. Uma consultoria Next.js audita arquitetura e metadata, prioriza as correções de maior impacto e implementa junto com o time interno nas partes críticas. O retorno aparece em build mais previsível, indexação estável e menos retrabalho.",
},
{
question: "Quanto custa uma consultoria de Next.js?",
answer:
"Depende do formato de trabalho: uma auditoria pontual de arquitetura e performance tem peso diferente de um acompanhamento contínuo ou da implementação completa das correções. O que define o investimento é o tamanho da base, o número de rotas críticas e a urgência dos gargalos. Depois de uma leitura inicial do projeto, eu consigo propor um escopo com expectativa clara de esforço.",
},
{
question: "Você atende times remotos ou só presencial?",
answer:
"Sim, atendo equipes em Joinville, Curitiba e em qualquer cidade do Brasil, presencial ou remoto. A consultoria funciona bem à distância: auditoria documentada, sessões de alinhamento com o time e implementação guiada nas partes críticas. Para empresas da região Norte de Santa Catarina e de Curitiba, também há a opção de encontros presenciais nos momentos de diagnóstico e validação.",
},
],
},
{
slug: "tauri-rust",
navLabel: "Tauri + Rust",
eyebrow: "Desktop e performance",
seoTitle:
"Desenvolvimento Tauri e Rust para Apps Desktop | Evandro Fonseca Junior",
seoTitle: "Desenvolvimento Tauri e Rust para Apps Desktop",
metaDescription:
"Desenvolvimento com Tauri e Rust para apps desktop mais leves, seguros e eficientes. Consultoria tecnica para produtos que precisam de performance, integracao local e distribuicao confiavel.",
"Desenvolvimento com Tauri e Rust para apps desktop mais leves, seguros e eficientes. Consultoria técnica para produtos que precisam de performance, integração local e distribuição confiável.",
headline:
"Tauri e Rust para apps desktop mais leves, seguros e prontos para operar.",
intro:
"Uso Tauri e Rust quando o produto precisa de executavel desktop com footprint menor, melhor controle do ambiente local e mais previsibilidade do que stacks mais pesadas.",
"Uso Tauri e Rust quando o produto precisa de executável desktop com footprint menor, melhor controle do ambiente local e mais previsibilidade do que stacks mais pesadas.",
audience: [
"Produtos desktop que precisam integrar recursos do sistema operacional.",
"Ferramentas internas com demanda por distribuicao leve e manutencao simples.",
"Times avaliando migracao de stack desktop ou construindo um MVP tecnico robusto.",
"Ferramentas internas com demanda por distribuição leve e manutenção simples.",
"Times avaliando migração de stack desktop ou construindo um MVP técnico robusto.",
],
outcomes: [
"Aplicacoes desktop com melhor eficiencia de recursos.",
"Fluxos locais e integracoes nativas com criterio tecnico mais alto.",
"Arquitetura preparada para distribuicao, atualizacao e manutencao.",
"Menos atrito entre interface moderna e motor de execucao confiavel.",
"Aplicações desktop com melhor eficiência de recursos.",
"Fluxos locais e integrações nativas com critério técnico mais alto.",
"Arquitetura preparada para distribuição, atualização e manutenção.",
"Menos atrito entre interface moderna e motor de execução confiável.",
],
process: [
"Definicao da necessidade real de desktop e do papel de Rust no produto.",
"Desenho da arquitetura entre frontend, camada nativa e distribuicao.",
"Implementacao dos fluxos criticos com foco em confiabilidade e ergonomia.",
"Empacotamento, validacao e estrategia de evolucao do aplicativo.",
"Definição da necessidade real de desktop e do papel de Rust no produto.",
"Desenho da arquitetura entre frontend, camada nativa e distribuição.",
"Implementação dos fluxos críticos com foco em confiabilidade e ergonomia.",
"Empacotamento, validação e estratégia de evolução do aplicativo.",
],
stack: [
"Tauri",
@@ -182,85 +246,151 @@ export const servicePages: ServicePageContent[] = [
ctaLabel: "Conversar sobre Tauri e Rust",
ctaHref: "/#contact",
relatedCaseIds: ["gcc-installer"],
faq: [
{
question: "Vale a pena criar app desktop com Tauri e Rust?",
answer:
"Vale quando o aplicativo precisa ser leve, seguro e próximo do sistema operacional. Apps desktop em Tauri usam o WebView do próprio sistema em vez de embutir um navegador inteiro, o que reduz bastante o tamanho do executável e o consumo de memória. Rust entra onde há processamento local crítico. Se o produto é só um formulário em janela, talvez uma web app resolva.",
},
{
question: "Tauri ou Electron: qual escolher?",
answer:
"Prefira Tauri quando distribuição leve, segurança e consumo de recursos importam: o executável final é muito menor porque reaproveita o WebView do sistema. Electron ainda faz sentido quando o time já domina Node.js e precisa de ecossistema maduro. Em projetos novos que exigem integração nativa e desempenho, eu costumo recomendar Tauri com lógica crítica em Rust e interface web moderna.",
},
{
question: "Rust é difícil de manter para times pequenos?",
answer:
"Rust tem curva de aprendizado real, mas a manutenção tende a ficar mais fácil no longo prazo: o compilador elimina categorias inteiras de bugs antes da produção. Para times pequenos, a estratégia é usar Rust apenas na camada que justifica o investimento, como integração com o sistema, performance e segurança, e manter a interface em TypeScript. Assim a complexidade fica onde traz retorno.",
},
{
question:
"Você desenvolve apps desktop para empresas em Santa Catarina?",
answer:
"Sim. Eu desenvolvo aplicativos desktop para empresas em Joinville, Curitiba e em todo o Brasil, com atendimento presencial ou remoto. O fluxo cobre da arquitetura ao empacotamento: definição do papel de Rust, implementação dos fluxos críticos, estratégia de distribuição e atualização do executável. Ferramentas internas que precisam rodar em máquinas da operação são um caso muito comum nesse formato de trabalho.",
},
],
},
{
slug: "integracao-hardware-software-iot",
navLabel: "Hardware + Software + IoT",
eyebrow: "Produtos conectados",
seoTitle: "Integracao Hardware + Software e IoT | Evandro Fonseca Junior",
seoTitle: "Integração Hardware + Software e IoT",
metaDescription:
"Integracao hardware + software, IoT e fluxos conectados para produtos e operacoes criticas. Da coleta de dados ao painel, com APIs, telemetria e arquitetura de software confiavel.",
"Integração hardware + software, IoT e fluxos conectados para produtos e operações críticas. Da coleta de dados ao painel, com APIs, telemetria e arquitetura de software confiável.",
headline:
"Integracao hardware + software para produtos conectados que precisam operar de verdade.",
"Integração hardware + software para produtos conectados que precisam operar de verdade.",
intro:
"Atuo na camada que costuma quebrar primeiro: a fronteira entre dispositivo, backend, painel e operacao. Estruturo esses fluxos para que dados, comando e observabilidade funcionem como produto, nao como remendo.",
"Atuo na camada que costuma quebrar primeiro: a fronteira entre dispositivo, backend, painel e operação. Estruturo esses fluxos para que dados, comando e observabilidade funcionem como produto, não como remendo.",
audience: [
"Empresas com dispositivos, sensores ou equipamentos conectados ao software.",
"Operacoes que dependem de telemetria, dashboards e troca de dados em tempo real.",
"Times que precisam ligar produto digital a processos fisicos com confiabilidade.",
"Operações que dependem de telemetria, dashboards e troca de dados em tempo real.",
"Times que precisam ligar produto digital a processos físicos com confiabilidade.",
],
outcomes: [
"Fluxos mais robustos entre dispositivo, API, banco e interface.",
"Menos perda de contexto entre operacao fisica e produto digital.",
"Arquitetura com observabilidade e manutencao mais previsivel.",
"Base melhor para evoluir IA, automacao ou analytics em cima dos dados.",
"Menos perda de contexto entre operação física e produto digital.",
"Arquitetura com observabilidade e manutenção mais previsível.",
"Base melhor para evoluir IA, automação ou analytics em cima dos dados.",
],
process: [
"Mapeamento de sinal, eventos, integracoes e pontos de falha.",
"Definicao de contratos entre hardware, backend e interfaces.",
"Implementacao do fluxo com testes e tratamento de excecoes relevantes.",
"Entrega com documentacao de operacao, limites e proximo passo.",
"Mapeamento de sinal, eventos, integrações e pontos de falha.",
"Definição de contratos entre hardware, backend e interfaces.",
"Implementação do fluxo com testes e tratamento de exceções relevantes.",
"Entrega com documentação de operação, limites e próximo passo.",
],
stack: [
"IoT",
"WebSockets",
"APIs",
"Dashboards",
"Integracao de sistemas",
"Integração de sistemas",
],
ctaLabel: "Falar sobre integracao e IoT",
ctaLabel: "Falar sobre integração e IoT",
ctaHref: "/#contact",
relatedCaseIds: ["twins-lab"],
faq: [
{
question: "Como integrar equipamentos e sistemas legados?",
answer:
"Comece mapeando o que o equipamento realmente expõe: portas seriais, sinais digitais, arquivos, protocolos industriais ou APIs antigas. A partir desse inventário, eu desenho uma camada de integração com contratos claros entre hardware, backend e interfaces, com tratamento de falha e observabilidade desde o primeiro dia. Sistemas legados raramente precisam ser substituídos; quase sempre precisam ser conectados com segurança.",
},
{
question: "O que é integração hardware e software na prática?",
answer:
"É fazer o dado físico virar informação útil: o sensor registra, o backend valida e armazena, o painel mostra e o comando volta para o equipamento quando necessário. Na prática, isso envolve telemetria, WebSockets ou filas para tempo real, contratos de API e tratamento dos casos em que o dispositivo cai. Quando essa fronteira é bem desenhada, a operação física e o produto digital falam a mesma língua.",
},
{
question: "Vale a pena investir em IoT na indústria?",
answer:
"Vale quando existe dado físico hoje desperdiçado: leitura manual, planilha isolada, decisão por estimativa. Projetos de IoT industriais transformam esse dado em telemetria e painéis que sustentam decisão em tempo real, especialmente em parques fabris como o de Joinville e região. O investimento só compensa, porém, quando a integração é tratada como produto, com observabilidade e manutenção previstas, e não como remendo pontual.",
},
{
question: "Como funciona um projeto de integração com você?",
answer:
"O projeto começa com o mapeamento de sinal, eventos, integrações existentes e pontos de falha. Em seguida eu defino os contratos entre hardware, backend e interfaces, implemento o fluxo com testes e tratamento das exceções relevantes, e entrego com documentação de operação, limites conhecidos e próximo passo recomendado. Atendo indústrias e empresas de tecnologia em Joinville, Curitiba e remotamente.",
},
],
},
{
slug: "arquitetura-de-software",
navLabel: "Arquitetura de Software",
eyebrow: "Diagnostico e decisao",
seoTitle:
"Arquitetura de Software e Consultoria Tecnica | Evandro Fonseca Junior",
eyebrow: "Diagnóstico e decisão",
seoTitle: "Arquitetura de Software e Consultoria Técnica",
metaDescription:
"Arquitetura de software para reduzir complexidade, destravar evolucao e organizar integracoes criticas. Consultoria tecnica para produtos que precisam de direcao antes de mais codigo.",
"Arquitetura de software para reduzir complexidade, destravar evolução e organizar integrações críticas. Consultoria técnica para produtos que precisam de direção antes de mais código.",
headline:
"Arquitetura de software para times que precisam de clareza antes de acelerar.",
intro:
"Quando o problema nao e falta de codigo, mas excesso de acoplamento, retrabalho e decisao mal distribuida, a arquitetura certa devolve velocidade, leitura e confianca ao time.",
"Quando o problema não é falta de código, mas excesso de acoplamento, retrabalho e decisão mal distribuída, a arquitetura certa devolve velocidade, leitura e confiança ao time.",
audience: [
"Produtos que cresceram rapido e perderam legibilidade tecnica.",
"Times travados entre refatorar tudo ou seguir acumulando divida.",
"Operacoes com muitas dependencias, integracoes ou regras sensiveis.",
"Produtos que cresceram rápido e perderam legibilidade técnica.",
"Times travados entre refatorar tudo ou seguir acumulando dívida.",
"Operações com muitas dependências, integrações ou regras sensíveis.",
],
outcomes: [
"Mapa claro dos gargalos e do risco tecnico real.",
"Prioridades de arquitetura ligadas a impacto de negocio.",
"Plano executavel de refatoracao, estabilizacao ou reestruturacao.",
"Mais qualidade de decisao entre produto, engenharia e operacao.",
"Mapa claro dos gargalos e do risco técnico real.",
"Prioridades de arquitetura ligadas a impacto de negócio.",
"Plano executável de refatoração, estabilização ou reestruturação.",
"Mais qualidade de decisão entre produto, engenharia e operação.",
],
process: [
"Leitura da base, fluxos, integracoes e dores do time.",
"Diagnostico escrito com pontos de risco, oportunidades e trade-offs.",
"Proposta de arquitetura alvo e plano de transicao realista.",
"Acompanhamento da execucao ou handoff estruturado para o time interno.",
"Leitura da base, fluxos, integrações e dores do time.",
"Diagnóstico escrito com pontos de risco, oportunidades e trade-offs.",
"Proposta de arquitetura alvo e plano de transição realista.",
"Acompanhamento da execução ou handoff estruturado para o time interno.",
],
stack: [
"Arquitetura de software",
"APIs",
"Performance",
"Integracoes",
"Sistemas criticos",
"Integrações",
"Sistemas críticos",
],
ctaLabel: "Solicitar diagnostico arquitetural",
ctaLabel: "Solicitar diagnóstico arquitetural",
ctaHref: "/#contact",
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
faq: [
{
question: "Quando investir em arquitetura de software?",
answer:
"Invista quando o custo de mudança começou a crescer mais rápido que o valor entregue: toda feature demora, todo deploy assusta e a dívida técnica vira pauta constante. Arquitetura de software vale o investimento nesse ponto, porque organiza acoplamento, integrações e responsabilidades antes que o problema multiplique. Antes do primeiro produto validado, geralmente basta uma base simples e bem separada.",
},
{
question: "O que é dívida técnica e como resolver?",
answer:
"Dívida técnica é o acúmulo de decisões rápidas que hoje custam velocidade: código duplicado, acoplamento excessivo, falta de testes e integrações frágeis. Ela se resolve com diagnóstico antes de refatoração: mapear os gargalos reais, priorizar o que trava entrega e operação, e evoluir em incrementos seguros. Reescrever tudo raramente é a resposta; a maioria dos sistemas melhora com transição planejada.",
},
{
question: "Como funciona um diagnóstico de arquitetura?",
answer:
"Eu leio a base de código, os fluxos, as integrações e as dores do time, e devolvo um diagnóstico escrito com pontos de risco, oportunidades e trade-offs explícitos. A partir dele vem a proposta de arquitetura alvo e um plano de transição realista, priorizado por impacto de negócio. O formato funciona tanto para times internos quanto para fundadores técnicos conduzindo a própria evolução.",
},
{
question: "Você faz consultoria de arquitetura remota ou presencial?",
answer:
"Dos dois jeitos. Atendo empresas em Joinville, Curitiba e em todo o Brasil, com diagnóstico e acompanhamento funcionando bem à distância; encontros presenciais ficam para os momentos de maior densidade, como kickoff e validação. Como a entrega central é um diagnóstico escrito e um plano executável, o formato remoto não reduz a profundidade da análise nem a qualidade do handoff para o time.",
},
],
},
];
@@ -268,11 +398,11 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "nutriscan",
title: "NutriScan",
subtitle: "PWA com Next.js para analise nutricional e operacao offline",
subtitle: "PWA com Next.js para análise nutricional e operação offline",
summary:
"Projeto em Next.js 14 com App Router, historico de uso, scanner de codigo de barras e cache estrategico para reduzir dependencia de rede.",
"Projeto em Next.js 14 com App Router, histórico de uso, scanner de código de barras e cache estratégico para reduzir dependência de rede.",
impact:
"Bom exemplo de engenharia de software orientada a produto, performance e experiencia de uso em contexto real.",
"Bom exemplo de engenharia de software orientada a produto, performance e experiência de uso em contexto real.",
stack: ["Next.js 14", "TypeScript", "MongoDB", "PWA", "Tailwind CSS"],
demoUrl: "https://nutriscan.evandro.dev.br",
githubUrl: "https://github.com/evandrodevbr/nutriscan",
@@ -282,9 +412,9 @@ export const caseStudies: CaseStudyContent[] = [
title: "Twins Lab",
subtitle: "Plataforma IoT industrial com dados em tempo real",
summary:
"Produto com dashboards, autenticacao, WebSockets e camada de IA para monitoramento de dispositivos e operacao industrial conectada.",
"Produto com dashboards, autenticação, WebSockets e camada de IA para monitoramento de dispositivos e operação industrial conectada.",
impact:
"Case forte para integracao hardware + software, telemetria e sistemas criticos com atualizacao em tempo real.",
"Case forte para integração hardware + software, telemetria e sistemas críticos com atualização em tempo real.",
stack: ["Next.js", "TypeScript", "NextAuth", "WebSockets", "OpenRouter AI"],
demoUrl: "https://twins.evandro.dev.br",
githubUrl: "https://github.com/evandrodevbr/twins-lab",
@@ -292,11 +422,11 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "reddit-explorer",
title: "Reddit Posts Explorer",
subtitle: "Aplicacao orientada a dados com API, cache e busca",
subtitle: "Aplicação orientada a dados com API, cache e busca",
summary:
"Projeto em Astro e MongoDB para explorar conteudo com filtros, ordenacao e cache, mantendo boa experiencia em consultas frequentes.",
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
impact:
"Mostra capacidade de desenhar consumo de API, camada de persistencia e experiencia de busca sem sobrecarregar a aplicacao.",
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
githubUrl: "https://github.com/evandrodevbr/reddit-search",
},
@@ -305,9 +435,9 @@ export const caseStudies: CaseStudyContent[] = [
title: "SC Plus Aquicultura",
subtitle: "Site institucional em Next.js com foco em performance e SEO",
summary:
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura tecnica de indexacao.",
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
impact:
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO tecnico trabalham juntos.",
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
demoUrl: "https://scplus.evandro.dev.br",
githubUrl: "https://github.com/evandrodevbr/scplus",
@@ -316,11 +446,11 @@ export const caseStudies: CaseStudyContent[] = [
id: "gcc-installer",
title: "MinGW GCC Installer",
subtitle:
"Ferramenta desktop para automacao de ambiente de desenvolvimento",
"Ferramenta desktop para automação de ambiente de desenvolvimento",
summary:
"Aplicacao em Python para simplificar setup local no Windows, com download assincrono, checagem de compatibilidade e integracao com PATH.",
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
impact:
"Mostra familiaridade com software desktop, integracao com sistema operacional e automacao de setup tecnico.",
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
},
@@ -329,9 +459,9 @@ export const caseStudies: CaseStudyContent[] = [
title: "AS Serviços",
subtitle: "Site institucional multidisciplinar com cena 3D procedural",
summary:
"Site institucional em Next.js 16 com cena 3D procedural em Three.js: uma casa wireframe que se transforma em placa-mae conforme o scroll. Quatro areas de engenharia — civil, eletrica, eletronica/automacao e software — sob o mesmo teto.",
"Site institucional em Next.js 16 com cena 3D procedural em Three.js: uma casa wireframe que se transforma em placa-mãe conforme o scroll. Quatro áreas de engenharia — civil, elétrica, eletrônica/automação e software — sob o mesmo teto.",
impact:
"Demonstra engenharia de software aplicada a presenca digital: cena 3D gerada em runtime sem assets externos, animacao dirigida por scroll e codigo 100% tipado.",
"Demonstra engenharia de software aplicada a presença digital: cena 3D gerada em runtime sem assets externos, animação dirigida por scroll e código 100% tipado.",
stack: [
"Next.js 16",
"React 19",
+11
View File
@@ -0,0 +1,11 @@
export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
export const NAVIGATION_ITEMS = [
{ href: "/#home", label: "Home" },
{ href: "/cases", label: "Casos" },
{ href: "/servicos", label: "Serviços" },
{ href: "/curriculo", label: "Currículo" },
{ href: "/#about", label: "Sobre" },
{ href: "/#contact", label: "Contato" },
] as const;
+5 -1
View File
@@ -13,7 +13,6 @@ const nextConfig: NextConfig = {
root: process.cwd(),
},
// Headers de cache e segurança
async headers(): Promise<
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
@@ -35,6 +34,11 @@ const nextConfig: NextConfig = {
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Content-Security-Policy-Report-Only",
value:
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://analytics.google.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
},
];
// HSTS apenas em produção com HTTPS
-1
View File
@@ -20,7 +20,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cobe": "^0.6.4",
"framer-motion": "^12.23.12",
"gsap": "^3.13.0",
"lenis": "^1.3.18",
"lucide-react": "^0.542.0",
-3
View File
@@ -57,9 +57,6 @@ importers:
cobe:
specifier: ^0.6.4
version: 0.6.4
framer-motion:
specifier: ^12.23.12
version: 12.23.12([email protected]([email protected]))([email protected])
gsap:
specifier: ^3.13.0
version: 3.13.0
+1 -1
View File
@@ -44,7 +44,7 @@ module.exports = {
"border-light": "rgba(10,10,10,.06)",
},
fontFamily: {
display: ["Syne", "sans-serif"],
display: ["Space Grotesk", "sans-serif"],
mono: ["Geist Mono", "monospace"],
},
fontSize: {