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

+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"