Compare commits

...
Author SHA1 Message Date
Evandro Fonseca Junior dfcadc71fa wip: foto da pasta principal antes de trocar de branch
CI / verify (push) Failing after 5m29s
Estado do disco em 14/10/2026, sem filtrar. Inclui as copias .sync-conflict-* do Syncthing de 07/10 e os 15 arquivos que o Syncthing apagou; todo esse conteudo ja existia em outros commits. So havia fora do Git: AGENTS.md, CLAUDE.md e a alteracao em data/project-health.json.
2026-10-14 00:42:49 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 b2f3899dc2 fix(mobile): sem tremido nas transicoes com rolagem rapida no celular
CI / verify (push) Failing after 5m47s
A secao que sai fica presa com position: sticky (o compositor do navegador a
mantem em sincronia com a rolagem) em vez de ser deslocada pelo JavaScript.
No toque, a secao que entra sobe com o dedo: a trava de posicao pelo GSAP
chegava um quadro atrasada na rolagem nativa e tremia. Cada passagem mantem
um efeito proprio sem disputar a posicao. Folga de visibilidade cobre o
quadro de atraso. Posicoes das cenas somam alturas (sticky altera offsetTop).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 03:08:42 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 4c826e938f fix(ci): lint do Biome, Next 16.3.8 (RCE em next/og) e transicoes que se remedem
CI / verify (push) Failing after 5m43s
- Biome: formatacao, ordem de imports, max-width duplicado no cabecalho de
  secao, supressao justificada para nomes de view-transition, ajustes nos
  scripts de auditoria de SEO
- next 16.3.3 -> 16.3.8: GHSA-vcvr-r3jv-pc5j (execucao remota de codigo em
  next/og ImageResponse, usado nas imagens de compartilhamento)
- overrides de brace-expansion (GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p,
  GHSA-q2hr-2g5m-vwhr), dependencia de desenvolvimento via jest
- visibilidade das cenas le as posicoes a cada quadro e o GSAP remede as
  passagens quando a pagina muda de altura (fontes, imagens, Experiencia)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:38:41 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 6db10ffa67 Merge branch 'design/espaco-e-consistencia'
Hero com cena WebGL legivel (tela, integracoes, dados) e mergulho com legendas,
rolagem suave e transicoes de secao ligadas a rolagem (desktop e celular),
transicao entre paginas, remocao da secao do notebook, otimizacoes de
desempenho, SEO/AEO/GEO e prompts de logo.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:28:01 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 6f2008a209 feat(mobile): hero fixo com mergulho e legendas no celular e no tablet
CI / verify (push) Failing after 5m45s
Hero compacto no celular (numeros em uma linha; servicos e redes ficam no menu
e no rodape) para caber em uma tela e ficar fixo a partir de 700px de altura.
A camera calcula o enquadramento pela proporcao da tela: em pe, se afasta
para cada camada caber na largura. Legendas em tamanho de celular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:25:03 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 def1e22302 perf(home): transicoes de Qualidade so com translate/opacity e titulos por palavra em CSS
Medido com trace do Chromium (rasterizacao no mesmo trecho de rolagem):
IA->Qualidade 840->558 ms, dentro de Qualidade 34->18 ms, Qualidade->Sobre
819->643 ms. Recorte animado, escala de camadas grandes e o desenho do diagrama
a cada quadro saem; o diagrama se desenha uma vez em CSS. TextDiffuseReveal
deixa de animar cada letra com desfoque.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:19:48 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 44efcfcb20 refactor(home): remove a secao animada do notebook
O mergulho do hero termina direto em Projetos. As capturas de tela seguem nos
cards (ProjectCover). Remove componentes, testes e 670 linhas de CSS do notebook.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:12:08 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 92c602c0ad feat(hero): camadas reconheciveis (tela, integracoes, tabela de dados), etiquetas e legendas do mergulho
A cena passa de discos abstratos para formas que um leigo reconhece. A camera
enquadra cada camada enquanto a legenda em linguagem simples explica o que e,
e so entao atravessa para a proxima.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 01:06:40 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 a3a206fb7a fix(home): transicoes estaveis, diagrama desenhado na entrada, rolagem suave nos links e transicao entre paginas
- visibilidade de cada cena calculada pela posicao da rolagem: nada vaza antes
  da passagem nem aparece atras de transparencias depois
- sem scale em ancestrais do canvas do notebook (tela desalinhada)
- IA -> qualidade em 2D (abertura em portas); a rotacao 3D adiava a pintura
  do diagrama, que agora se desenha junto com a entrada
- iris com aceleracao gradual; hero para de desenhar ao fim do mergulho
- links internos rolam suavemente ate o fim da transicao do destino
- navegacao entre paginas com ViewTransition (app/template.tsx)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:50:57 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 df1c1888da feat(home): rolagem suave (Lenis) e seis transicoes de secao ligadas a rolagem
Cada passagem segura a secao que sai e a que entra na tela enquanto a
transicao acontece: dissolucao do hero no notebook, cartao, iris, corte
lateral, perspectiva, cortina diagonal e zoom. Medidas pelo layout
(offsetTop) para nao confundir os gatilhos com os transforms.
Remove letreiros e divisores entre secoes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:32:32 -03:00
Evandro Fonseca JuniorandClaude Opus 5.5 0ec0194441 feat(hero): campo de particulas WebGL com mergulho guiado pela rolagem e transicao de secoes em CSS
Tres camadas de pontos ligadas por colunas de dados, animadas no vertex shader;
o hero fica fixo em telas grandes enquanto a camera atravessa as camadas.
Secoes sobem como cartoes via animation-timeline: view() (sem JS).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 00:22:18 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 3d47fe9cc7 docs(seo): medicao antes e depois, achados fora do codigo e checagens de e-mail ofuscado e links do GitHub no auditor
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:05:32 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 03c6fc1f0e docs: prompts de logo para os sete projetos do portfolio
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:03:43 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 85dd326964 feat(seo): og:locale pt_BR em todas as paginas, titulos e descricoes mais curtos, Person com cargo e areas; remove alegacao de LCP do SC Plus
- openGraph.locale pt_BR nas 6 rotas que redefinem openGraph (so a home tinha)
- titulos: cases usam 'Case de Engenharia'; /curriculo, Tauri e Engenharia de Software mais curtos; descricao da home (193 -> 146) e de AS Servicos (184 -> 136)
- Person JSON-LD ganha jobTitle e knowsAbout (dados e texto ja visiveis no site)
- SC Plus: remove 'otimizada para Core Web Vitals' / LCP < 2,5 s; Lighthouse mobile local mediu LCP 3,7 s. Texto passa a afirmar so metadados e dados estruturados (verificados no site)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:02:35 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 7c03dece9b chore(seo): auditor repetivel de SEO/AEO/GEO (scripts/seo-audit.mjs e seo-compare.mjs)
Le o sitemap, baixa cada URL como um robo (HTML sem JS) e confere titulo, descricao, canonical, Open Graph, JSON-LD, titulos de secao, resposta direta cedo, acesso de robos de busca e de IA e links internos. Gera JSON para comparar antes e depois.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:02:35 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 02ecba73f8 feat(cases): Reddit Posts Explorer e MinGW GCC Installer com resposta, desafio, implementacao, limites e fontes fixadas em commit
- textos reescritos a partir do codigo e do README dos repositorios publicos (SHA ff76e08 e bab64fc); remove afirmacoes que o codigo nao sustenta (SSG, rate limiting/cache, busca em tempo real, download assincrono, monitoramento)
- public/portfolio-data.json alinhado (descricao, tecnologias, recursos e selos)
- testes: cinco cases detalhados, limite sourced + escopo editorial, guardas contra afirmacoes sem fonte

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-05 00:00:42 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2c032b68af feat(as-servicos): remove o link do site e cita a participacao no projeto
- demoUrl de AS Servicos passa a null (so o codigo no GitHub); teamSize deixa de ser 'Individual'
- novo campo opcional role: 'Desenvolvimento da primeira landing page do site, com animacoes 3D, e de um painel para controle de projetos'
- role aparece no card de projeto e no topo do case

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:54:43 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 4db4b1f71c fix(a11y): nome acessivel dos botoes do notebook inclui o numero visivel (WCAG 2.5.3)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:51:07 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 14849a4a4d fix(footer): area de toque de 44px no e-mail direto
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:48:19 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 23aeaa2fda feat(hero): hero redesenhada, titulo visivel no HTML (LCP) e cena 3D so depois do idle
- titulo fluido (clamp ate 88px) com destaque em 'sob medida', eyebrow mono, seta no CTA e brilho de fundo
- numeros reais na hero (anos de experiencia, projetos publicados, demos online) calculados em app/page.tsx a partir dos dados do portfolio
- remove o opacity:0 inicial do motion: o texto nasce visivel; a entrada anima so a posicao (CSS)
- HeroSystem monta a cena Three.js via requestIdleCallback (timeout 2,5s), depois de pintar e hidratar o essencial
- capas decorativas (sem captura) escondidas no celular

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:48:19 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 35a950a9b1 refactor(footer): remove o convite de contato duplicado (fica a secao Contato acima) e mantem o e-mail direto
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:45 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2a428ec7a2 feat(mobile): espacamento menor, rotulos legiveis e botao de analytics compacto no celular
- .ds-section usa clamp(52px, 13vw, 64px) em vez de 72px; header de secao com menos margem
- eyebrow/kicker 11px e tags/links 12px no celular (antes 9-11px)
- botao persistente de analytics mostra 'Cookies' abaixo de 640px; o nome acessivel continua 'Preferencias de analytics'

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:45 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2ffe718fac feat(typography): texto corrido em sans 16px; mono so em rotulos, tags e metadados
- --font-body (Space Grotesk) aplicado a p/li/dd de main e footer com :where() (especificidade zero)
- rotulos (.ds-eyebrow, .ds-kicker, .ds-tag, .ds-metric, .quality-*) seguem em Geist Mono
- .ds-copy 16px/1.6, descricoes e listas 15px/1.7 (celular deixa de usar 13px)
- cards de servicos (WorkModalities) em sans

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:44:00 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 2b5b4a2267 feat(experience): skills e experiencia visiveis (aberta em telas largas) e link Experiencia no menu
- ExperienceDisclosure abre a gaveta em >=1024px e quando o link aponta para #experience/#skills; no celular segue recolhida
- novo item Experiencia em NAVIGATION_ITEMS (desktop e menu mobile); testes do Header atualizados

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:42:27 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d5f04162a8 feat(projects): capa nos cards de projeto (captura real ou painel gerado em codigo)
- ProjectCover usa as capturas ja publicadas (next/image, webp/avif) e, sem captura, um painel com monograma e stack
- .ds-cover mantem proporcao 16:10 e nao encolhe no card flex

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:40:25 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 e008a7c241 feat(design): botoes .ds-link com 44px de altura minima e cantos retos
- .ds-link centraliza o conteudo e garante min-height 44px (alvo de toque), igual em todas as 25 ocorrencias
- remove rounded-sm do CTA do Footer e das abas do HeroSystem para seguir o resto do site (cantos retos)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:28:17 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5.5 d8b59b03f2 feat(design): container e tipografia fluidos, cabecalho de secao em duas colunas e grade de projetos automatica
- tokens --shell, --fs-section-title e --fs-lead: iguais ao desenho atual ate ~1440px e crescem em telas grandes
- .ds-section-header vira container; acima de 880px o titulo fica a esquerda e a descricao a direita (aninhamento nativo + container query)
- .ds-grid-fluid: 2 colunas em notebook e 3 em tela grande, sem breakpoint (usada em Projects)
- Hero acompanha a largura util em telas grandes

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:26:39 -03:00
Evandro Fonseca Junior 229280d519 fix(projects): reveal individual cards on small screens 2026-09-13 13:44:04 -03:00
Evandro Fonseca Junior 673f3c1843 fix(ci): generate route types before typechecking a fresh checkout 2026-09-13 04:37:45 -03:00
Evandro Fonseca Junior b181216a35 fix(nav): hand scroll restoration back to the browser after arrival 2026-09-13 04:35:00 -03:00
Evandro Fonseca Junior 9aff4d1035 feat: rebuild landing experience, content and analytics 2026-09-13 04:30:58 -03:00
Evandro Fonseca Junior d24ee12299 feat(brand): apply logos, icons and social sharing cards 2026-09-13 04:19:02 -03:00
Evandro Fonseca Junior 8b12e51bc5 feat: refine portfolio showcase, services and quality lab
CI / verify (push) Failing after 5m9s
Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
2026-09-05 14:20:54 -03:00
Evandro Fonseca Junior e2b20bc699 test: align loader with unavailable demo 2026-09-04 22:08:04 -03:00
Evandro Fonseca Junior 0ae5d2f784 fix: remove stale Twins Lab demo link 2026-09-04 22:07:14 -03:00
Evandro Fonseca Junior a2992ca41e fix: hide unavailable Twins Lab demo 2026-09-04 22:04:55 -03:00
Evandro Fonseca Junior bea235d6fd fix: preserve safe motion and consent failures 2026-09-04 22:02:00 -03:00
Evandro Fonseca Junior a4e5e5c048 fix: restore portfolio loader and safe consent revocation 2026-09-04 21:40:35 -03:00
Evandro Fonseca Junior bff3e4d735 docs: add SEO operations runbook 2026-09-04 20:13:22 -03:00
Evandro Fonseca Junior f83c5e1ada feat: publish privacy and consent links 2026-09-04 20:04:27 -03:00
Evandro Fonseca Junior a9a35cf936 feat: add static social image cards 2026-09-04 19:50:23 -03:00
Evandro Fonseca Junior 4a4b41613a fix: complete dynamic social image metadata 2026-09-04 19:43:55 -03:00
Evandro Fonseca Junior 62bcfbd8fa feat: harden portfolio and add SEO foundations 2026-09-04 19:25:30 -03:00
Evandro Fonseca Junior b079afdd73 feat(story): build procedural notebook animation 2026-09-01 04:35:11 -03:00
Evandro Fonseca Junior f3ed9da777 fix(story): harden notebook interaction and fallback 2026-08-31 01:28:43 -03:00
Evandro Fonseca Junior bb4bf8e7e2 fix: descartar geometrias clonadas do notebook 2026-08-31 00:18:48 -03:00
Evandro Fonseca Junior 1c1a6d5c6d refactor: substituir celular e filmstrip por notebook 2026-08-31 00:14:54 -03:00
Evandro Fonseca Junior d768dfa791 docs: adicionar plano do notebook story 2026-08-31 00:14:45 -03:00
Evandro Fonseca Junior b4a2b81d55 feat: adicionar browser e fallback do notebook 2026-08-30 23:42:31 -03:00
Evandro Fonseca Junior c66ff7fde6 style: format notebook contracts 2026-08-30 23:42:26 -03:00
Evandro Fonseca Junior 8b858528f8 feat: definir contratos do notebook story 2026-08-30 23:35:44 -03:00
Evandro Fonseca Junior d89dde21ee feat: adicionar asset laptop cc0 para notebook story 2026-08-30 23:31:58 -03:00
Evandro Fonseca Junior 370cc34e16 docs: plano de implementacao do notebook story 2026-08-30 23:24:48 -03:00
Evandro Fonseca Junior 7bbdfd9daf docs: spec notebook story interativo 2026-08-30 23:19:09 -03:00
Evandro Fonseca Junior a3d080d4d0 fix(story): filmstrip de prints com sticky+scrub dedado e ReelDirector pos-hidratacao sem mutation no DOM 2026-08-30 21:04:11 -03:00
Evandro Fonseca Junior ecadb7c540 feat(story): celular CC0 com site vivo na tela, ReelDirector scrub em todas as seções e filmstrip pinado de prints reais 2026-08-30 20:00:56 -03:00
Evandro Fonseca Junior 8be2ba572d feat(story): reel scrollytelling scrubbed por scroll com palco WebGL 3D 2026-08-30 18:15:02 -03:00
Evandro Fonseca Junior 52960313f0 fix(hero): starfield uniforme com grade estratificada + tags mobile sem quebra interna 2026-08-30 18:15:02 -03:00
Evandro Fonseca Junior 957c58b69a fix: starfield visibility on load and WebGL fail-soft guard
- ParticleBuildText: self-scheduling rAF loop (previous one-shot tick left the canvas blank until scroll), deterministic sampleUntilBuilt warm-up, ambient star layer spread across the whole card with twinkle, random drift and a birth/death lifecycle (4-10s TTL with 800ms fades and respawn)
- ControlRoomGlobe: fail-soft WebGL guard (webgl2/webgl check + try/catch around createGlobe) so an unavailable or poisoned context renders the fallback instead of crashing the hero tree
2026-08-27 19:29:38 -03:00
Evandro Fonseca Junior b791b7de9e 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).
2026-08-27 10:55:21 -03:00
Evandro Fonseca Junior 0920370f30 chore: ignore local worktrees 2026-08-08 20:00:19 -03:00
Evandro Fonseca Junior cdde81eb6e fix: stabilize reveals and update contact email 2026-08-08 19:42:40 -03:00
Evandro Fonseca Junior aad56ad885 feat: add AS Servicos site (asservicos.com) to portfolio 2026-08-05 22:38:27 -03:00
Evandro Fonseca JuniorandClaude Sonnet 5 d76bbf5974 feat: revamp portfolio with case studies, curriculo page, and local SEO
Adds dynamic case study and curriculo routes, project health monitoring
for live demo status, and strengthens local SEO (structured data,
service-area targeting for Garuva/Joinville/Curitiba, a dedicated
"criação de sites" service page) so search results better match the
professional title (Engenheiro da Computação) and experience (5 anos).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-15 01:31:46 -03:00
Evandro Fonseca Junior 9236fdd685 feat: add randomized portfolio hero variants 2026-07-10 02:46:42 -03:00
Evandro Fonseca Junior 812499b318 merge: develop → main 2026-03-18 07:19:10 -03:00
226 changed files with 20450 additions and 12141 deletions

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
name: CI
"on":
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
+18
View File
@@ -39,3 +39,21 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# local agent/editor state
/.augment/
.opencode/
.claude/settings.local.json
.superpowers/
/.worktrees/
/.impeccable/
# generated analysis artifacts
/graphify-out/
*.graphify.json
*.analysis.json
*.tmp.json
*.debug.json
.omo/
.playwright-mcp/
+21
View File
@@ -0,0 +1,21 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+6
View File
@@ -0,0 +1,6 @@
# Credits / Third-party assets
## Project screenshots
- `public/assets/prints/*.png` are captures taken with headless Chrome from
production deployments owned by the site author (evandro.dev.br), used as
on-screen content inside the notebook browser surface.
+121 -21
View File
@@ -1,36 +1,136 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
<div align="center">
## Getting Started
# Evandro.dev.br: portfólio
First, run the development server:
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
</div>
## Requisitos
- Node.js `22.x`
- pnpm `9.15.9`, fixado em `package.json#packageManager`
O projeto usa Next.js `16.3.0`. Essa versão é intencional: `16.2.11` ainda mantinha três advisories transitivos de severidade alta; `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias atuais sem vulnerabilidades high.
## Início rápido
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
corepack [email protected] build
corepack [email protected] start
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Scripts
## Learn More
| Comando | Comportamento |
| --- | --- |
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack. |
| `pnpm build` | Gera o build de produção com Turbopack. |
| `pnpm start` | Serve um build já gerado. |
| `pnpm lint` | Executa `biome check`. |
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos. |
| `pnpm format` | Formata o repositório com Biome. |
| `pnpm test` | Executa a suíte Jest. |
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
To learn more about Next.js, take a look at the following resources:
## Stack
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
| Área | Pacotes usados |
| --- | --- |
| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
| Movimento | Motion 12 e GSAP 3. |
| 3D e gráficos | Three.js, React Three Fiber, Drei e Cobe. |
| Interface | Lucide React. |
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
## Deploy on Vercel
## Rotas
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
| Rota | Conteúdo |
| --- | --- |
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. |
| `/cases` | Índice dos seis cases. |
| `/cases/nutriscan` | Case NutriScan. |
| `/cases/twins-lab` | Case Twins Lab. |
| `/cases/reddit-explorer` | Case Reddit Posts Explorer. |
| `/cases/sc-plus` | Case SC Plus Aquicultura. |
| `/cases/gcc-installer` | Case MinGW GCC Installer. |
| `/cases/as-servicos` | Case AS Serviços. |
| `/servicos` | Índice dos seis serviços, com explicações de uso e acesso ao detalhamento técnico. |
| `/servicos/criacao-de-sites` | Criação de sites. |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto. |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT. |
| `/servicos/arquitetura-de-software` | Arquitetura de software. |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a página 404 do projeto. Não há API route ativa.
As páginas de serviços mantêm escopo, exemplos ilustrativos e etapas visíveis; tecnologias e decisões de implementação ficam em um `<details>` nativo. Sobre e Contato apontam para `/#about` e `/#contact`, com ajuste de chegada após a definição do layout do notebook.
## Comportamento da aplicação
### Conteúdo e project health
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`. A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não remove cards. Ele apenas controla o estado do CTA de cada demo: disponível, indisponível, desconhecido ou sem demo.
Em produção, a renderização só lê o snapshot versionado e não consulta demos externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. A atualização manual opcional é `pnpm check:projects` e grava o arquivo por rename atômico. O contrato completo está em [`docs/project-health.md`](docs/project-health.md).
### 3D progressivo e movimento reduzido
O hero 3D é carregado no cliente a partir de `1024px`, sem `prefers-reduced-motion: reduce` e sem ponteiro coarse. O notebook 3D exige mais de `1024px`, hover disponível e as mesmas condições de movimento e ponteiro. Nos demais casos, o notebook entrega a navegação HTML completa sem WebGL ou movimento. No desktop, tampa, tela, abas e cursor seguem o mesmo progresso de scroll; a cena renderiza sob demanda e pausa fora do viewport ou com a página oculta.
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
### Contato
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing preenchido; não existe backend de formulário. O rodapé mantém contato direto por e-mail.
### Consentimento de analytics
Google Analytics só é carregado depois de aceite explícito. A decisão fica em `evandro.analytics-consent.v1`; recusar não monta o loader. As preferências permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*` acessíveis.
## Estrutura
```text
app/ App Router, rotas e componentes da interface
lib/ Dados derivados, navegação, JSON-LD e project health
public/ Conteúdo JSON, PDF, fontes, imagens e modelo 3D
scripts/check-projects.mjs
Atualização manual do snapshot de demos
data/project-health.json
Última observação versionada das demos
docs/project-health.md Contrato operacional do snapshot
.github/workflows/ci.yml
Gate único de integração contínua
nixpacks.toml Build e start para Nixpacks
```
## CI e deploy
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. Ele instala Node 22 e pnpm conforme `packageManager`, restaura o cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest serial, build e auditorias de produção e desenvolvimento com limite high.
O Nixpacks usa `nodejs_22`, `pnpm-9_x`, `pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há etapa de deploy nem secret de CI neste repositório.
## Validação local
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] lint
corepack [email protected] typecheck
corepack [email protected] test -- --runInBand
corepack [email protected] build
corepack [email protected] audit --prod --audit-level=high
corepack [email protected] audit --dev --audit-level=high
```
-8
View File
@@ -1,8 +0,0 @@
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
module.exports = ResizeObserver;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+52
View File
@@ -0,0 +1,52 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
}
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
return {
id: caseStudy.id,
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: OG_CONTENT_TYPE,
};
}
export function generateImageMetadata({
params,
}: {
params: { slug?: string };
}) {
if (!params.slug) {
return caseStudies.map(getImageMetadata);
}
const caseStudy = getCaseStudy(params.slug);
if (!caseStudy) notFound();
return [getImageMetadata(caseStudy)];
}
export default async function Image({
params,
id,
}: {
params: Promise<{ slug: string }>;
id: Promise<string>;
}) {
const [{ slug }, imageId] = await Promise.all([params, id]);
const caseStudy = getCaseStudy(slug);
if (!caseStudy || imageId !== caseStudy.id) notFound();
return new ImageResponse(
<OgCard
eyebrow="Case de engenharia de software"
title={caseStudy.title}
description={caseStudy.summary}
/>,
OG_SIZE,
);
}
+324
View File
@@ -0,0 +1,324 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
const publicCases = [
{ id: "nutriscan", title: "NutriScan" },
{ id: "twins-lab", title: "Twins Lab" },
{ id: "reddit-explorer", title: "Reddit Posts Explorer" },
{ id: "sc-plus", title: "SC Plus Aquicultura" },
{ id: "gcc-installer", title: "MinGW GCC Installer" },
{ id: "as-servicos", title: "AS Serviços" },
] as const;
const publicCaseIds = publicCases.map(({ id }) => id);
function getCase(id: (typeof publicCases)[number]["id"]) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
);
if (scripts.length === 0) throw new Error("Missing JSON-LD");
return Array.from(scripts).flatMap((script) => {
if (!script.textContent) throw new Error("Empty JSON-LD");
const value = JSON.parse(script.textContent) as Record<string, unknown>;
return Array.isArray(value["@graph"]) ? value["@graph"] : [value];
});
}
function expectCaseCtas() {
expect(
screen.getByRole("link", {
name: "Avaliar perfil para contratação",
}),
).toHaveAttribute("href", "/curriculo");
expect(
screen.getByRole("link", {
name: "Solicitar consultoria técnica",
}),
).toHaveAttribute("href", "/#contact");
}
it("keeps the six public params and factual case metadata", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
const caseStudy = getCase("as-servicos");
const url = `${siteUrl}/cases/${caseStudy.id}`;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title: expect.stringContaining(caseStudy.title),
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
description: caseStudy.summary,
url,
},
twitter: {
title: caseStudy.title,
description: caseStudy.summary,
},
});
expect(metadata).not.toHaveProperty("keywords");
});
it("renders the authorized detailed case in order with traceable sources", async () => {
const caseStudy = getCase("as-servicos");
if (!caseStudy.detail) throw new Error("Missing detailed case content");
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(caseStudy.detail.answer.text);
expect(heading.nextElementSibling).toBe(answer);
const sectionNames = [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
];
const sectionHeadings = sectionNames.map((name) =>
screen.getByRole("heading", { level: 2, name }),
);
for (let index = 1; index < sectionHeadings.length; index += 1) {
expect(
sectionHeadings[index - 1].compareDocumentPosition(
sectionHeadings[index],
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
}
const challengeSection = screen
.getByRole("heading", { level: 2, name: "Desafio verificável" })
.closest("section");
if (!challengeSection) throw new Error("Missing challenge section");
const challengeText = within(challengeSection).getByText(
caseStudy.detail.challenge.text,
);
expect(challengeText).toBeVisible();
expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]);
for (const statement of caseStudy.detail.implementation) {
expect(screen.getByText(statement.text)).toBeVisible();
}
for (const boundary of caseStudy.detail.boundaries) {
const boundaryText = screen.getByText(boundary.text);
expect(boundaryText).toBeVisible();
expect(boundaryText.closest("a")).toBeNull();
}
const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ });
expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length);
for (const source of caseStudy.detail.sources) {
const link = screen.getByRole("link", { name: `Fonte: ${source.label}` });
expect(link).toHaveAttribute("href", source.url);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/));
expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//);
const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1];
if (!sha) throw new Error(`Missing source SHA for ${source.id}`);
expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible();
}
expectCaseCtas();
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService|LocalBusiness/,
);
const breadcrumb = graph[0] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
item: string;
}>;
};
const canonicalUrl = `${siteUrl}/cases/${caseStudy.id}`;
expect(breadcrumb.itemListElement).toEqual([
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: canonicalUrl,
},
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
current: link.getAttribute("aria-current"),
})),
).toEqual([
{ name: "Início", href: "/", current: null },
{ name: "Cases", href: "/cases", current: null },
{
name: caseStudy.title,
href: `/cases/${caseStudy.id}`,
current: "page",
},
]);
});
it("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("gcc-installer");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(screen.getByText(caseStudy.impact)).toBeVisible();
for (const technology of caseStudy.stack) {
expect(screen.getByText(technology)).toBeVisible();
}
expect(screen.queryByRole("link", { name: "Abrir demonstração" })).toBeNull();
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
);
for (const name of [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
]) {
expect(screen.queryByRole("heading", { name })).toBeNull();
}
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
const expectedServices = servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id))
.map(({ slug, navLabel }) => ({
name: `Ver serviço relacionado: ${navLabel}`,
href: `/servicos/${slug}`,
}))
.sort((a, b) => a.href.localeCompare(b.href));
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
});
expect(
relatedLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedServices);
expectCaseCtas();
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
"BreadcrumbList",
]);
});
it("keeps the hub cards, schema and distinct intents aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i);
const caseLinks = screen.getAllByRole("link", { name: "Ler case" });
const caseHrefs = caseLinks.map((link) => link.getAttribute("href"));
expect(caseHrefs).toHaveLength(6);
expect(new Set(caseHrefs).size).toBe(6);
expect([...caseHrefs].sort()).toEqual(
publicCaseIds.map((id) => `/cases/${id}`).sort(),
);
expectCaseCtas();
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Cases", "/cases"],
]);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
"ItemList",
"BreadcrumbList",
]);
const itemList = graph[1] as {
itemListElement: Array<{
"@type": string;
position: number;
name: string;
url: string;
}>;
};
const visibleItems = caseLinks.map((link, index) => {
const href = link.getAttribute("href");
const expected = publicCases.find(({ id }) => href === `/cases/${id}`);
if (!expected) throw new Error(`Unexpected visible case link ${href}`);
const card = link.closest("article");
if (!card) throw new Error(`Missing visible card for ${href}`);
expect(within(card).getByText(expected.title)).toBeVisible();
return {
"@type": "ListItem",
position: index + 1,
name: expected.title,
url: `${siteUrl}${href}`,
};
});
expect(itemList.itemListElement).toEqual(visibleItems);
});
+255
View File
@@ -0,0 +1,255 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import {
caseStudies,
getRelatedServices,
intentCtas,
siteName,
siteUrl,
} from "@/lib/site-content";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//;
function getSourceSha(url: string) {
return sourceShaPattern.exec(url)?.[1];
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
export async function generateMetadata({
params,
}: CasePageProps): Promise<Metadata> {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) return { title: "Case não encontrado" };
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: `${caseStudy.title} | Case de Engenharia de Software`,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
description: caseStudy.summary,
url,
siteName,
type: "article",
},
twitter: {
card: "summary_large_image",
title: caseStudy.title,
description: caseStudy.summary,
},
};
}
export default async function CasePage({ params }: CasePageProps) {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) notFound();
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
const relatedServices = getRelatedServices(caseStudy.id);
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", `${siteUrl}/cases`],
[caseStudy.title, pageUrl],
] as const),
],
};
return (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/cases">
Cases
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/cases/${caseStudy.id}`}
>
{caseStudy.title}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.detail?.answer.text ?? caseStudy.summary}
</p>
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
) : null}
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl ? (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir demonstração
</a>
) : null}
{caseStudy.githubUrl ? (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver código
</a>
) : null}
</div>
</div>
</section>
{caseStudy.detail ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
<section aria-labelledby="case-challenge">
<h2 id="case-challenge" className="ds-section-title">
Desafio verificável
</h2>
<p className="ds-section-description">
{caseStudy.detail.challenge.text}
</p>
</section>
<section aria-labelledby="case-implementation">
<h2 id="case-implementation" className="ds-section-title">
Decisões de implementação
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.implementation.map((statement) => (
<li key={statement.text}>{statement.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-boundaries">
<h2 id="case-boundaries" className="ds-section-title">
Limites desta evidência
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.boundaries.map((boundary) => (
<li key={boundary.text}>{boundary.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-sources">
<h2 id="case-sources" className="ds-section-title">
Fontes
</h2>
<ul className="mt-sp-5 space-y-sp-4">
{caseStudy.detail.sources.map((source) => {
const sha = getSourceSha(source.url);
return (
<li key={source.id}>
<a
className="ds-link ds-link-muted"
href={source.url}
target="_blank"
rel="noreferrer"
>
Fonte: {source.label}
</a>
{sha ? (
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
Commit: <code className="break-all">{sha}</code>
</span>
) : null}
</li>
);
})}
</ul>
</section>
</div>
</section>
) : null}
{relatedServices.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Serviços relacionados</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{relatedServices.map((service) => (
<Link
key={service.slug}
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
</div>
</section>
) : null}
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.hiring.href}
>
{intentCtas.hiring.label}
</Link>
<Link
className="ds-link ds-link-muted"
href={intentCtas.consulting.href}
>
{intentCtas.consulting.label}
</Link>
</div>
</div>
</section>
</article>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt =
"Cases de software, web e sistemas conectados — Evandro Fonseca Junior";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Trabalho verificável"
title="Cases de software, web e sistemas conectados"
description="Decisões, implementação, limites e fontes."
/>,
OG_SIZE,
);
}
+77 -36
View File
@@ -1,21 +1,26 @@
import type { Metadata } from "next";
import Link from "next/link";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases de Engenharia de Software, Next.js e IoT";
const pageDescription =
"Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados.";
export const metadata: Metadata = {
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.",
title: pageTitle,
description: pageDescription,
alternates: {
canonical: `${siteUrl}/cases`,
canonical: pageUrl,
},
openGraph: {
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
description:
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.",
url: `${siteUrl}/cases`,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
@@ -27,11 +32,10 @@ export default function CasesPage() {
"@graph": [
{
"@type": "CollectionPage",
"@id": `${siteUrl}/cases#page`,
name: "Cases de engenharia de software",
url: `${siteUrl}/cases`,
description:
"Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.",
"@id": `${pageUrl}#page`,
name: pageTitle,
url: pageUrl,
description: pageDescription,
},
{
"@type": "ItemList",
@@ -39,30 +43,49 @@ export default function CasesPage() {
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
url: caseStudy.demoUrl ?? caseStudy.githubUrl ?? `${siteUrl}/cases`,
url: `${pageUrl}/${caseStudy.id}`,
})),
},
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", pageUrl],
] as const),
],
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/cases"
>
Cases
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integracoes e produtos conectados.
Cases em software, Next.js, integrações e produtos conectados.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Esta pagina organiza projetos com leitura mais indexavel e comercial, conectando stack,
contexto tecnico e tipo de problema resolvido.
{pageDescription}
</p>
</div>
</div>
@@ -70,9 +93,9 @@ export default function CasesPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-grid-2 items-start">
{caseStudies.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<article key={caseStudy.id} className="ds-card !h-auto">
<div className="ds-kicker">{caseStudy.title}</div>
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
<p className="ds-copy">{caseStudy.summary}</p>
@@ -81,33 +104,51 @@ export default function CasesPage() {
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
<li key={item} className="ds-tag">
{item}
</MagneticButton>
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case
</Link>
{caseStudy.demoUrl ? (
<MagneticButton as="a" href={caseStudy.demoUrl} className="ds-link ds-link-accent" target="_blank" rel="noreferrer" strength={0.25}>
<MagneticButton
as="a"
href={caseStudy.demoUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
strength={0.25}
>
Abrir projeto
</MagneticButton>
) : null}
{caseStudy.githubUrl ? (
<MagneticButton as="a" href={caseStudy.githubUrl} className="ds-link ds-link-muted" target="_blank" rel="noreferrer" strength={0.2}>
Ver codigo
</MagneticButton>
) : null}
</div>
</article>
))}
</div>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.25}>
Ver servicos
<MagneticButton
as="a"
href={intentCtas.hiring.href}
className="ds-link ds-link-accent"
strength={0.25}
>
{intentCtas.hiring.label}
</MagneticButton>
<MagneticButton as="a" href="/#contact" className="ds-link ds-link-muted" strength={0.2}>
Ir para contato
<MagneticButton
as="a"
href={intentCtas.consulting.href}
className="ds-link ds-link-muted"
strength={0.2}
>
{intentCtas.consulting.label}
</MagneticButton>
</div>
</div>
+36 -29
View File
@@ -1,41 +1,48 @@
"use client";
"use client";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import profilePhoto from "@/public/edev.jpeg";
import { Section } from "./Section";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import profilePhoto from "@/public/edev.jpg";
import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
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 código ao sistema no ar",
content:
"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.",
},
];
@@ -80,18 +87,18 @@ 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">
<div className="mt-sp-4 ds-metric" data-reel="left">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
Garuva, SC | Remote worldwide
</div>
}
>
<motion.div
className="ds-grid-2"
className="ds-grid-2 items-start"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
@@ -113,7 +120,9 @@ export default function About() {
<div className="ds-kicker">{step.label}</div>
<DrawLine delay={0.03} />
<h3 className="ds-card-title">{step.title}</h3>
<p className="ds-copy">{step.content}</p>
<p className="ds-copy" data-reel="right">
{step.content}
</p>
</motion.article>
))}
</motion.div>
@@ -127,20 +136,20 @@ export default function About() {
>
<LazyRevealImage
src={profilePhoto}
alt="Retrato de Evandro"
className="mb-sp-6 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-cover"
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-contain"
/>
<div className="ds-kicker">Technical profile</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
<p className="ds-copy">
Sou engenheiro de software full-stack 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.
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
<p className="ds-copy" data-reel="top">
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.
</p>
<div className="mt-sp-6 space-y-sp-4">
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
key={point.title}
@@ -158,5 +167,3 @@ export default function About() {
</Section>
);
}
@@ -1,320 +0,0 @@
import { render } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
import fc from "fast-check";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
return <div {...props}>{children}</div>;
},
},
};
});
describe("Property 10: Alt Text for Images", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should have non-empty alt attributes for all company logo images", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image in the CompanyLogos component,
* it should have a non-empty alt attribute describing the company.
* This ensures accessibility for screen readers and assistive technologies.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should have descriptive alt text that includes company name", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should be descriptive
* and include identifying information about the company. This ensures
* meaningful accessibility information is provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all alt text contains "logo" or is descriptive
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
if (!alt) return false;
const altLower = alt.toLowerCase();
// Alt text should contain "logo" or be at least 5 characters (descriptive)
return altLower.includes("logo") || alt.trim().length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should maintain alt text consistency across multiple renders", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any number of component renders, the alt text for each
* company logo should remain consistent. This ensures stable accessibility
* information across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 2, max: 5 }), // Test 2-5 renders
fc.boolean(), // Random reduced motion preference
(numRenders, shouldReduce) => {
mockReducedMotion = shouldReduce;
let firstRenderAltTexts: string[] = [];
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const currentAltTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
if (i === 0) {
firstRenderAltTexts = currentAltTexts;
} else {
// Compare with first render
if (currentAltTexts.length !== firstRenderAltTexts.length) {
unmount();
return false;
}
// Check that alt texts match (accounting for duplicates in animated mode)
const expectedCount = shouldReduce ? 5 : 10;
if (currentAltTexts.length !== expectedCount) {
unmount();
return false;
}
}
unmount();
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should have unique alt text for each distinct company", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any set of company logos, each distinct company should have
* its own unique alt text. This ensures each company is properly identified
* for accessibility purposes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode to get unique logos
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// In static mode, we should have 5 unique alt texts
const uniqueAltTexts = new Set(altTexts);
// Should have 5 unique alt texts (one per company)
return uniqueAltTexts.size === 5 && altTexts.length === 5;
}),
{ numRuns: 100 },
);
});
it("should preserve alt text in both animated and static modes", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any reduced motion preference, all company logo images
* should maintain their alt text attributes. The animation mode should
* not affect accessibility attributes.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt attributes
const allHaveAlt = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// Verify correct number of images based on mode
const expectedCount = shouldReduce ? 5 : 10;
const hasCorrectCount = logoImages.length === expectedCount;
return allHaveAlt && hasCorrectCount;
},
),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only alt text", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should not be empty
* or consist only of whitespace. This ensures meaningful accessibility
* information is always provided.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Check that no image has empty or whitespace-only alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
// Alt must exist and not be empty after trimming
if (!alt || alt.trim().length === 0) {
return false;
}
// Alt should not be just whitespace
return alt !== alt.trim() ? false : true;
});
},
),
{ numRuns: 100 },
);
});
it("should have alt text that matches the expected company names", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image, the alt text should correspond to
* one of the expected company names (Apolo Energia, Julia Beauty Lashes,
* Innovo, TeraByteShop, Pichau). This ensures correct identification.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true; // Use static mode for clarity
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
const altTexts = Array.from(logoImages).map(
(img) => img.getAttribute("alt") || "",
);
// Expected company names in alt text
const expectedCompanies = [
"Apolo Energia",
"Julia Beauty Lashes",
"Innovo",
"TeraByteShop",
"Pichau",
];
// Check that each alt text contains one of the expected company names
return altTexts.every((alt) =>
expectedCompanies.some((company) => alt.includes(company)),
);
}),
{ numRuns: 100 },
);
});
it("should maintain alt text when images fail to load", () => {
/**
* **Validates: Requirements 6.2**
*
* Property: For any company logo image that fails to load, the alt text
* should still be present in the DOM. This ensures accessibility is
* maintained even when images are unavailable.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
// Verify all images have alt text before any error handling
const allHaveAltBeforeError = Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
// The alt attribute should be present regardless of load state
return allHaveAltBeforeError;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,344 +0,0 @@
import { render } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
import fc from "fast-check";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("Property 4: Reduced Motion Respect", () => {
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid layout when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, when useReducedMotion() returns true,
* the CompanyLogos component should render a static grid layout without motion
* animations. This ensures accessibility compliance with prefers-reduced-motion.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Check for animated motion.div with animation props
const animatedElements = container.querySelectorAll("[data-animate]");
// Check for static grid layout
const staticGrid = container.querySelector(".grid");
if (shouldReduce) {
// When reduced motion is enabled:
// - Should NOT have animated elements
// - Should have static grid layout
return animatedElements.length === 0 && staticGrid !== null;
} else {
// When reduced motion is disabled:
// - Should have animated elements
// - Should NOT have static grid layout
return animatedElements.length > 0 && staticGrid === null;
}
},
),
{ numRuns: 100 },
);
});
it("should exclude horizontal scroll animation when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any component render with reduced motion enabled, the horizontal
* scroll animation (x: [0, '-50%']) should be completely absent. This verifies
* that motion animations are properly disabled.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed
() => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for animation props
const animatedElements = container.querySelectorAll("[data-animate]");
let hasScrollAnimation = false;
animatedElements.forEach((element) => {
const animateValue = element.getAttribute("data-animate");
if (animateValue && animateValue !== "undefined") {
try {
const props = JSON.parse(animateValue);
// Check for x animation (horizontal scroll)
if (props.x !== undefined) {
hasScrollAnimation = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have scroll animation when reduced motion is enabled
return !hasScrollAnimation;
},
),
{ numRuns: 100 },
);
});
it("should display all company logos in both animated and static modes", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any reduced motion preference, all company logos should be
* displayed. The reduced motion preference should only affect the animation
* style, not the content visibility.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll('img[alt*="logo"]');
// Should have at least 5 logos (the original set)
// In animated mode, we have duplicates, so it will be 10
// In static mode, we have exactly 5
if (shouldReduce) {
return logoImages.length === 5;
} else {
return logoImages.length === 10; // Duplicated for infinite scroll
}
},
),
{ numRuns: 100 },
);
});
it("should maintain accessibility attributes regardless of reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3, 6.2**
*
* Property: For any reduced motion preference, all company logo images should
* maintain proper alt text attributes. Accessibility should not be compromised
* by the animation mode.
*/
fc.assert(
fc.property(
fc.boolean(), // Random reduced motion preference
(shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container } = render(<CompanyLogos />);
// Find all logo images
const logoImages = container.querySelectorAll("img");
// Verify we found images
if (logoImages.length === 0) {
return false;
}
// Check that all images have non-empty alt text
return Array.from(logoImages).every((img) => {
const alt = img.getAttribute("alt");
return alt !== null && alt.trim().length > 0;
});
},
),
{ numRuns: 100 },
);
});
it("should consistently apply reduced motion across multiple renders", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: For any number of component renders with reduced motion enabled,
* the static layout should be consistently rendered without animations.
* This verifies that the reduced motion behavior is stable across re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
mockReducedMotion = true;
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<CompanyLogos />);
// Check for animated elements
const animatedElements =
container.querySelectorAll("[data-animate]");
// Check for static grid
const staticGrid = container.querySelector(".grid");
// Should have no animations and should have static grid
if (animatedElements.length > 0 || staticGrid === null) {
allRendersValid = false;
}
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should use grid layout classes when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should use CSS grid
* layout classes (grid, grid-cols-*) to display logos in a static arrangement.
* This ensures proper visual presentation without animation.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for grid layout classes
const gridContainer = container.querySelector(".grid");
if (!gridContainer) {
return false;
}
// Verify grid has responsive column classes
const hasGridCols = gridContainer.className.includes("grid-cols-");
return hasGridCols;
}),
{ numRuns: 100 },
);
});
it("should not have infinite repeat transition when reduced motion is enabled", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: When reduced motion is enabled, the component should not have
* transition configurations with infinite repeat. This ensures no continuous
* motion occurs when users prefer reduced motion.
*/
fc.assert(
fc.property(fc.constant(null), () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Check for transition props
const elementsWithTransition =
container.querySelectorAll("[data-transition]");
let hasInfiniteRepeat = false;
elementsWithTransition.forEach((element) => {
const transitionValue = element.getAttribute("data-transition");
if (transitionValue && transitionValue !== "undefined") {
try {
const props = JSON.parse(transitionValue);
// Check for infinite repeat
if (props.repeat === Infinity || props.repeat === "Infinity") {
hasInfiniteRepeat = true;
}
} catch (e) {
// Skip invalid JSON
}
}
});
// Should not have infinite repeat when reduced motion is enabled
return !hasInfiniteRepeat;
}),
{ numRuns: 100 },
);
});
it("should switch between animated and static modes based on reduced motion preference", () => {
/**
* **Validates: Requirements 3.6, 6.3**
*
* Property: The component should render different layouts based on the reduced
* motion preference. When the preference changes, the component should adapt
* accordingly. This verifies the conditional rendering logic.
*/
fc.assert(
fc.property(
fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes
(preferenceSequence) => {
let allTransitionsValid = true;
preferenceSequence.forEach((shouldReduce) => {
mockReducedMotion = shouldReduce;
const { container, unmount } = render(<CompanyLogos />);
const hasAnimations =
container.querySelectorAll("[data-animate]").length > 0;
const hasStaticGrid = container.querySelector(".grid") !== null;
// Verify correct mode is rendered
if (shouldReduce) {
if (hasAnimations || !hasStaticGrid) {
allTransitionsValid = false;
}
} else {
if (!hasAnimations || hasStaticGrid) {
allTransitionsValid = false;
}
}
unmount();
});
return allTransitionsValid;
},
),
{ numRuns: 100 },
);
});
});
-503
View File
@@ -1,503 +0,0 @@
import { render, screen } from "@testing-library/react";
import CompanyLogos from "./CompanyLogos";
// Mock framer-motion with configurable useReducedMotion
let mockReducedMotion = false;
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => mockReducedMotion,
motion: {
div: ({ children, animate, transition, ...props }: any) => {
// Capture animation props for testing
const animationProps = {
"data-animate": JSON.stringify(animate),
"data-transition": JSON.stringify(transition),
};
return (
<div {...props} {...animationProps}>
{children}
</div>
);
},
},
};
});
describe("CompanyLogos Component - Logo Rendering", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should render all five company logos", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, logos are duplicated (10 total)
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll)
});
it("should render Apolo Energia logo", () => {
render(<CompanyLogos />);
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll
expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg");
});
it("should render Julia Beauty Lashes logo", () => {
render(<CompanyLogos />);
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
expect(juliaLogos.length).toBe(2);
expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg");
});
it("should render Innovo logo", () => {
render(<CompanyLogos />);
const innovoLogos = screen.getAllByAltText("Innovo logo");
expect(innovoLogos.length).toBe(2);
expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg");
});
it("should render TeraByteShop logo", () => {
render(<CompanyLogos />);
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
expect(terabyteLogos.length).toBe(2);
expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg");
});
it("should render Pichau logo", () => {
render(<CompanyLogos />);
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(pichauLogos.length).toBe(2);
expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg");
});
it("should have alt text for all logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
it("should duplicate logo array for seamless infinite scroll", () => {
const { container } = render(<CompanyLogos />);
// Each company should appear exactly twice
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
const innovoLogos = screen.getAllByAltText("Innovo logo");
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
const pichauLogos = screen.getAllByAltText("Pichau logo");
expect(apoloLogos.length).toBe(2);
expect(juliaLogos.length).toBe(2);
expect(innovoLogos.length).toBe(2);
expect(terabyteLogos.length).toBe(2);
expect(pichauLogos.length).toBe(2);
});
});
describe("CompanyLogos Component - Animation Configuration", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have horizontal scroll animation configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
expect(animatedElement).toBeInTheDocument();
const animateValue = animatedElement?.getAttribute("data-animate");
expect(animateValue).toBeTruthy();
const animateProps = JSON.parse(animateValue!);
expect(animateProps.x).toEqual([0, "-50%"]);
});
it("should have 30-second animation duration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
expect(animatedElement).toBeInTheDocument();
const transitionValue = animatedElement?.getAttribute("data-transition");
expect(transitionValue).toBeTruthy();
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.duration).toBe(30);
});
it("should have infinite repeat configuration", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
// JSON.stringify converts Infinity to null, so we check for null
expect(transitionProps.repeat).toBe(null);
});
it("should use linear easing for smooth animation", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-transition]");
const transitionValue = animatedElement?.getAttribute("data-transition");
const transitionProps = JSON.parse(transitionValue!);
expect(transitionProps.ease).toBe("linear");
});
it("should have flex layout with gap-sp-12 for logo spacing", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("flex");
expect(classes).toContain("gap-sp-12");
});
it("should have items-center alignment", () => {
const { container } = render(<CompanyLogos />);
const animatedElement = container.querySelector("[data-animate]");
const classes = animatedElement?.className || "";
expect(classes).toContain("items-center");
});
});
describe("CompanyLogos Component - Reduced Motion Fallback", () => {
beforeEach(() => {
mockReducedMotion = true;
});
afterEach(() => {
mockReducedMotion = false;
});
it("should render static grid when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
expect(staticGrid).toBeInTheDocument();
});
it("should not have animated elements when reduced motion is enabled", () => {
const { container } = render(<CompanyLogos />);
const animatedElements = container.querySelectorAll("[data-animate]");
expect(animatedElements.length).toBe(0);
});
it("should render exactly 5 logos in static mode (no duplication)", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll('img[alt*="logo"]');
expect(logoImages.length).toBe(5);
});
it("should have responsive grid columns in static mode", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("grid-cols-2");
expect(classes).toContain("sm:grid-cols-3");
expect(classes).toContain("md:grid-cols-5");
});
it("should have gap-sp-6 spacing in static grid", () => {
const { container } = render(<CompanyLogos />);
const staticGrid = container.querySelector(".grid");
const classes = staticGrid?.className || "";
expect(classes).toContain("gap-sp-6");
});
it("should maintain all logo alt text in static mode", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const alt = img.getAttribute("alt");
expect(alt).toBeTruthy();
expect(alt?.length).toBeGreaterThan(0);
});
});
});
describe("CompanyLogos Component - Missing Logo Image Handling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have onError handler for logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
// Check that the image element has an onError handler
// In React, this is attached as a prop, so we verify the element exists
expect(img).toBeInTheDocument();
});
});
it("should apply grayscale filter to logo images", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("grayscale");
});
});
it("should have hover effect to remove grayscale", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("hover:grayscale-0");
});
});
it("should have opacity transition on hover", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("opacity-60");
expect(classes).toContain("hover:opacity-100");
});
});
it("should have transition-all for smooth hover effects", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("transition-all");
});
});
});
describe("CompanyLogos Component - Responsive Layout", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have section element as container", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have full width class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("w-full");
});
it("should have overflow-hidden for animation containment", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("overflow-hidden");
});
it("should have border-b for visual separation", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("border-b");
});
it("should have background color class", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
const classes = section?.className || "";
expect(classes).toContain("bg-[var(--paper)]");
});
it("should have py-sp-8 vertical padding", () => {
const { container } = render(<CompanyLogos />);
// In animated mode, padding is on inner div
const innerDiv = container.querySelector(".py-sp-8");
expect(innerDiv).toBeInTheDocument();
});
it("should have responsive padding in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("px-sp-6");
expect(classes).toContain("py-sp-8");
expect(classes).toContain("md:px-sp-10");
expect(classes).toContain("lg:px-sp-16");
});
it("should have mx-auto for centering in static mode", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
const innerContainer = container.querySelector(".container");
const classes = innerContainer?.className || "";
expect(classes).toContain("mx-auto");
});
});
describe("CompanyLogos Component - Logo Sizing and Styling", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should have h-12 height for all logos", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("h-12");
});
});
it("should have w-auto width for aspect ratio preservation", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("w-auto");
});
});
it("should have object-contain for proper image scaling", () => {
const { container } = render(<CompanyLogos />);
const logoImages = container.querySelectorAll("img");
logoImages.forEach((img) => {
const classes = img.className;
expect(classes).toContain("object-contain");
});
});
it("should have flex-shrink-0 on logo containers in animated mode", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex-shrink-0");
});
});
it("should center logos within their containers", () => {
const { container } = render(<CompanyLogos />);
const logoContainers = container.querySelectorAll("[data-animate] > div");
logoContainers.forEach((div) => {
const classes = div.className;
expect(classes).toContain("flex");
expect(classes).toContain("items-center");
expect(classes).toContain("justify-center");
});
});
});
describe("CompanyLogos Component - Accessibility", () => {
beforeEach(() => {
mockReducedMotion = false;
});
it("should use semantic section element", () => {
const { container } = render(<CompanyLogos />);
const section = container.querySelector("section");
expect(section).toBeInTheDocument();
});
it("should have descriptive alt text for each logo", () => {
render(<CompanyLogos />);
expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2);
expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2);
expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2);
expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2);
expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2);
});
it("should respect reduced motion preference", () => {
mockReducedMotion = true;
const { container } = render(<CompanyLogos />);
// Should render static grid instead of animation
const staticGrid = container.querySelector(".grid");
const animatedElements = container.querySelectorAll("[data-animate]");
expect(staticGrid).toBeInTheDocument();
expect(animatedElements.length).toBe(0);
});
it("should maintain content visibility in both modes", () => {
// Test animated mode
mockReducedMotion = false;
const { container: animatedContainer } = render(<CompanyLogos />);
const animatedLogos =
animatedContainer.querySelectorAll('img[alt*="logo"]');
// Test static mode
mockReducedMotion = true;
const { container: staticContainer } = render(<CompanyLogos />);
const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]');
// Both modes should display logos (different counts due to duplication)
expect(animatedLogos.length).toBeGreaterThan(0);
expect(staticLogos.length).toBeGreaterThan(0);
});
});
-114
View File
@@ -1,114 +0,0 @@
"use client";
/**
* CompanyLogos Component
*
* Displays an infinite-scrolling banner of company logos after the header.
* Features smooth horizontal animation with reduced motion support.
*
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
*/
import { motion } from "framer-motion";
import Image from "next/image";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
// TypeScript interface for company logos
export interface CompanyLogo {
name: string; // Company name
imagePath: string; // Path to logo image (SVG preferred)
alt: string; // Alt text for accessibility
}
// Company logos data
const companies: CompanyLogo[] = [
{
name: "Apolo Energia",
imagePath: "/logos/apolo.svg",
alt: "Apolo Energia logo",
},
{
name: "Julia Beauty Lashes",
imagePath: "/logos/julia.svg",
alt: "Julia Beauty Lashes logo",
},
{
name: "Innovo",
imagePath: "/logos/innovo.svg",
alt: "Innovo logo",
},
{
name: "TeraByteShop",
imagePath: "/logos/terabyte.svg",
alt: "TeraByteShop logo",
},
{
name: "Pichau",
imagePath: "/logos/pichau.svg",
alt: "Pichau logo",
},
];
export default function CompanyLogos() {
const shouldReduceMotion = useReducedMotion();
// Duplicate logo array for seamless infinite scroll
const duplicatedLogos = [...companies, ...companies];
// Static grid for reduced motion preference
if (shouldReduceMotion) {
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="container mx-auto px-sp-6 py-sp-8 md:px-sp-10 lg:px-sp-16">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-sp-6 items-center justify-items-center">
{companies.map((logo) => (
<div key={logo.name} className="flex items-center justify-center">
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale opacity-60"
/>
</div>
))}
</div>
</div>
</section>
);
}
// Animated infinite scroll
return (
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
<div className="py-sp-8">
<motion.div
className="flex gap-sp-12 items-center"
animate={{
x: [0, "-50%"],
}}
transition={{
duration: 30,
repeat: Infinity,
ease: "linear",
}}
>
{duplicatedLogos.map((logo, index) => (
<div
key={`${logo.name}-${index}`}
className="flex-shrink-0 flex items-center justify-center"
>
<Image
src={logo.imagePath}
alt={logo.alt}
width={150}
height={48}
className="h-12 w-auto object-contain grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
/>
</div>
))}
</motion.div>
</div>
</section>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.setItem("evandro.analytics-consent.v1", "granted");
delete window["ga-disable-G-QTT9QT0T3S"];
window.gtag = jest.fn();
jest.spyOn(window, "open").mockImplementation(() => null);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
delete window.gtag;
});
it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => {
const user = userEvent.setup();
render(<Contact />);
const name = "Ana & João";
const email = "[email protected]";
const context =
"Integrar pedidos & estoque? Objetivo: evitar retrabalho.\nPrazo: outubro #2026";
await user.type(screen.getByRole("textbox", { name: /nome/i }), name);
await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email);
const subject = screen.getByRole("combobox", { name: /tipo de projeto/i });
await user.selectOptions(subject, "Integrações e automações");
await user.type(screen.getByRole("textbox", { name: /contexto/i }), context);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const [destination, target, features] = jest.mocked(window.open).mock
.calls[0];
const url = new URL(String(destination));
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/);
expect([...url.searchParams.keys()]).toEqual(["text"]);
const message = url.searchParams.get("text");
expect(message).toContain(name);
expect(message).toContain(email);
expect(message).toContain("Integrações e automações");
expect(message).toContain(context);
expect(target).toBe("_blank");
expect(features).toBe("noopener,noreferrer");
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Contato",
placement: "contact_form",
page_path: "/",
page_location: "https://evandro.dev.br/",
page_referrer: "",
});
});
it("does not open WhatsApp while required fields are empty or the email is invalid", async () => {
const user = userEvent.setup();
render(<Contact />);
const submit = screen.getByRole("button", { name: /whatsapp/i });
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"email-invalido",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(submit);
expect(window.open).not.toHaveBeenCalled();
expect(window.gtag).not.toHaveBeenCalled();
});
it("opens the project draft with analytics unavailable and consent denied", async () => {
localStorage.setItem("evandro.analytics-consent.v1", "denied");
delete window.gtag;
const user = userEvent.setup();
render(<Contact />);
await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana");
await user.type(
screen.getByRole("textbox", { name: /e-mail/i }),
"[email protected]",
);
await user.type(
screen.getByRole("textbox", { name: /contexto/i }),
"Organizar pedidos",
);
await user.click(screen.getByRole("button", { name: /whatsapp/i }));
expect(window.open).toHaveBeenCalledTimes(1);
const destination = new URL(
String(jest.mocked(window.open).mock.calls[0][0]),
);
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain("Organizar pedidos");
});
+31 -16
View File
@@ -1,16 +1,22 @@
"use client";
import { motion, type Variants } from "motion/react";
import type React from "react";
import { useState } from "react";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { Section } from "./Section";
import { useEffect, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
const WHATSAPP_NUMBER = "5547992742365";
import { Section } from "./Section";
export default function Contact() {
const shouldReduceMotion = useReducedMotion();
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setWhatsAppDisplay(getWhatsAppDisplayNumber());
}, []);
const fieldClassName =
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
const [formData, setFormData] = useState({
@@ -25,8 +31,7 @@ export default function Contact() {
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
const encodedMessage = encodeURIComponent(messageTemplate);
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
const whatsappUrl = buildWhatsAppUrl(messageTemplate);
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
};
@@ -59,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
>
@@ -85,19 +90,29 @@ export default function Contact() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div className="ds-kicker">Availability</div>
<div className="ds-kicker" data-reel="left">
Availability
</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">Resposta inicial em ate 24h</h3>
<p className="ds-copy">
<h3 className="ds-card-title" data-reel="top">
Resposta inicial em ate 24h
</h3>
<p className="ds-copy" data-reel="right">
Ideal para discovery tecnico, revisao de arquitetura, rescue de
projeto, performance ou construcao de produto com ownership real.
</p>
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
<div>WhatsApp: +55 47 99274-2365</div>
<div
className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]"
data-reel-stagger="bottom"
>
<div>WhatsApp: {whatsAppDisplay}</div>
<div>E-mail: evandrofonsecajunior@gmail.com</div>
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
</div>
<div className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]">
<div
className="mt-sp-6 border-t border-[rgba(255,255,255,.08)] pt-sp-4 text-[12px] leading-6 text-[rgba(255,255,255,.75)]"
data-reel="fade"
>
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva.
</div>
@@ -177,7 +192,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>
-913
View File
@@ -1,913 +0,0 @@
"use client";
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { motion } from "motion/react";
interface ContactFormProps {
formType: "simple" | "advanced";
setFormType: (type: "simple" | "advanced") => void;
}
export function ContactForm({ formType, setFormType }: ContactFormProps) {
const { data } = usePortfolioData();
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
"whatsapp",
);
// MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders
const [formData, setFormData] = useState({
userName: "",
userEmail: "",
userPhone: "",
userCompany: "",
howFound: "",
projectType: "",
otherProjectType: "",
desiredDeadline: "",
budgetValue: "",
technicalComplexity: "",
hasInternalTeam: "",
customMessage: "",
});
const [isDecisor, setIsDecisor] = useState<string>("");
const [projectApproved, setProjectApproved] = useState<string>("");
const [urgency, setUrgency] = useState<string>("");
const [preferredModality, setPreferredModality] = useState<string>("");
const [isExpanded, setIsExpanded] = useState<boolean>(false); // Novo estado para controlar a expansão do formulário avançado
const [nameError, setNameError] = useState<string | null>(null);
const [emailError, setEmailError] = useState<string | null>(null);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [otherProjectTypeError, setOtherProjectTypeError] = useState<
string | null
>(null); // Novo estado de erro
const [honeypot, setHoneypot] = useState<string>(""); // Estado para o campo honeypot
const [startTime, setStartTime] = useState<number>(0); // Estado para o tempo de início do formulário
const [nameFocused, setNameFocused] = useState<boolean>(false);
const [emailFocused, setEmailFocused] = useState<boolean>(false);
const [phoneFocused, setPhoneFocused] = useState<boolean>(false);
useEffect(() => {
setStartTime(Date.now()); // Registra o tempo de carregamento do componente
}, []);
if (!data) {
return null; // Ou um componente de carregamento/erro
}
const getGreetingMessageWithUserName = () => {
const hour = new Date().getHours();
const dayPart =
hour >= 8 && hour < 12
? "manhã"
: hour >= 12 && hour < 18
? "tarde"
: "noite";
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
Meu nome é ${formData.userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
Atenciosamente,
${formData.userName}`;
};
const generateAdvancedMessage = () => {
if (formData.customMessage) return formData.customMessage;
// Saudação profissional
let message = `Prezado Evandro,\n\n`;
// Informações do contato
message += `Meu nome é ${formData.userName}`;
if (formData.userCompany) {
message += `, represento a empresa ${formData.userCompany}`;
}
message += `.\n`;
if (formData.howFound) {
message += `Conheci seu trabalho através de ${formData.howFound}.\n`;
}
message += `\n`;
// Detalhes do projeto
message += `=== DETALHES DO PROJETO ===\n`;
message += `Tipo de projeto: `;
if (formData.projectType === "outro" && formData.otherProjectType) {
message += `${formData.otherProjectType}\n`;
} else if (formData.projectType) {
message += `${formData.projectType}\n`;
} else {
message += `Não especificado\n`;
}
if (formData.desiredDeadline) {
message += `Prazo desejado: ${formData.desiredDeadline}\n`;
}
if (formData.budgetValue) {
message += `Orçamento disponível: ${formData.budgetValue}\n`;
}
if (formData.technicalComplexity) {
message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`;
}
if (formData.hasInternalTeam) {
message += `Equipe técnica interna: ${
formData.hasInternalTeam === "sim"
? "Sim"
: formData.hasInternalTeam === "nao"
? "Não"
: "Parcialmente"
}\n`;
}
message += `\n`;
// Contatos
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
if (formData.userEmail) {
message += `📧 Email: ${formData.userEmail}\n`;
}
if (formData.userPhone) {
message += `📱 Telefone/WhatsApp: ${formData.userPhone}\n`;
}
message += `\n`;
// Mensagem final
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
message += `Atenciosamente,\n`;
message += `${formData.userName}`;
return message;
};
const handleInputChange =
(field: keyof typeof formData) =>
(
e: ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>,
) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
// Clear specific errors if the user starts typing
if (e.target.value.trim() !== "") {
if (field === "userName") setNameError(null);
if (field === "userEmail") setEmailError(null);
if (field === "userPhone") setPhoneError(null);
if (field === "otherProjectType") setOtherProjectTypeError(null);
}
};
const handleNameFocus = () => setNameFocused(true);
const handleEmailFocus = () => setEmailFocused(true);
const handlePhoneFocus = () => setPhoneFocused(true);
const generateWhatsAppUrl = () => {
const message = encodeURIComponent(
formType === "advanced"
? generateAdvancedMessage()
: formData.customMessage || getGreetingMessageWithUserName(),
);
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
return `https://wa.me/${phone}?text=${message}`;
};
const generateEmailUrl = () => {
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
const body = encodeURIComponent(
formType === "advanced"
? generateAdvancedMessage()
: `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${
formData.customMessage || getGreetingMessageWithUserName()
}`,
);
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
let hasError = false;
if (formData.userName.trim() === "") {
setNameError("Por favor, insira seu nome.");
hasError = true;
} else {
setNameError(null);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.userEmail)) {
setEmailError("Por favor, insira um email válido.");
hasError = true;
} else {
setEmailError(null);
}
if (formData.userPhone.trim() === "") {
setPhoneError("Por favor, insira seu telefone.");
hasError = true;
} else {
setPhoneError(null);
}
if (
formData.projectType === "outro" &&
formData.otherProjectType.trim() === ""
) {
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
hasError = true;
} else {
setOtherProjectTypeError(null);
}
// Validação Honeypot
if (honeypot) {
console.log("Honeypot preenchido, provável bot.");
return; // Ignora a submissão
}
// UX FIX: Validação de tempo com feedback ou limite menor
const timeElapsed = Date.now() - startTime;
// Reduzido para 1000ms para evitar falsos positivos com autofill
if (timeElapsed < 1000) {
console.log("Formulário preenchido muito rápido, provável bot.");
// Opcional: Mostrar erro para humano real que digita muito rápido
// alert("Por favor, verifique os dados antes de enviar.");
return; // Ignora a submissão
}
if (hasError) return;
// Verificação de interação mínima
if (!nameFocused || !emailFocused || !phoneFocused) {
// MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo.
// Considere remover ou tornar apenas um aviso no console.
console.warn(
"Campos principais não foram focados, verificando integridade.",
);
}
if (contactMethod === "whatsapp") {
window.open(generateWhatsAppUrl(), "_blank");
} else {
window.open(generateEmailUrl(), "_blank");
}
};
const getCtaMessage = () => {
if (formData.projectType === "consultoria") {
return "Agendar Consultoria Gratuita";
}
if (
formData.budgetValue &&
parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000
) {
return "Discutir Soluções de Baixo Custo";
}
if (
formData.projectType &&
formData.desiredDeadline &&
formData.budgetValue
) {
return "Receber Proposta Personalizada";
}
return "Enviar Formulário";
};
const getEstimatedValue = () => {
let hours = 0;
let hourlyRate = 55; // Taxa base R$ 55/h
// Estimativa de horas baseada no tipo de projeto
switch (formData.projectType) {
case "web-app":
hours += 80; // Aplicação web completa
break;
case "landing-page":
hours += 16; // Landing page simples
break;
case "mvp":
hours += 40; // MVP básico
break;
case "iot-app":
hours += 120; // Aplicação IoT complexa
break;
case "integracao":
hours += 32; // Integração de sistemas
break;
case "manutencao":
hours += 8; // Manutenção pontual
break;
case "consultoria":
hours += 4; // Consultoria inicial
break;
default:
break;
}
// Ajuste de horas baseado no prazo
switch (formData.desiredDeadline) {
case "1-3-weeks":
hours *= 1.3; // Urgência aumenta horas
break;
case "1-2-months":
hours *= 1.1;
break;
case "6-plus-months":
hours *= 0.9; // Prazo longo pode reduzir horas
break;
default:
break;
}
// Ajuste de horas baseado na complexidade
switch (formData.technicalComplexity) {
case "alta":
hours *= 1.4;
hourlyRate = 65; // Taxa maior para alta complexidade
break;
case "muito-alta":
hours *= 1.7;
hourlyRate = 70; // Taxa máxima para muito alta complexidade
break;
case "media":
hourlyRate = 50; // Taxa média
break;
default:
break;
}
if (formData.hasInternalTeam === "nao") {
hours *= 1.2; // Sem equipe interna = mais horas necessárias
}
const totalValue = hours * hourlyRate;
if (totalValue > 0) {
return `Estimativa: ${Math.round(
hours,
)}h × R$ ${hourlyRate}/h = R$ ${totalValue.toFixed(0)}`;
}
return "Estimativa: Solicite orçamento personalizado";
};
return (
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
viewport={{ once: true }}
className="space-y-4"
>
<h3 className="text-2xl font-semibold mb-6 bg-gradient-to-r from-cyan-400 to-blue-500 bg-clip-text text-transparent">
Preencha o Formulário
</h3>
<div className="flex justify-center gap-4 mb-8">
<button
type="button"
onClick={() => setFormType("simple")}
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
formType === "simple"
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
}`}
>
Formulário Simples
</button>
<button
type="button"
onClick={() => setFormType("advanced")}
className={`py-2 px-4 rounded-lg font-semibold transition-all duration-300 ${
formType === "advanced"
? "bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
: "bg-gray-700 text-gray-300 hover:bg-gray-600 cursor-pointer"
}`}
>
Formulário Avançado
</button>
</div>
{formType === "simple" ? (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campo Honeypot */}
<div style={{ display: "none" }}>
<label htmlFor="honeypot">Não preencha este campo</label>
<input
type="text"
id="honeypot"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
/>
</div>
{/* Formulário Simples: Apenas Nome */}
<div>
<label
htmlFor="simple-user-name"
className="block text-sm font-medium text-gray-300 mb-2"
>
Seu Nome:
</label>
<input
type="text"
id="simple-user-name"
className={`w-full p-3 rounded-lg bg-white/5 border ${
nameError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Seu nome"
value={formData.userName}
onChange={handleInputChange("userName")}
onFocus={handleNameFocus}
required
/>
{nameError && (
<p className="text-red-400 text-xs mt-1">{nameError}</p>
)}
</div>
{/* Botão de Contato para o formulário simples */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<button
type="submit"
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-green-600 hover:bg-green-700 text-white shadow-lg shadow-green-600/25"
onClick={() => window.open(generateWhatsAppUrl(), "_blank")}
>
Enviar Mensagem via WhatsApp
</button>
</motion.div>
</form>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Campo Honeypot */}
<div style={{ display: "none" }}>
<label htmlFor="honeypot">Não preencha este campo</label>
<input
type="text"
id="honeypot"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
/>
</div>
{/* Dados Básicos */}
<div>
<h4 className="text-xl font-semibold mb-4 text-gray-200">
Dados Básicos
</h4>
<div className="space-y-4">
<div>
<label
htmlFor="user-name"
className="block text-sm font-medium text-gray-300 mb-2"
>
Nome Completo:
</label>
<input
type="text"
id="user-name"
className={`w-full p-3 rounded-lg bg-white/5 border ${
nameError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Seu nome completo"
value={formData.userName}
onChange={handleInputChange("userName")}
onFocus={handleNameFocus}
required
/>
{nameError && (
<p className="text-red-400 text-xs mt-1">{nameError}</p>
)}
</div>
<div>
<label
htmlFor="user-email"
className="block text-sm font-medium text-gray-300 mb-2"
>
Email Corporativo:
</label>
<input
type="email"
id="user-email"
className={`w-full p-3 rounded-lg bg-white/5 border ${
emailError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="[email protected]"
value={formData.userEmail}
onChange={handleInputChange("userEmail")}
onFocus={handleEmailFocus}
required
/>
{emailError && (
<p className="text-red-400 text-xs mt-1">{emailError}</p>
)}
</div>
<div>
<label
htmlFor="user-phone"
className="block text-sm font-medium text-gray-300 mb-2"
>
WhatsApp/Telefone:
</label>
<input
type="tel"
id="user-phone"
className={`w-full p-3 rounded-lg bg-white/5 border ${
phoneError ? "border-red-500" : "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="(XX) XXXXX-XXXX"
value={formData.userPhone}
onChange={handleInputChange("userPhone")}
onFocus={handlePhoneFocus}
required
/>
{phoneError && (
<p className="text-red-400 text-xs mt-1">{phoneError}</p>
)}
</div>
<div>
<label
htmlFor="user-company"
className="block text-sm font-medium text-gray-300 mb-2"
>
Empresa/Posição:
</label>
<input
type="text"
id="user-company"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Nome da empresa, sua posição"
value={formData.userCompany}
onChange={handleInputChange("userCompany")}
/>
</div>
<div>
<label
htmlFor="how-found"
className="block text-sm font-medium text-gray-300 mb-2"
>
Como conheceu meu trabalho?
</label>
<select
id="how-found"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.howFound}
onChange={handleInputChange("howFound")}
>
{" "}
<option value="" className="bg-gray-800 text-white">
Selecione uma opção
</option>
<option value="linkedin" className="bg-gray-800 text-white">
LinkedIn
</option>
<option value="github" className="bg-gray-800 text-white">
GitHub
</option>
<option value="indicacao" className="bg-gray-800 text-white">
Indicação
</option>
<option value="pesquisa" className="bg-gray-800 text-white">
Pesquisa Online
</option>
<option value="outro" className="bg-gray-800 text-white">
Outro
</option>
</select>
</div>
</div>
</div>
{/* Tile de Expandir/Recolher */}
<div
onClick={() => setIsExpanded(!isExpanded)}
className="p-4 rounded-lg bg-white/5 border border-white/10 text-white cursor-pointer hover:bg-white/10 transition-colors duration-300 mb-6 flex justify-between items-center"
>
<h4 className="text-xl font-semibold text-gray-200">
{isExpanded
? "Detalhes do Projeto (Recolher)"
: "Detalhes do Projeto (Expandir)"}
</h4>
<svg
className={`w-6 h-6 transition-transform duration-300 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
<div
className={`overflow-hidden transition-all duration-500 ease-in-out ${
isExpanded ? "max-h-[1000px] opacity-100" : "max-h-0 opacity-0"
} space-y-6`}
>
{/* Conteúdo do Formulário Avançado */}
{/* Sobre o Projeto */}
<div>
<h4 className="text-xl font-semibold mb-4 text-gray-200">
Sobre o Projeto
</h4>
<div className="space-y-4">
<div>
<label
htmlFor="project-type"
className="block text-sm font-medium text-gray-300 mb-2"
>
Tipo de Projeto:
</label>
<select
id="project-type"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.projectType}
onChange={handleInputChange("projectType")}
>
<option value="" className="bg-gray-800 text-white">
Selecione o tipo
</option>
<option value="web-app" className="bg-gray-800 text-white">
Aplicação Web Completa
</option>
<option
value="landing-page"
className="bg-gray-800 text-white"
>
Landing Page
</option>
<option value="mvp" className="bg-gray-800 text-white">
MVP (Produto Mínimo Viável)
</option>
<option value="iot-app" className="bg-gray-800 text-white">
Aplicação IoT
</option>
<option
value="integracao"
className="bg-gray-800 text-white"
>
Integração de Sistemas
</option>
<option
value="manutencao"
className="bg-gray-800 text-white"
>
Manutenção/Ajustes
</option>
<option
value="consultoria"
className="bg-gray-800 text-white"
>
Consultoria/Mentoria
</option>
<option value="outro" className="bg-gray-800 text-white">
Outro
</option>
</select>
</div>
{formData.projectType === "outro" && (
<div>
<label
htmlFor="other-project-type"
className="block text-sm font-medium text-gray-300 mb-2"
>
Qual outro tipo de projeto?
</label>
<input
type="text"
id="other-project-type"
className={`w-full p-3 rounded-lg bg-white/5 border ${
otherProjectTypeError
? "border-red-500"
: "border-white/10"
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
value={formData.otherProjectType}
onChange={handleInputChange("otherProjectType")}
required
/>
{otherProjectTypeError && (
<p className="text-red-400 text-xs mt-1">
{otherProjectTypeError}
</p>
)}
</div>
)}
<div>
<label
htmlFor="desired-deadline"
className="block text-sm font-medium text-gray-300 mb-2"
>
Prazo Desejado:
</label>
<select
id="desired-deadline"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.desiredDeadline}
onChange={handleInputChange("desiredDeadline")}
>
<option value="" className="bg-gray-800 text-white">
Selecione o prazo
</option>
<option
value="1-3-weeks"
className="bg-gray-800 text-white"
>
1-3 Semanas
</option>
<option
value="1-2-months"
className="bg-gray-800 text-white"
>
1-2 Meses
</option>
<option
value="2-4-months"
className="bg-gray-800 text-white"
>
2-4 Meses
</option>
<option
value="4-6-months"
className="bg-gray-800 text-white"
>
4-6 Meses
</option>
<option
value="6-plus-months"
className="bg-gray-800 text-white"
>
Mais de 6 Meses
</option>
</select>
</div>
<div>
<label
htmlFor="budget-value"
className="block text-sm font-medium text-gray-300 mb-2"
>
Orçamento Disponível (R$):
</label>
<input
type="text"
id="budget-value"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Ex: 5000, 10000, A Combinar"
value={formData.budgetValue}
onChange={handleInputChange("budgetValue")}
/>
</div>
<div>
<label
htmlFor="technical-complexity"
className="block text-sm font-medium text-gray-300 mb-2"
>
Complexidade Técnica Estimada:
</label>
<select
id="technical-complexity"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.technicalComplexity}
onChange={handleInputChange("technicalComplexity")}
>
<option value="" className="bg-gray-800 text-white">
Selecione a complexidade
</option>
<option value="baixa" className="bg-gray-800 text-white">
Baixa
</option>
<option value="media" className="bg-gray-800 text-white">
Média
</option>
<option value="alta" className="bg-gray-800 text-white">
Alta
</option>
<option
value="muito-alta"
className="bg-gray-800 text-white"
>
Muito Alta
</option>
</select>
</div>
<div>
<label
htmlFor="has-internal-team"
className="block text-sm font-medium text-gray-300 mb-2"
>
Já tem equipe técnica interna?
</label>
<select
id="has-internal-team"
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white focus:ring-2 focus:ring-cyan-500 focus:border-transparent appearance-none"
style={{
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 12px center",
backgroundRepeat: "no-repeat",
backgroundSize: "16px",
}}
value={formData.hasInternalTeam}
onChange={handleInputChange("hasInternalTeam")}
>
<option value="" className="bg-gray-800 text-white">
Selecione
</option>
<option value="sim" className="bg-gray-800 text-white">
Sim
</option>
<option value="nao" className="bg-gray-800 text-white">
Não
</option>
<option value="parcial" className="bg-gray-800 text-white">
Parcialmente
</option>
</select>
</div>
</div>
</div>
{/* Custom Message Input */}
<div className="mb-6">
<label
htmlFor="custom-message"
className="block text-sm font-medium text-gray-300 mb-2"
>
Mensagem Adicional (Opcional):
</label>
<textarea
id="custom-message"
rows={4}
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
placeholder="Descreva seu projeto ou suas necessidades aqui..."
value={formData.customMessage}
onChange={handleInputChange("customMessage")}
></textarea>
</div>
</div>
{/* Contact Button */}
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<button
type="submit"
className="w-full py-4 px-6 rounded-lg font-semibold text-lg transition-all duration-300 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white shadow-lg shadow-cyan-500/25 border-2 border-cyan-500"
>
{getCtaMessage()}
</button>
</motion.div>
{formData.projectType &&
formData.desiredDeadline &&
formData.budgetValue && (
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
{getEstimatedValue()}
</p>
)}
</form>
)}
</motion.div>
);
}
-140
View File
@@ -1,140 +0,0 @@
"use client";
import { motion } from "motion/react";
import { Mail, MessageCircle } from "lucide-react";
import { usePortfolioData } from "@/lib/usePortfolioData";
import MagneticButton from "./cinematic/MagneticButton";
interface ContactInfoProps {
onWhatsAppClick: () => void;
}
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
const { data } = usePortfolioData();
if (!data) {
return null; // Ou um componente de carregamento/erro
}
return (
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
viewport={{ once: true }}
className="space-y-4"
>
<div>
<h3 className="text-2xl font-semibold mb-6 text-blue-400">
Entre em Contato
</h3>
<div className="space-y-4">
<MagneticButton
as="a"
href={`mailto:${data.contact.email}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-blue-500/20">
<Mail className="w-6 h-6 text-blue-400" />
</div>
<div>
<p className="text-white font-medium">Email</p>
<p className="text-gray-400">{data.contact.email}</p>
</div>
</MagneticButton>
<MagneticButton
onClick={onWhatsAppClick}
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-green-500/20">
<MessageCircle className="w-6 h-6 text-green-400" />
</div>
<div>
<p className="text-white font-medium">WhatsApp</p>
<p className="text-gray-400">{data.contact.whatsapp}</p>
</div>
</MagneticButton>
<MagneticButton
as="a"
href={data.contact.linkedin}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-blue-500/20">
<svg
className="w-6 h-6 text-blue-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="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" />
</svg>
</div>
<div>
<p className="text-white font-medium">LinkedIn</p>
<p className="text-gray-400">
{data.contact.linkedin.replace("https://www.", "")}
</p>
</div>
</MagneticButton>
<MagneticButton
as="a"
href={data.contact.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors w-full text-left"
wrapperClassName="block w-full"
strength={0.1}
>
<div className="p-3 rounded-full bg-gray-500/20">
<svg
className="w-6 h-6 text-gray-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</div>
<div>
<p className="text-white font-medium">GitHub</p>
<p className="text-gray-400">
{data.contact.github.replace("https://github.com/", "")}
</p>
</div>
</MagneticButton>
</div>
</div>
<div>
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
Localização
</h3>
<p className="text-gray-300">📍 {data.contact.location}</p>
<p className="text-gray-400 mt-2">
Disponível para projetos remotos e trabalho presencial na região.
</p>
</div>
{/* Timeline de Resposta (Placeholder) */}
<div>
<h3 className="text-2xl font-semibold mb-4 text-blue-400">
Timeline de Resposta
</h3>
<p className="text-gray-300">
Em breve, uma timeline detalhada do processo de contato e resposta.
</p>
</div>
</motion.div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import { Section } from "./Section";
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
description:
"Login não basta. Permissões precisam ser verificadas no servidor e testadas entre usuários, empresas e perfis diferentes.",
},
{
title: "Como recuperar a operação?",
description:
"Ambientes separados, acesso restrito à produção e backups com restauração testada reduzem o impacto de uma alteração errada.",
},
{
title: "Quem valida antes de publicar?",
description:
"Código gerado também passa por revisão, testes e avaliação de dependências. O escopo inclui manutenção, custos e riscos de operação.",
},
];
export default function EngineeringWithAI() {
return (
<Section
id="engineering-ai"
eyebrow="IA no desenvolvimento"
title="Se a IA cria um sistema, por que contratar um desenvolvedor?"
description="A IA permite testar ideias e construir protótipos mesmo sem experiência em programação. Quando o sistema passa a cuidar de clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que foi gerado e validar como ele se comporta fora da demonstração."
disableChildrenAnimation
>
<div className="border-l-2 border-[var(--slash)] pl-sp-5">
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
Você contrata engenharia para transformar as regras do seu negócio em
um sistema que possa ser testado, operado e mantido. A IA pode fazer
parte desse trabalho. Ela não dispensa decisões sobre arquitetura,
segurança e responsabilidade pelos dados.
</p>
</div>
<div className="mt-sp-8 grid gap-sp-6 md:grid-cols-3">
{RESPONSIBILITIES.map((item) => (
<article
key={item.title}
className="border-t border-[var(--border)] pt-sp-5"
>
<h3 className="text-lg font-semibold leading-snug">{item.title}</h3>
<p className="ds-copy">{item.description}</p>
</article>
))}
</div>
<div className="mt-sp-12">
<h3 className="ds-card-title">O que casos documentados ensinam</h3>
<p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas. Eles
mostram riscos de configuração, permissões e operação; não provam que
todo sistema feito com IA seja inseguro ou que um profissional elimine
qualquer risco.
</p>
<div className="mt-sp-6 space-y-sp-3">
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Dados expostos em aplicações criadas com Lovable
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em 29 de maio de 2025, a Semafor noticiou uma análise de Matt
Palmer e Kody Low: 170 de 1.645 aplicações examinadas permitiam
acesso indevido a informações, incluindo dados de usuários e
chaves de API. A reportagem relacionou a exposição à
configuração de acesso dos bancos conectados às aplicações.
</p>
<p>
O registro CVE-2025-48757 descreve políticas insuficientes de
acesso por registro no banco. O fornecedor contesta a atribuição
da vulnerabilidade à plataforma e ressalta a responsabilidade
dos clientes por seus dados. O relato de exposição não comprova
roubo por terceiros em todas essas aplicações.
</p>
<p>
<strong>O cuidado técnico:</strong> testar se uma pessoa
consegue ler ou alterar dados de outra, em vez de considerar a
tela de login uma prova de segurança.
</p>
<div className="flex flex-wrap gap-x-sp-6 gap-y-sp-2">
<a
className="underline underline-offset-4"
href="https://www.semafor.com/article/05/29/2025/the-hottest-new-vibe-coding-startup-lovable-is-a-sitting-duck-for-hackers"
target="_blank"
rel="noopener noreferrer"
>
Reportagem da Semafor (em inglês)
</a>
<a
className="underline underline-offset-4"
href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757"
target="_blank"
rel="noopener noreferrer"
>
Registro e contestação no NVD
</a>
</div>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Replit: um agente apagou dados de uma aplicação
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, Jason Lemkin, cofundador da SaaStr, relatou
que um agente apagou dados durante a construção de uma
aplicação. A Replit reconheceu o episódio e informou que o banco
foi integralmente recuperado por rollback. Naquele projeto,
alterações de desenvolvimento podiam afetar a produção.
</p>
<p>
A plataforma anunciou separação entre bancos de desenvolvimento
e produção por padrão. <strong>O cuidado técnico:</strong>{" "}
limitar permissões de agentes, separar ambientes e testar a
recuperação antes de depender dela. Neste caso houve exclusão e
recuperação de dados, não roubo de informações.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://replit.com/blog/doubling-down-on-our-commitment-to-secure-vibe-coding"
target="_blank"
rel="noopener noreferrer"
>
Relato e medidas publicadas pela Replit (29/07/2025)
</a>
</div>
</details>
<details className="ds-card">
<summary className="cursor-pointer text-base font-semibold leading-7">
Base44: aplicações privadas com falha de acesso
</summary>
<div className="mt-sp-4 max-w-[85ch] space-y-sp-3 text-sm leading-7">
<p>
Em julho de 2025, pesquisadores da Wiz demonstraram uma falha
que permitia acesso não autorizado a aplicações privadas do
Base44. A falha estava na camada compartilhada de autenticação
da plataforma, não em um erro comprovado de um usuário
iniciante.
</p>
<p>
A Wix corrigiu o problema em menos de 24 horas após a
notificação e informou não ter encontrado evidência de
exploração maliciosa. <strong>O cuidado técnico:</strong>{" "}
avaliar também o fornecedor e as permissões efetivas das
integrações, além do código da aplicação.
</p>
<a
className="inline-block underline underline-offset-4"
href="https://www.wiz.io/blog/critical-vulnerability-base44"
target="_blank"
rel="noopener noreferrer"
>
Pesquisa da Wiz e resposta da Wix (29/07/2025)
</a>
</div>
</details>
</div>
</div>
<div className="mt-sp-8 grid gap-sp-6 lg:grid-cols-2">
<article className="ds-card">
<h3 className="ds-card-title !mt-0">E a LGPD?</h3>
<p className="ds-copy">
Usar IA não afasta as obrigações de proteção de dados. A ANPD exige
medidas técnicas e administrativas também de agentes de pequeno
porte e esclarece que as flexibilizações não os isentam das demais
obrigações da LGPD, como bases legais e direitos dos titulares.
</p>
<p className="ds-copy">
O trabalho técnico inclui controles de acesso, coleta limitada ao
necessário e rotinas de retenção e exclusão. Conformidade depende
também dos processos da empresa e de orientação jurídica; contratar
desenvolvimento não equivale a uma certificação de adequação.
</p>
<a
className="mt-sp-4 inline-block text-sm underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
target="_blank"
rel="noopener noreferrer"
>
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
</a>
</article>
<article className="ds-card">
<h3 className="ds-card-title !mt-0">
Já começou com IA? Podemos partir daí.
</h3>
<p className="ds-copy">
Seu protótipo ajuda a explicar a ideia. Antes de propor uma
reconstrução, avalio o que pode ser aproveitado, quais riscos
precisam de correção e o que falta para atender usuários em
produção.
</p>
<p className="ds-copy">
Você não precisa saber programar para conduzir o negócio. Precisa
entender o que está contratando, acompanhar a entrega e ter clareza
sobre quem cuida de cada parte do sistema.
</p>
<a className="ds-link ds-link-accent mt-sp-6" href="/#contact">
Avaliar meu projeto
</a>
</article>
</div>
</Section>
);
}
@@ -1,556 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div 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 5: Experience Categorization", () => {
// Counter for unique IDs
let idCounter = 0;
// Generator for experience entries
const experienceArbitrary = (type: "Software" | "Hardware") =>
fc.record({
id: fc
.string({ minLength: 10, maxLength: 20 })
.map((s) => `exp-${type.toLowerCase()}-${idCounter++}-${s}`),
title: fc.string({ minLength: 5, maxLength: 50 }),
company: fc.string({ minLength: 3, maxLength: 30 }),
period: fc.string({ minLength: 10, maxLength: 30 }),
location: fc.string({ minLength: 5, maxLength: 40 }),
type: fc.constant(type),
description: fc.string({ minLength: 20, maxLength: 200 }),
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
minLength: 1,
maxLength: 5,
}),
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
minLength: 1,
maxLength: 10,
}),
featured: fc.constant(true),
});
beforeEach(() => {
idCounter = 0;
});
afterEach(() => {
jest.clearAllMocks();
});
it("should filter and render Software experiences in the Software category", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry with type="Software", it should be
* filtered and rendered within the Software category group. This ensures
* correct categorization of software-related work experiences.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 5,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 0,
maxLength: 5,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<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"),
);
const hardwareHeading = headings.find((h3) =>
h3.textContent?.includes("Hardware"),
);
// Both headings should exist
if (!softwareHeading || !hardwareHeading) return false;
// Get all experience cards
const allCards = container.querySelectorAll("article");
// Total cards should equal total experiences
return allCards.length === allExperiences.length;
},
),
{ numRuns: 100 },
);
});
it("should not render category heading when no experiences exist for that type", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any category (Software or Hardware) with zero experiences,
* the category heading should not be rendered. This ensures clean UI without
* empty category sections.
*/
fc.assert(
fc.property(
fc.oneof(
fc.record({
software: fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 5,
}),
hardware: fc.constant([]),
}),
fc.record({
software: fc.constant([]),
hardware: fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 5,
}),
}),
),
({ software, hardware }) => {
const allExperiences = [...software, ...hardware];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
const headings = Array.from(container.querySelectorAll("h3"));
const softwareHeading = headings.find((h3) =>
h3.textContent?.includes("Software"),
);
const hardwareHeading = headings.find((h3) =>
h3.textContent?.includes("Hardware"),
);
// If software array is empty, no software heading should exist
const softwareCheck =
software.length === 0 ? !softwareHeading : !!softwareHeading;
// If hardware array is empty, no hardware heading should exist
const hardwareCheck =
hardware.length === 0 ? !hardwareHeading : !!hardwareHeading;
return softwareCheck && hardwareCheck;
},
),
{ numRuns: 100 },
);
});
it("should maintain experience data integrity within each category", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry, its data (title, company, description)
* should be rendered correctly within its assigned category. The categorization
* process should not modify or lose experience data.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 3,
}),
fc.array(experienceArbitrary("Hardware"), {
minLength: 1,
maxLength: 3,
}),
(softwareExps, hardwareExps) => {
const allExperiences = [...softwareExps, ...hardwareExps];
mockUsePortfolioData.mockReturnValue({
data: {
experiences: allExperiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<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),
);
});
// Check that all company names are present
const allCompaniesPresent = allExperiences.every((exp) => {
return container.textContent?.includes(exp.company);
});
return allTitlesPresent && allCompaniesPresent;
},
),
{ numRuns: 100 },
);
});
it("should handle mixed case and variations in type field values", () => {
/**
* **Validates: Requirements 4.1**
*
* Property: For any experience entry, the type field should be matched
* case-sensitively to ensure correct categorization. Only exact matches
* to "Software" or "Hardware" should be categorized.
*/
fc.assert(
fc.property(
fc.array(experienceArbitrary("Software"), {
minLength: 1,
maxLength: 3,
}),
(softwareExps) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<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 },
);
});
});
@@ -1,641 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div 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)),
);
});
};
beforeEach(() => {
idCounter = 0;
});
afterEach(() => {
jest.clearAllMocks();
});
it("should sort Software experiences in descending chronological order by year", () => {
/**
* **Validates: Requirements 4.3**
*
* Property: For any set of Software experiences with different years,
* they should be rendered in descending chronological order (newest first).
* This ensures users see the most recent experiences at the top.
*/
fc.assert(
fc.property(
fc
.integer({ min: 2, max: 5 })
.chain((count) => experiencesWithYearsArbitrary("Software", count)),
(softwareExps) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences: softwareExps,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<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,
}),
(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"),
);
// Extract first year from each rendered card
const renderedYears = softwareCards.map((card) => {
const periodText =
card.querySelector(".ds-kicker")?.textContent || "";
const yearMatch = periodText.match(/\d{4}/);
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
});
// Check if years are in descending order
for (let i = 0; i < renderedYears.length - 1; i++) {
if (renderedYears[i] < renderedYears[i + 1]) {
return false;
}
}
return true;
},
),
{ numRuns: 100 },
);
});
});
@@ -1,407 +0,0 @@
import { render } from "@testing-library/react";
import Experience from "./Experience";
import fc from "fast-check";
import { usePortfolioData } from "@/lib/usePortfolioData";
// Mock the portfolio data hook
jest.mock("@/lib/usePortfolioData");
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
typeof usePortfolioData
>;
// Mock framer-motion
jest.mock("framer-motion", () => {
const actual = jest.requireActual("framer-motion");
return {
...actual,
useReducedMotion: () => false,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
article: ({ children, ...props }: any) => (
<article {...props}>{children}</article>
),
},
};
});
// Mock Section component
jest.mock("./Section", () => ({
Section: ({ children, ...props }: any) => (
<section {...props}>{children}</section>
),
}));
// Mock animation components
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => <div 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 11: Semantic Heading Hierarchy", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any sequence of heading elements in the Experience section,
* the heading levels should follow proper semantic hierarchy (h2 for section
* title, h3 for experience card titles, no skipped levels). This ensures
* screen readers can properly navigate the document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.string(),
title: fc.string({ minLength: 1 }),
company: fc.string({ minLength: 1 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.string({ minLength: 1 }),
achievements: fc.array(fc.string({ minLength: 1 })),
technologies: fc.array(fc.string({ minLength: 1 })),
featured: fc.constant(true),
}),
{ minLength: 1, maxLength: 10 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<Experience />);
// Get all heading elements in order
const headings = Array.from(
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
);
if (headings.length === 0) {
return false;
}
// Extract heading levels
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
// Check for proper hierarchy:
// 1. Should start with h2 (section title)
// 2. Experience card titles should be h3
// 3. No skipped levels (difference should not be > 1)
// First heading should be h2 (section title)
if (levels[0] !== 2) {
return false;
}
// Check for skipped levels
for (let i = 1; i < levels.length; i++) {
const diff = levels[i] - levels[i - 1];
// Difference should be -1, 0, or 1 (no skipping levels)
if (Math.abs(diff) > 1 && diff > 0) {
return false;
}
}
// All experience card titles should be h3
const cardTitles = container.querySelectorAll("article h3");
if (cardTitles.length === 0) {
return false;
}
return true;
},
),
{ numRuns: 100 },
);
});
it("should not skip heading levels in the hierarchy", () => {
/**
* **Validates: Requirements 6.4**
*
* Property: For any generated set of experiences, the heading hierarchy
* should never skip levels (e.g., h2 directly to h4). This is critical
* for accessibility as screen reader users rely on heading levels to
* understand document structure.
*/
fc.assert(
fc.property(
fc.array(
fc.record({
id: fc.uuid(),
title: fc.lorem({ maxCount: 5 }),
company: fc.lorem({ maxCount: 2 }),
type: fc.constantFrom("Software", "Hardware"),
period: fc.string({ minLength: 1 }),
location: fc.string({ minLength: 1 }),
description: fc.lorem({ maxCount: 10 }),
achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }),
technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }),
featured: fc.constant(true),
}),
{ minLength: 2, maxLength: 8 },
),
(experiences) => {
mockUsePortfolioData.mockReturnValue({
data: {
experiences,
personal: {} as any,
projects: [],
skills: { technical: [], soft: [] },
contact: {} as any,
social: [],
},
loading: false,
error: null,
});
const { container } = render(<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 },
);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { render, screen, within } from "@testing-library/react";
import type { HTMLAttributes, ReactNode } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import Experience from "./Experience";
type MotionProps = HTMLAttributes<HTMLElement> & {
initial?: unknown;
whileInView?: unknown;
viewport?: unknown;
variants?: unknown;
whileHover?: unknown;
whileTap?: unknown;
transition?: unknown;
};
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>
);
return {
motion: {
div: createMotionElement("div"),
article: createMotionElement("article"),
},
useReducedMotion: () => false,
};
});
jest.mock("./Section", () => ({
Section: ({
children,
id,
title,
}: {
children?: ReactNode;
id?: string;
title?: string;
}) => (
<section id={id}>
<h2>{title}</h2>
{children}
</section>
),
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
function experience({
id,
title,
period,
featured,
}: {
id: string;
title: string;
period: string;
featured: boolean;
}): PortfolioExperience {
return {
id,
title,
company: "Empresa",
period,
location: "Remoto",
type: "Software",
description: "Descrição",
achievements: [],
technologies: [],
featured,
};
}
describe("Experience", () => {
it("renders only featured experiences from newest to oldest", () => {
render(
<Experience
experiences={[
experience({
id: "hidden",
title: "Oculta",
period: "2026 - Atual",
featured: false,
}),
experience({
id: "older",
title: "Mais antiga",
period: "2019 - 2021",
featured: true,
}),
experience({
id: "recent",
title: "Mais recente",
period: "2024 - Atual",
featured: true,
}),
]}
/>,
);
expect(
screen.queryByRole("heading", { name: "Oculta" }),
).not.toBeInTheDocument();
expect(
screen
.getAllByRole("article")
.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
),
).toEqual(["Mais recente", "Mais antiga"]);
});
});
+27 -36
View File
@@ -1,35 +1,24 @@
"use client";
import { useMemo } from "react";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
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 MagneticButton from "./cinematic/MagneticButton";
import { SectionLoading } from "./SectionLoading";
import { Section } from "./Section";
export default function Experience() {
const { data, loading, error } = usePortfolioData();
export default function Experience({
experiences = [],
}: {
experiences?: PortfolioExperience[];
}) {
const shouldReduceMotion = useReducedMotion();
const sortedExperiences = useMemo(() => {
if (!data?.experiences) return [];
return [...data.experiences]
.filter((exp) => exp.featured)
.sort((a, b) => {
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
return yearB - yearA;
});
}, [data]);
if (loading) {
return <SectionLoading message="Loading selected experience" />;
}
if (error || !data) {
return <div className="ds-error">Error loading selected experience</div>;
}
const sortedExperiences = [...experiences]
.filter((experience) => experience.featured)
.sort((a, b) => {
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
return yearB - yearA;
});
const listVariants: Variants = {
hidden: {},
@@ -59,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
@@ -90,7 +79,9 @@ export default function Experience() {
<div className="max-w-[720px]">
<div className="ds-kicker">{exp.period}</div>
<DrawLine delay={0.04} />
<h3 className="ds-card-title">{exp.title}</h3>
<h3 className="ds-card-title" data-reel="left">
{exp.title}
</h3>
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(10,10,10,.64)] dark:text-[rgba(255,255,255,.6)]">
{exp.company} • {exp.type}
</p>
@@ -98,7 +89,9 @@ export default function Experience() {
<div className="ds-metric">{exp.location}</div>
</div>
<p className="ds-copy">{exp.description}</p>
<p className="ds-copy" data-reel="right">
{exp.description}
</p>
<ul className="ds-list">
{exp.achievements?.map((achievement) => (
@@ -106,11 +99,11 @@ export default function Experience() {
))}
</ul>
<ul className="ds-tags mt-sp-6">
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{exp.technologies?.slice(0, 6).map((tech) => (
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
<li key={tech} className="ds-tag">
{tech}
</MagneticButton>
</li>
))}
</ul>
</motion.article>
@@ -119,5 +112,3 @@ export default function Experience() {
</Section>
);
}
@@ -1,300 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 9: ARIA Labels for Links", () => {
it("should have descriptive aria-label attributes for all social media links", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link in the Footer component, it should have
* a descriptive aria-label attribute that identifies the platform and action.
* This ensures accessibility for users with assistive technologies.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they have SVG icons and are in the Connect section)
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check each social link has a non-empty, descriptive aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
// aria-label must exist and be non-empty
if (!ariaLabel || ariaLabel.trim().length === 0) {
return false;
}
// aria-label should be descriptive (at least 5 characters)
// and should describe an action (contain words like "Visit", "View", "Go to", etc.)
return ariaLabel.length >= 5;
});
},
),
{ numRuns: 100 },
);
});
it("should have unique aria-label for each social platform", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any set of social media links, each should have a unique
* aria-label that identifies the specific platform. This ensures users can
* distinguish between different social links when using screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with aria-labels
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Collect all aria-labels
const ariaLabels = socialLinksWithSvg.map((link) =>
link.getAttribute("aria-label"),
);
// Verify we have aria-labels
if (ariaLabels.length === 0) {
return false;
}
// Check that all aria-labels are unique
const uniqueLabels = new Set(ariaLabels);
return uniqueLabels.size === ariaLabels.length;
}),
{ numRuns: 100 },
);
});
it("should have aria-label that identifies both platform and action", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link, the aria-label should contain both
* the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.).
* This provides complete context for assistive technology users.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Common action verbs for social links
const actionVerbs = ["visit", "view", "go to", "open", "see", "check"];
// Check each link has an action verb in its aria-label
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel) {
return false;
}
const lowerLabel = ariaLabel.toLowerCase();
// Check if aria-label contains at least one action verb
return actionVerbs.some((verb) => lowerLabel.includes(verb));
});
}),
{ numRuns: 100 },
);
});
it("should maintain aria-labels across multiple renders", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any number of component renders, social links should consistently
* maintain their aria-label attributes. This verifies that accessibility attributes
* are not accidentally removed during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
"footer a[aria-label]",
);
const socialLinksWithSvg = Array.from(socialLinks).filter(
(link) => {
return link.querySelector("svg") !== null;
},
);
// Check each link has a valid aria-label
socialLinksWithSvg.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
if (!ariaLabel || ariaLabel.trim().length < 5) {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it('should have aria-label for every social link with target="_blank"', () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social link that opens in a new tab (target="_blank"),
* it must have an aria-label. This ensures users with assistive technologies
* are informed about where the link leads before activating it.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links that open in new tab
const newTabLinks = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (newTabLinks.length === 0) {
return false;
}
// Each new tab link should have an aria-label
return Array.from(newTabLinks).every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should not have empty or whitespace-only aria-labels", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with an aria-label attribute,
* the value should not be empty or contain only whitespace. Empty
* aria-labels provide no accessibility benefit and can confuse screen readers.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links
const socialLinks = container.querySelectorAll("footer a[aria-label]");
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// Verify we found social links
if (socialLinksWithSvg.length === 0) {
return false;
}
// Check that no aria-label is empty or whitespace-only
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
return ariaLabel !== null && ariaLabel.trim().length > 0;
});
}),
{ numRuns: 100 },
);
});
it("should have aria-label that matches or relates to the link title", () => {
/**
* **Validates: Requirements 6.1**
*
* Property: For any social media link with both aria-label and title attributes,
* the aria-label should provide context that relates to the title (platform name).
* This ensures consistency between visual tooltips and screen reader announcements.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social media links with both aria-label and title
const socialLinks = container.querySelectorAll(
"footer a[aria-label][title]",
);
// Filter to only social links (those with SVG children)
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
return link.querySelector("svg") !== null;
});
// If no links have both attributes, that's okay (not all implementations require title)
if (socialLinksWithSvg.length === 0) {
return true;
}
// Check that aria-label relates to title
return socialLinksWithSvg.every((link) => {
const ariaLabel = link.getAttribute("aria-label");
const title = link.getAttribute("title");
if (!ariaLabel || !title) {
return false;
}
// aria-label should contain the platform name from title
// or title should be contained in aria-label
const lowerLabel = ariaLabel.toLowerCase();
const lowerTitle = title.toLowerCase();
return (
lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel)
);
});
}),
{ numRuns: 100 },
);
});
});
@@ -1,264 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 3: Responsive Component Layout", () => {
it("should include Tailwind responsive utility classes for grid layout", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, it should include Tailwind responsive
* utility classes (sm:, md:, lg:) to adapt the grid layout across viewport sizes.
* This ensures the component is responsive and provides optimal layout at different
* screen widths.
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find the main grid container
const gridContainer = container.querySelector("footer .grid");
// Verify grid container exists
if (!gridContainer) {
return false;
}
const className = gridContainer.className;
// Check for responsive grid-cols classes
// Should have base grid-cols-1 and responsive variants
const hasBaseGridCols = className.includes("grid-cols-1");
const hasMdResponsive = className.includes("md:grid-cols-");
const hasLgResponsive = className.includes("lg:grid-cols-");
return hasBaseGridCols && hasMdResponsive && hasLgResponsive;
},
),
{ numRuns: 100 },
);
});
it("should include responsive padding classes", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component, the container should use the design system
* section class (ds-section) which provides responsive padding that scales
* appropriately across viewport sizes. This ensures consistent spacing that
* adapts to different screen widths.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find the footer element
const footer = container.querySelector("footer");
if (!footer) {
return false;
}
const className = footer.className;
// Check for design system section class which handles responsive padding
const hasDsSection = className.includes("ds-section");
// The ds-section class in globals.css provides responsive padding via CSS
// padding: var(--sp-20) var(--sp-16) on desktop
// padding: var(--sp-16) var(--sp-6) on mobile (via @media query)
return hasDsSection;
}),
{ numRuns: 100 },
);
});
it("should include responsive gap classes for grid spacing", () => {
/**
* **Validates: Requirements 2.6**
*
* Property: For any Footer component grid, it should include gap
* classes to adjust spacing between grid items.
* This ensures optimal spacing that scales with the layout.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// 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 },
);
});
});
@@ -1,246 +0,0 @@
import { render } from "@testing-library/react";
import Footer from "./Footer";
import fc from "fast-check";
describe("Property 2: Social Links Open in New Tab", () => {
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social media link in the Footer component, it should have
* target="_blank" and rel="noopener noreferrer" attributes to open in a new tab
* securely. This ensures consistent behavior across all social links and prevents
* security vulnerabilities (tabnabbing).
*/
fc.assert(
fc.property(
fc.constant(null), // No random input needed, testing static component
() => {
const { container } = render(<Footer />);
// Find all social media links (they are within the "Connect" section)
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check each social link
let allLinksValid = true;
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
// Both attributes must be present and correct
if (target !== "_blank" || rel !== "noopener noreferrer") {
allLinksValid = false;
}
});
return allLinksValid;
},
),
{ numRuns: 100 },
);
});
it("should consistently apply security attributes across all social link variations", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any subset of social links rendered, each link should maintain
* the security attributes (target="_blank" and rel="noopener noreferrer").
* This property verifies that the security pattern is consistently applied
* regardless of which social platforms are included.
*/
fc.assert(
fc.property(
fc.integer({ min: 0, max: 10 }), // Random seed for variation
() => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Filter to only social links (those with SVG icons and aria-labels about visiting profiles)
const socialLinks = Array.from(allLinks).filter((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
return ariaLabel && ariaLabel.includes("Visit") && hasSvg;
});
// Verify we found social links
if (socialLinks.length === 0) {
return false;
}
// Check that ALL social links have the required attributes
return socialLinks.every((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
return target === "_blank" && rel === "noopener noreferrer";
});
},
),
{ numRuns: 100 },
);
});
it("should not apply new tab attributes to non-social links", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: Only social media links should have target="_blank". Other links
* in the footer (like email) should not force new tab behavior. This ensures
* the new tab behavior is intentionally applied only where appropriate.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Get all anchor tags in the footer
const allLinks = container.querySelectorAll("footer a");
// Separate social links from other links
const socialLinks: Element[] = [];
const otherLinks: Element[] = [];
allLinks.forEach((link) => {
const ariaLabel = link.getAttribute("aria-label");
const hasSvg = link.querySelector("svg") !== null;
if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) {
socialLinks.push(link);
} else {
otherLinks.push(link);
}
});
// All social links should have target="_blank"
const allSocialLinksHaveTarget = socialLinks.every((link) => {
return link.getAttribute("target") === "_blank";
});
// Non-social links (like email) should NOT have target="_blank"
const noOtherLinksHaveTarget = otherLinks.every((link) => {
return link.getAttribute("target") !== "_blank";
});
return allSocialLinksHaveTarget && noOtherLinksHaveTarget;
}),
{ numRuns: 100 },
);
});
it("should maintain security attributes after multiple renders", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any number of component renders, social links should consistently
* maintain their security attributes. This verifies that the attributes are not
* accidentally removed or modified during re-renders.
*/
fc.assert(
fc.property(
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
(numRenders) => {
let allRendersValid = true;
for (let i = 0; i < numRenders; i++) {
const { container, unmount } = render(<Footer />);
// Find social links
const socialLinks = container.querySelectorAll(
'a[aria-label*="Visit"]',
);
// Check each link
socialLinks.forEach((link) => {
const target = link.getAttribute("target");
const rel = link.getAttribute("rel");
if (target !== "_blank" || rel !== "noopener noreferrer") {
allRendersValid = false;
}
});
unmount();
}
return allRendersValid;
},
),
{ numRuns: 100 },
);
});
it("should have both target and rel attributes present together", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: For any social link, if target="_blank" is present, then
* rel="noopener noreferrer" must also be present. These attributes work
* together to provide secure new tab behavior, and one without the other
* creates a security vulnerability.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all links with target="_blank"
const linksWithTarget = container.querySelectorAll(
'footer a[target="_blank"]',
);
// Verify we found links
if (linksWithTarget.length === 0) {
return false;
}
// Each link with target="_blank" must have rel="noopener noreferrer"
return Array.from(linksWithTarget).every((link) => {
const rel = link.getAttribute("rel");
return rel === "noopener noreferrer";
});
}),
{ numRuns: 100 },
);
});
it("should have exactly the expected number of social links with security attributes", () => {
/**
* **Validates: Requirements 2.7**
*
* Property: The Footer should render a specific number of social links (based on
* the socialLinks array), and all of them should have the security attributes.
* This ensures no links are accidentally missing the attributes.
*/
fc.assert(
fc.property(fc.constant(null), () => {
const { container } = render(<Footer />);
// Find all social links with security attributes
const secureLinks = container.querySelectorAll(
'footer a[target="_blank"][rel="noopener noreferrer"]',
);
// Find all social links (with aria-label containing "Visit")
const allSocialLinks = container.querySelectorAll(
'footer a[aria-label*="Visit"]',
);
// The number of secure links should equal the number of social links
// This ensures ALL social links have the security attributes
return (
secureLinks.length === allSocialLinks.length &&
allSocialLinks.length > 0
);
}),
{ numRuns: 100 },
);
});
});
+59 -292
View File
@@ -1,323 +1,90 @@
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();
const emailLink = screen.getByRole("link", {
name: "[email protected]",
});
expect(emailLink).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("should render email address", () => {
it("renders quick links as same-tab navigation", () => {
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]");
expect(emailLink).toHaveAttribute("href", "mailto:[email protected]");
});
it("should apply correct styling classes to email link", () => {
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");
expect(screen.getByRole("link", { name: "Privacidade" })).toHaveAttribute(
"href",
"/privacidade",
);
});
});
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 />);
const copyrightHeading = screen.getByText("Informacoes legais");
expect(copyrightHeading).toBeInTheDocument();
expect(copyrightHeading.tagName).toBe("H3");
});
it("should display current year in copyright text", () => {
describe("Footer copyright", () => {
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();
});
});
+13 -11
View File
@@ -16,22 +16,23 @@ 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: "Privacidade", href: "/privacidade" },
{ 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 +51,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>
@@ -64,11 +65,11 @@ export default function Footer() {
</p>
<MagneticButton
as="a"
href="mailto:evandro@dev.br"
href="mailto:evandro[email protected]"
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
strength={0.2}
>
evandro@dev.br
evandrofonsecajunior@gmail.com
</MagneticButton>
</div>
</div>
@@ -79,14 +80,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>
+279
View File
@@ -0,0 +1,279 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
import GoogleAnalytics from "./GoogleAnalytics";
jest.mock("next/script", () => ({
__esModule: true,
default: ({
strategy: _strategy,
...props
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
}));
const CONSENT_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SRC = "https://www.googletagmanager.com/gtag/js?id=G-QTT9QT0T3S";
type AnalyticsWindow = Window & {
dataLayer?: IArguments[];
gtag?: (...args: unknown[]) => void;
[GA_DISABLE_KEY]?: boolean;
};
const analyticsWindow = window as AnalyticsWindow;
function consentUpdates(gtag: jest.Mock) {
return gtag.mock.calls.filter(
([command, action]) => command === "consent" && action === "update",
);
}
function setCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
document.cookie = value;
}
describe("GoogleAnalytics consent", () => {
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
document.getElementById("google-analytics-script")?.remove();
localStorage.clear();
setCookie("_ga=; Max-Age=0; Path=/");
setCookie("_ga_PORTFOLIO=; Max-Age=0; Path=/");
setCookie("portfolio_session=; Max-Age=0; Path=/");
analyticsWindow.dataLayer = undefined;
analyticsWindow.gtag = undefined;
analyticsWindow[GA_DISABLE_KEY] = undefined;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("waits for a decision before loading analytics", async () => {
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Aceitar analytics" }),
).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
});
it("persists acceptance and enables analytics without advertising consent", async () => {
const user = userEvent.setup();
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
expect(screen.getByTestId("google-analytics-script")).toHaveAttribute(
"src",
GA_SRC,
);
const dataLayer = analyticsWindow.dataLayer;
expect(dataLayer).toHaveLength(4);
expect(dataLayer?.every((entry) => !Array.isArray(entry))).toBe(true);
expect(Array.from(dataLayer?.[0] ?? [])).toEqual([
"consent",
"default",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
"consent",
"update",
{
analytics_storage: "granted",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
"config",
"G-QTT9QT0T3S",
{
allow_ad_personalization_signals: false,
anonymize_ip: true,
},
]);
expect(
screen.getByRole("button", { name: "Preferências de analytics" }),
).not.toHaveFocus();
});
it("persists refusal and keeps analytics absent after reload", async () => {
const user = userEvent.setup();
const firstRender = render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
firstRender.unmount();
render(<GoogleAnalytics />);
expect(
await screen.findByRole("button", { name: "Preferências de analytics" }),
).toBeVisible();
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
it("keeps preferences reachable and fully revokes analytics only", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
setCookie("_ga=main; Path=/");
setCookie("_ga_PORTFOLIO=property; Path=/");
setCookie("portfolio_session=keep; Path=/");
const gtag = jest.fn();
analyticsWindow.gtag = gtag;
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
expect(consentUpdates(gtag)).toContainEqual([
"consent",
"update",
{
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
},
]);
expect(document.cookie).not.toContain("_ga=");
expect(document.cookie).not.toContain("_ga_PORTFOLIO=");
expect(document.cookie).toContain("portfolio_session=keep");
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
const preferencesButton = screen.getByRole("button", {
name: "Preferências de analytics",
});
expect(preferencesButton).toBeVisible();
expect(preferencesButton).toHaveFocus();
});
it("restores focus after closing or applying opened preferences", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "denied");
render(<GoogleAnalytics />);
const preferencesButton = await screen.findByRole("button", {
name: "Preferências de analytics",
});
await user.click(preferencesButton);
expect(
screen.getByRole("link", { name: "Ler política de privacidade" }),
).toHaveAttribute("href", "/privacidade");
await user.click(
screen.getByRole("button", { name: "Fechar preferências" }),
);
expect(preferencesButton).toHaveFocus();
await user.click(preferencesButton);
await user.click(screen.getByRole("button", { name: "Aceitar analytics" }));
expect(preferencesButton).toHaveFocus();
});
it("fails closed when local storage is unavailable", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage blocked");
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Aceitar analytics" }),
);
await waitFor(() => {
expect(
screen.queryByTestId("google-analytics-script"),
).not.toBeInTheDocument();
});
});
it("reports when revocation cannot be persisted", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await screen.findByTestId("google-analytics-script");
await user.click(
screen.getByRole("button", { name: "Preferências de analytics" }),
);
await user.click(screen.getByRole("button", { name: "Revogar analytics" }));
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
expect(analyticsWindow[GA_DISABLE_KEY]).not.toBe(true);
expect(
screen.getByRole("button", { name: "Revogar analytics" }),
).toBeVisible();
});
it("reports when refusal cannot be persisted", async () => {
const user = userEvent.setup();
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage blocked");
});
render(<GoogleAnalytics />);
await user.click(
await screen.findByRole("button", { name: "Recusar analytics" }),
);
expect(
screen.getByRole("alert", {
name: "Falha ao salvar o consentimento",
}),
).toHaveTextContent(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
expect(
screen.getByRole("button", { name: "Recusar analytics" }),
).toBeVisible();
expect(
screen.queryByRole("button", { name: "Preferências de analytics" }),
).not.toBeInTheDocument();
});
});
+317 -19
View File
@@ -1,33 +1,331 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") {
const deniedConsent = {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
} as const;
type AnalyticsConsent = "granted" | "denied";
type Gtag = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: IArguments[];
"ga-disable-G-QTT9QT0T3S"?: boolean;
gtag?: Gtag;
}
}
function readConsent(): AnalyticsConsent | null {
try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
return value === "granted" || value === "denied" ? value : null;
} catch {
return null;
}
}
function persistConsent(value: AnalyticsConsent): boolean {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, value);
return true;
} catch {
return false;
}
}
function getGtag(): Gtag {
window.dataLayer ??= [];
const dataLayer = window.dataLayer;
if (!window.gtag) {
window.gtag = function gtag() {
// biome-ignore lint/complexity/noArguments: The official gtag bootstrap requires an IArguments entry, not an Array.
dataLayer.push(arguments);
};
}
return window.gtag;
}
function removeAnalyticsLoader() {
document.getElementById("google-analytics-script")?.remove();
}
function writeCookie(value: string) {
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store is not universal and domain-scoped GA cookies must also be expired.
document.cookie = value;
}
function expireAnalyticsCookies() {
const cookieNames = new Set(
document.cookie
.split(";")
.map((cookie) => cookie.split("=")[0]?.trim())
.filter(
(name): name is string =>
name === "_ga" || Boolean(name?.startsWith("_ga_")),
),
);
const { hostname } = window.location;
const domains =
hostname === SITE_DOMAIN || hostname.endsWith(`.${SITE_DOMAIN}`)
? new Set([hostname, SITE_DOMAIN])
: new Set<string>();
for (const name of cookieNames) {
const expiration = `${name}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
writeCookie(expiration);
for (const domain of domains) {
writeCookie(`${expiration}; Domain=${domain}`);
}
}
}
const panelClassName =
"fixed bottom-sp-4 left-sp-4 right-sp-4 z-[9980] ml-auto max-w-[560px] border border-[var(--slash)] bg-[var(--ink)] p-sp-5 text-white shadow-[8px_8px_0_var(--slash)] sm:left-auto";
const buttonClassName =
"inline-flex min-h-11 min-w-11 items-center justify-center border px-sp-4 py-sp-2 font-mono text-[11px] font-bold uppercase tracking-[0.08em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
function GoogleAnalyticsConsent() {
const [consent, setConsent] = useState<AnalyticsConsent | null>();
const [preferencesOpen, setPreferencesOpen] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const configuredRef = useRef(false);
const preferencesButtonRef = useRef<HTMLButtonElement>(null);
const enableAnalytics = useCallback(() => {
const gtag = getGtag();
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("js", new Date());
configuredRef.current = true;
}
gtag("consent", "update", {
...deniedConsent,
analytics_storage: "granted",
});
gtag("config", GA_MEASUREMENT_ID, {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
}, []);
useEffect(() => {
const storedConsent = readConsent();
if (storedConsent === "granted") enableAnalytics();
setConsent(storedConsent);
}, [enableAnalytics]);
useEffect(() => {
if (consent !== "granted" && consent !== undefined) {
removeAnalyticsLoader();
}
}, [consent]);
function closePreferences() {
setPreferencesOpen(false);
preferencesButtonRef.current?.focus();
}
function acceptAnalytics() {
if (!persistConsent("granted")) {
setConsentError(
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
enableAnalytics();
setConsent("granted");
closePreferences();
}
function denyAnalytics() {
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar sua recusa. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
setConsent("denied");
closePreferences();
}
function revokeAnalytics() {
if (!persistConsent("denied")) {
setConsentError(
"Não foi possível salvar a revogação. Tente novamente para concluir.",
);
return;
}
setConsentError(null);
window[GA_DISABLE_KEY] = true;
window.gtag?.("consent", "update", deniedConsent);
expireAnalyticsCookies();
setConsent("denied");
closePreferences();
}
if (consent === undefined) return null;
if (consent === null) {
return (
<section
role="dialog"
aria-modal="false"
aria-labelledby="analytics-consent-title"
aria-describedby="analytics-consent-description"
className={panelClassName}
>
<p
id="analytics-consent-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Privacidade // Analytics"}
</p>
<p
id="analytics-consent-description"
className="mt-sp-2 max-w-[48ch] text-[13px] leading-6 text-white/85"
>
Você decide se permite métricas anônimas para melhorar este site.
Publicidade e personalização continuam desativadas.
</p>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{consentError}
</p>
)}
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar analytics
</button>
</div>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
);
}
return (
<>
<Script src="/sys/libs/vendor-utils.js" strategy="lazyOnload" />
<Script id="google-analytics" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_MEASUREMENT_ID}', {
transport_url: window.location.origin + '/sys/api/vitals',
cookie_flags: 'SameSite=None;Secure',
allow_ad_personalization_signals: false,
anonymize_ip: true,
page_location: window.location.href,
page_title: document.title
});
`}
</Script>
{consent === "granted" && (
<Script
id="google-analytics-script"
data-testid="google-analytics-script"
src={GA_SCRIPT_SRC}
strategy="afterInteractive"
/>
)}
<button
type="button"
ref={preferencesButtonRef}
aria-expanded={preferencesOpen}
aria-controls="analytics-preferences"
className={`${buttonClassName} fixed bottom-sp-4 right-sp-4 z-[9980] border-white/25 bg-[var(--ink)] text-white shadow-[4px_4px_0_var(--slash)]`}
onClick={() => setPreferencesOpen((open) => !open)}
>
Preferências de analytics
</button>
{preferencesOpen && (
<section
id="analytics-preferences"
role="dialog"
aria-modal="false"
aria-labelledby="analytics-preferences-title"
aria-describedby="analytics-preferences-description"
className={panelClassName}
>
<p
id="analytics-preferences-title"
className="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-[var(--slash)]"
>
{"Preferências // Analytics"}
</p>
<p
id="analytics-preferences-description"
className="mt-sp-2 text-[13px] leading-6 text-white/85"
>
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
Dados de publicidade permanecem negados.
</p>
{consentError && (
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mt-sp-3 text-[13px] leading-6 text-[var(--slash)]"
>
{consentError}
</p>
)}
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
{consent === "granted" ? (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={revokeAnalytics}
>
Revogar analytics
</button>
) : (
<button
type="button"
className={`${buttonClassName} border-[var(--slash)] bg-[var(--slash)] text-[var(--ink)]`}
onClick={acceptAnalytics}
>
Aceitar analytics
</button>
)}
<button
type="button"
className={`${buttonClassName} border-white/40 bg-transparent text-white`}
onClick={closePreferences}
>
Fechar preferências
</button>
</div>
<a
className="ds-link ds-link-accent mt-sp-3 inline-block"
href="/privacidade"
>
Ler política de privacidade
</a>
</section>
)}
</>
);
}
export default function GoogleAnalytics() {
if (process.env.NODE_ENV !== "production") return null;
return <GoogleAnalyticsConsent />;
}
+135
View File
@@ -0,0 +1,135 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { type ReactNode, useEffect } from "react";
import { useHomeFragmentNavigation } from "./cinematic/useHomeFragmentNavigation";
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 };
});
function HomeWithHeader() {
const onLayoutReady = useHomeFragmentNavigation();
useEffect(() => {
onLayoutReady();
}, [onLayoutReady]);
return (
<>
<Header />
<section id="about" aria-label="Sobre">
<h2>Sobre</h2>
</section>
</>
);
}
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("link", { name: "Sobre" })).toHaveAttribute(
"href",
"/#about",
);
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
"href",
"/#contact",
);
});
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(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
fireEvent.click(menuButton);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
"aria-expanded",
"true",
);
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
expect(
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
mobileLink.focus();
fireEvent.keyDown(mobileLink, { key: "Escape" });
const openButton = screen.getByRole("button", { name: "Abrir menu" });
expect(openButton).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).toBe(openButton);
});
it("closes the mobile menu without taking focus away from a home destination", () => {
window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
});
mobileLink.focus();
fireEvent.click(mobileLink);
expect(screen.getByRole("button", { name: "Abrir menu" })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
});
});
+70 -50
View File
@@ -1,35 +1,46 @@
"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);
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeMobileMenu();
menuButtonRef.current?.focus();
};
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 +56,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-11 w-11 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 +88,49 @@ 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();
menuButtonRef.current?.focus();
}}
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>
);
}
+53 -95
View File
@@ -1,101 +1,59 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { act } from "@testing-library/react";
import { useReducedMotion } from "motion/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import Hero from "./Hero";
let mockReducedMotion = false;
jest.mock("motion/react", () => ({
...jest.requireActual("motion/react"),
useReducedMotion: jest.fn(),
}));
jest.mock("next/dynamic", () => () => () => null);
jest.mock("./cinematic/ControlRoomGlobe", () => ({
ControlRoomGlobe: () => null,
}));
jest.mock("./cinematic/HeroStarfield", () => ({
HeroStarfield: () => null,
}));
jest.mock("framer-motion", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
initial,
animate,
whileHover,
whileTap,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: unknown;
whileHover?: unknown;
whileTap?: unknown;
} & Record<string, unknown>) => {
const dataProps = {
...(initial !== undefined
? { "data-initial": JSON.stringify(initial) }
: {}),
...(animate !== undefined
? { "data-animate": JSON.stringify(animate) }
: {}),
...(whileHover !== undefined
? { "data-while-hover": JSON.stringify(whileHover) }
: {}),
...(whileTap !== undefined
? { "data-while-tap": JSON.stringify(whileTap) }
: {}),
};
return React.createElement(tag, { ...props, ...dataProps }, children);
};
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return {
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
motion,
useInView: () => true,
useReducedMotion: () => mockReducedMotion,
};
afterEach(() => {
jest.restoreAllMocks();
});
describe("Hero reduced motion", () => {
afterEach(() => {
mockReducedMotion = false;
});
it.each([false, true])(
"hydrates without attribute mismatches when reduced motion resolves to %s",
async (reducedMotion) => {
jest.spyOn(window, "matchMedia").mockImplementation((media) => ({
media,
matches: media === "(prefers-reduced-motion: reduce)" && reducedMotion,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
const preference = jest.mocked(useReducedMotion);
preference.mockReturnValue(null);
const container = document.createElement("div");
container.innerHTML = renderToString(<Hero />);
document.body.append(container);
it("removes hover/tap scale animations when reduced motion is enabled", () => {
mockReducedMotion = true;
const { container } = render(<Hero />);
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
expect(primaryAction).not.toHaveAttribute("data-while-hover");
expect(primaryAction).not.toHaveAttribute("data-while-tap");
expect(secondaryAction).not.toHaveAttribute("data-while-hover");
expect(secondaryAction).not.toHaveAttribute("data-while-tap");
const rootMotionBlock = container.querySelector(
"div[data-initial][data-animate]",
);
expect(rootMotionBlock).toBeInTheDocument();
expect(rootMotionBlock?.getAttribute("data-initial")).toContain(
'"opacity":0',
);
expect(rootMotionBlock?.getAttribute("data-animate")).toContain(
'"opacity":1',
);
});
it("keeps essential hero content visible with reduced motion enabled", () => {
mockReducedMotion = true;
render(<Hero />);
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
expect(
screen.getByText(/Available for selected builds/i),
).toBeInTheDocument();
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
});
});
preference.mockReturnValue(reducedMotion);
const consoleErrors = jest.spyOn(console, "error").mockImplementation();
const recoverableErrors: unknown[] = [];
let root: Root | undefined;
try {
await act(async () => {
root = hydrateRoot(container, <Hero />, {
onRecoverableError: (error) => recoverableErrors.push(error),
});
});
expect(recoverableErrors).toEqual([]);
expect(consoleErrors.mock.calls).toEqual([]);
} finally {
await act(async () => root?.unmount());
container.remove();
}
},
);
+122 -25
View File
@@ -1,9 +1,15 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import Hero from "./Hero";
jest.mock("framer-motion", () => {
jest.mock("next/dynamic", () => () => {
const DynamicComponent = () => <div data-testid="hero-scene" />;
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
@@ -12,6 +18,12 @@ jest.mock("framer-motion", () => {
get: (_, tag: string) => {
const Component = ({
children,
initial: _initial,
animate: _animate,
whileHover: _whileHover,
whileTap: _whileTap,
variants: _variants,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
@@ -26,37 +38,122 @@ 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,
value: jest.fn().mockImplementation((query) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("Hero", () => {
it("renders the upgraded hero content and primary actions", () => {
beforeEach(() => {
mockMatchMedia(false);
jest.spyOn(window, "open").mockImplementation(() => null);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders the published command-center content and primary actions", () => {
render(<Hero />);
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
expect(
screen.getByText(
/Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i,
),
screen.getByText("Disponível para novas oportunidades"),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
level: 1,
name: "Engenharia de software para sistemas que precisam operar de verdade.",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: "Conversar sobre uma oportunidade",
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Agendar no WhatsApp" }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver casos selecionados" }),
).toHaveAttribute("href", "/cases");
expect(screen.getByRole("link", { name: "Download CV" })).toHaveAttribute(
"href",
"/Profile.pdf",
);
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
it("keeps social destinations, operational proof and WhatsApp action", () => {
render(<Hero />);
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
"href",
"https://github.com/evandrodevbr",
);
expect(screen.getByRole("link", { name: "LinkedIn" })).toHaveAttribute(
"href",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
);
expect(screen.getByRole("link", { name: "Email" })).toHaveAttribute(
"href",
"mailto:[email protected]",
);
expect(screen.getByText("Garuva, SC / Brasil")).toBeInTheDocument();
expect(screen.getByText("Operação")).toBeInTheDocument();
expect(screen.getByText("Clareza")).toBeInTheDocument();
expect(screen.getByText("Resultado")).toBeInTheDocument();
expect(
screen.getByRole("region", {
name: "Telemetria operacional em tempo real",
}),
).toBeInTheDocument();
expect(
screen.getByText(/Globo interativo centrado em Joinville/i),
).toBeInTheDocument();
expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument();
expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument();
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
});
it("renders social links and proof content", () => {
render(<Hero />);
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
expect(screen.getByText(/Especialidade/i)).toBeInTheDocument();
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", {
name: "Conversar sobre uma oportunidade",
}),
);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining("https://wa.me/"),
"_blank",
"noopener,noreferrer",
);
});
});
+219 -135
View File
@@ -1,8 +1,16 @@
"use client";
import { motion, type Variants } from "motion/react";
import dynamic from "next/dynamic";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { CV_URL } from "@/lib/site-navigation";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
import { HeroStarfield } from "./cinematic/HeroStarfield";
import MagneticButton from "./cinematic/MagneticButton";
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
ssr: false,
@@ -36,50 +44,102 @@ const SOCIAL_LINKS = [
},
];
const HERO_POINTS = [
"Produtos conectados, integracoes complexas e operacoes criticas.",
"Arquitetura mais clara para evoluir sem aumentar o caos tecnico.",
"Execucao ponta a ponta, do discovery a entrega em producao.",
];
const HERO_CONTENT = {
status: "Disponível para novas oportunidades",
chip: "Garuva · Joinville · Curitiba · Remoto",
eyebrow:
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
headline:
"Engenharia de software para sistemas que precisam operar de verdade.",
subtitle:
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
cta: "Conversar sobre uma oportunidade",
whatsappMessage:
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
sideLabel: "Contato profissional direto",
sideTitle: "Procurando um engenheiro da computação para o seu time?",
sideText:
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
points: [
"Engenharia full-stack com base em sistemas e integrações.",
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
],
proof: [
{
label: "Operação",
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
},
{
label: "Clareza",
value: "Menos ruído entre produto, engenharia e decisão técnica.",
},
{
label: "Resultado",
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
},
],
} as const;
const PROOF_ITEMS = [
{
label: "Quem eu ajudo",
value:
"Empresas que dependem de integracoes, hardware ou fluxos criticos para operar com seguranca e previsibilidade.",
},
{
label: "Como entrego",
value:
"Traduzo complexidade tecnica em backlog claro, arquitetura legivel e decisao mais rapida para o time.",
},
{
label: "Por que conversar",
value:
"Voce ganha um parceiro tecnico capaz de conectar produto, backend, interface e operacao sem perder contexto.",
},
];
function getGreeting(message: string) {
const hour = new Date().getHours();
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
if (hour >= 13 && hour < 18) return `Boa tarde! ${message}`;
return message;
}
export default function Hero() {
const heroRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion();
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [isHeroVisible, setIsHeroVisible] = useState(true);
const hour = new Date().getHours();
const greeting =
hour >= 8 && hour < 12
? "Ola! Vi seu portfolio. Podemos conversar?"
: hour >= 13 && hour < 18
? "Boa tarde! Vi seu portfolio. Podemos conversar?"
: "Ola! Vi seu portfolio. Podemos conversar quando estiver disponivel?";
useEffect(() => {
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
if (
!shouldLoadHeroScene({
reducedMotion,
coarsePointer,
width: window.innerWidth,
})
) {
return;
}
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
const hero = heroRef.current;
if (!hero || !("IntersectionObserver" in window)) return undefined;
const observer = new IntersectionObserver(
([entry]) => setIsHeroVisible(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(hero);
return () => observer.disconnect();
}, []);
const handleWhatsAppClick = () => {
window.open(
buildWhatsAppUrl(getGreeting(HERO_CONTENT.whatsappMessage)),
"_blank",
"noopener,noreferrer",
);
};
// Cinematic stagger variants
const heroContainer: Variants = {
hidden: { opacity: 0 },
hidden: shouldReduceMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.3,
},
transition: shouldReduceMotion
? { duration: 0 }
: { staggerChildren: 0.1, delayChildren: 0.3 },
},
};
@@ -87,47 +147,42 @@ export default function Hero() {
const heroItem: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 40, filter: "blur(10px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
duration: 1,
ease: easeOut,
},
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1, ease: easeOut },
},
};
const heroItemSlow: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 60, filter: "blur(12px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: {
duration: 1.2,
ease: easeOut,
},
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1.2, ease: easeOut },
},
};
return (
<section
ref={heroRef}
id="home"
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
>
{/* 3D Scene Background */}
{!shouldReduceMotion && <HeroScene />}
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
{/* Cinematic overlays */}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
{/* Scanlines effect */}
<div
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
style={{
@@ -135,19 +190,17 @@ export default function Hero() {
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
}}
/>
{/* Vignette */}
<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"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={heroContainer}
className="grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10"
className="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"
>
<div className="max-w-[760px]">
{/* Status badge with glow */}
<div className="max-w-[800px]">
<motion.div
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
variants={heroItem}
@@ -178,62 +231,58 @@ export default function Hero() {
}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
Available for selected builds
{HERO_CONTENT.status}
</span>
</div>
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
Produtos conectados, integracoes criticas e entrega ponta a ponta
{HERO_CONTENT.chip}
</div>
</motion.div>
{/* Eyebrow */}
<motion.p
className="mb-sp-4 max-w-[540px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
variants={heroItem}
>
Evandro Fonseca Junior | Engenheiro de software full-stack
{HERO_CONTENT.eyebrow}
</motion.p>
{/* Main heading with cinematic reveal */}
<motion.h1
className="max-w-[920px] 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}
>
<span className="cinematic-text-gradient">
Engenharia de software para sistemas que nao podem falhar.
</span>
{HERO_CONTENT.headline}
</motion.h1>
{/* Subtitle */}
<motion.p
className="mt-sp-6 max-w-[680px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
variants={heroItem}
>
Ajudo empresas com produtos conectados, integracoes complexas e
operacoes criticas a transformar arquitetura confusa em software
estavel, mais rapido de evoluir e muito mais facil de operar.
{HERO_CONTENT.subtitle}
</motion.p>
{/* Key points with staggered reveal */}
<motion.ul
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
variants={heroItem}
>
{HERO_POINTS.map((point, i) => (
{HERO_CONTENT.points.map((point, i) => (
<motion.li
key={point}
className="cinematic-glass flex min-w-0 gap-sp-3 px-sp-4 py-sp-4 text-[13px] leading-6 text-[rgba(244,242,237,.82)]"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, y: 30, scale: 0.95 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}
}
>
<span className="mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--slash)] shadow-[0_0_8px_rgba(204,255,0,0.5)]" />
<span>{point}</span>
@@ -241,26 +290,24 @@ export default function Hero() {
))}
</motion.ul>
{/* CTAs with magnetic effect */}
<motion.div
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
variants={heroItem}
>
<MagneticButton
as="a"
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
as="button"
type="button"
onClick={handleWhatsAppClick}
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
data-cursor="View"
strength={0.25}
>
<span className="relative z-10">Agendar conversa tecnica</span>
<span className="relative z-10">{HERO_CONTENT.cta}</span>
</MagneticButton>
<MagneticButton
as="a"
href="/cases"
className="cinematic-cta-secondary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#f4f2ed] no-underline"
data-cursor="View"
strength={0.25}
>
Ver casos selecionados
@@ -268,7 +315,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}
@@ -277,7 +324,6 @@ export default function Hero() {
</MagneticButton>
</motion.div>
{/* Footer info with reveal */}
<motion.div
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.06)] pt-sp-6"
variants={heroItem}
@@ -296,6 +342,7 @@ export default function Hero() {
key={link.label}
as="a"
href={link.href}
aria-label={link.label}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
@@ -322,66 +369,93 @@ export default function Hero() {
</motion.div>
</div>
{/* Aside card with cinematic entrance */}
<motion.aside
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
}
animate={
animate={{ opacity: 1, x: 0, rotateY: 0 }}
transition={
shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, x: 0, rotateY: 0 }
? { duration: 0 }
: {
duration: 1.2,
delay: 0.5,
ease: [0.16, 1, 0.3, 1],
}
}
transition={{
duration: 1.2,
delay: 0.5,
ease: [0.16, 1, 0.3, 1],
}}
>
{/* Top glow line */}
<motion.div
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
initial={{ scaleX: 0 }}
initial={shouldReduceMotion ? false : { scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 1.5,
delay: 1,
ease: [0.16, 1, 0.3, 1],
}
}
/>
<div className="mb-sp-6 space-y-sp-4">
<HeroStarfield />
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
<TelemetryStrip reducedMotion={shouldReduceMotion} />
</div>
<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)]">
Why this positioning works
{HERO_CONTENT.sideLabel}
</div>
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
Menos atrito entre produto, engenharia e operacao.
{HERO_CONTENT.sideTitle}
</h2>
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
Entro onde a complexidade cresce e ajudo a devolver clareza,
ritmo de entrega e confiabilidade para o sistema e para o time.
{HERO_CONTENT.sideText}
</p>
<button
type="button"
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)]"
>
{/* Í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>
<div className="mt-sp-6 space-y-sp-5">
{PROOF_ITEMS.map((item, index) => (
{HERO_CONTENT.proof.map((item, index) => (
<motion.article
key={item.label}
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
initial={
shouldReduceMotion
? { opacity: 0 }
? false
: { opacity: 0, y: 20, filter: "blur(6px)" }
}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
}}
transition={{
duration: 0.8,
delay: 0.8 + index * 0.12,
ease: [0.16, 1, 0.3, 1],
}}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.8,
delay: 0.8 + index * 0.12,
ease: [0.16, 1, 0.3, 1],
}
}
>
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.56)]">
{item.label}
@@ -396,27 +470,37 @@ export default function Hero() {
</motion.div>
</div>
{/* Scroll indicator */}
<motion.div
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
initial={{ opacity: 0, y: -10 }}
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2, duration: 1 }}
transition={
shouldReduceMotion ? { duration: 0 } : { 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-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
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-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.68)]">
Scroll
</span>
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
</motion.div>
)}
</motion.div>
</section>
);
+29
View File
@@ -0,0 +1,29 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LighthouseMetrics from "./LighthouseMetrics";
it("updates the inspection criteria while preserving keyboard navigation", async () => {
const user = userEvent.setup();
render(<LighthouseMetrics />);
const report = screen.getByRole("region", { name: "Inspeção de qualidade" });
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
const accessibility = screen.getByRole("radio", { name: /Acessibilidade/ });
await user.click(accessibility);
expect(accessibility).toHaveFocus();
expect(within(report).getByRole("list")).toHaveTextContent(/teclado/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Boas práticas/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/consentimento/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /SEO/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/Sitemap/);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("radio", { name: /Performance/ })).toBeChecked();
expect(within(report).getByRole("list")).toHaveTextContent(/JavaScript/);
});
+312 -78
View File
@@ -1,109 +1,343 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { Section } from "./Section";
import { useState } from "react";
const METRICS = [
{
score: 100,
id: "performance",
label: "Performance",
code: "LCP / INP / CLS",
title: "O essencial chega primeiro.",
description:
"Cada milissegundo conta. Tempos de carregamento instantâneos retêm mais usuários, aumentam a conversão e garantem que a interface pareça nativa.",
"O carregamento começa pelo que importa. Imagens, fontes e JavaScript precisam trabalhar a favor da experiência, não competir com ela.",
checks: [
"Conteúdo principal com prioridade de carregamento",
"Dimensões reservadas para evitar saltos de layout",
"JavaScript carregado onde faz diferença",
],
caption: "Em foco: o conteúdo principal e a estabilidade da página.",
},
{
score: 100,
id: "accessibility",
label: "Acessibilidade",
code: "TECLADO / FOCO / SEMÂNTICA",
title: "A interface inclui mais pessoas.",
description:
"Acesso universal. Contraste perfeito, navegação por teclado e semântica adequada garantindo o uso por todos os perfis e mitigando riscos.",
"Uma boa interface não exige um jeito único de navegar. Teclado, leitores de tela e preferências de movimento fazem parte do projeto.",
checks: [
"Foco visível e navegação por teclado",
"Controles com nomes e estrutura semântica",
"Contraste legível e movimento reduzido",
],
caption:
"Em foco: controles reconhecíveis e um caminho claro pelo teclado.",
},
{
score: 100,
label: "Best Practices",
id: "practices",
label: "Boas práticas",
code: "HTTPS / PRIVACIDADE / DEPENDÊNCIAS",
title: "Confiança também se constrói.",
description:
"Código seguro e moderno. Uso das APIs mais recentes, prevenção inteligente contra vulnerabilidades comuns e front-end limpo.",
"Cuidar da entrega também é cuidar dos dados, das dependências e dos limites do navegador. Segurança não se resume a uma nota.",
checks: [
"Conexões seguras e políticas do navegador",
"Analytics condicionado ao consentimento",
"Dependências verificadas e atualizadas",
],
caption: "Em foco: a conexão e os limites de acesso aos dados.",
},
{
score: 100,
id: "seo",
label: "SEO",
code: "HTML / METADATA / INDEXAÇÃO",
title: "Conteúdo pronto para ser encontrado.",
description:
"Rankeamento orgânico superior. Estrutura impecável para indexação precisa pelos motores de busca e otimização para crawlers sociais.",
"Estrutura antes de promessas. Conteúdo claro, títulos coerentes e dados estruturados ajudam pessoas e buscadores a entender cada página.",
checks: [
"Títulos, descrições e URLs canônicas",
"HTML semântico e conteúdo rastreável",
"Sitemap e dados estruturados factuais",
],
caption: "Em foco: a estrutura que dá contexto ao conteúdo.",
},
];
const BLUEPRINT = (
<svg
className="quality-blueprint"
viewBox="0 0 640 440"
fill="none"
aria-hidden="true"
focusable="false"
>
<g className="quality-dimensions">
<path d="M64 68V48m512 20V48M64 58h512M44 86H24m20 284H24M34 86v284" />
<path d="m60 54 8 8m504-8 8 8M30 82l8 8m-8 276 8 8" />
<text x="304" y="47">
RESPONSIVE
</text>
<path d="M64 390v18m512-18v18M64 400h180m152 0h180" />
<text x="272" y="405">
WEB / BLUEPRINT
</text>
</g>
<rect
className="quality-browser-frame"
x="64"
y="86"
width="512"
height="284"
rx="8"
/>
<path className="quality-wire" d="M64 123h512" />
<g className="quality-browser-dots">
<circle cx="83" cy="105" r="3" />
<circle cx="95" cy="105" r="3" />
<circle cx="107" cy="105" r="3" />
</g>
<rect
className="quality-wire"
x="142"
y="96"
width="350"
height="18"
rx="3"
/>
<text className="quality-address" x="158" y="109">
evandro.dev.br
</text>
<path className="quality-wire" d="M539 100h15m-15 5h15m-15 5h15" />
<text className="quality-browser-heading" x="96" y="176">
Engenharia
</text>
<text className="quality-browser-heading" x="96" y="203">
que funciona.
</text>
<path className="quality-wire" d="M96 222h170M96 230h130" />
<rect
className="quality-browser-button"
x="96"
y="254"
width="142"
height="32"
rx="2"
/>
<text className="quality-browser-button-label" x="112" y="274">
EXPLORAR PROJETO
</text>
<rect
className="quality-wire"
x="330"
y="144"
width="216"
height="142"
rx="3"
/>
<g className="quality-wire">
<path d="m438 164 58 33v55l-58 33-58-33v-55zM380 197l58 33 58-33m-58 33v55m0-121v66" />
<path d="m409 181 58 33v55m-58 0v-55l58-33" />
<path d="M96 306h450M96 324h113m-113 10h80m84-10h113m-113 10h80m84-10h122m-122 10h80" />
</g>
<g className="quality-layer" data-layer="performance">
<rect
className="quality-highlight"
x="318"
y="133"
width="240"
height="165"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M558 216h43V59h-91" />
<circle cx="558" cy="216" r="4" />
<text x="393" y="63">
LCP / CONTEÚDO
</text>
<path d="M330 299v12m216-12v12m-216-6h216" />
<rect
className="quality-annotation"
x="368"
y="311"
width="154"
height="18"
/>
<text x="376" y="323">
ESPAÇO RESERVADO
</text>
</g>
<g className="quality-layer" data-layer="accessibility">
<rect
className="quality-highlight quality-focus-frame"
x="88"
y="246"
width="158"
height="48"
rx="5"
/>
<path className="quality-trace" pathLength="1" d="M88 270H50v149h20" />
<circle cx="88" cy="270" r="4" />
<text x="79" y="424">
TAB / FOCO VISÍVEL
</text>
<rect x="260" y="259" width="45" height="24" rx="3" />
<text x="270" y="275">
TAB
</text>
</g>
<g className="quality-layer" data-layer="practices">
<rect
className="quality-highlight"
x="134"
y="91"
width="366"
height="28"
rx="4"
/>
<path className="quality-trace" pathLength="1" d="M500 105h101v314h-90" />
<circle cx="500" cy="105" r="4" />
<path
className="quality-highlight"
d="m499 220 36 13v32c0 25-36 41-36 41s-36-16-36-41v-32z"
/>
<rect x="487" y="255" width="24" height="20" rx="2" />
<path d="M491 255v-7a8 8 0 0 1 16 0v7m-8 6v7" />
<text x="290" y="424">
HTTPS / CONSENTIMENTO
</text>
</g>
<g className="quality-layer" data-layer="seo">
<rect
className="quality-highlight"
x="85"
y="144"
width="224"
height="95"
rx="3"
/>
<path className="quality-trace" pathLength="1" d="M85 159H48V58h135" />
<circle cx="85" cy="159" r="4" />
<text x="74" y="49">
HTML / SEMÂNTICA
</text>
<text x="99" y="140">
&lt;h1&gt;
</text>
<path d="M331 317h213v29H331z" />
<text x="350" y="337">
&lt;article&gt; / &lt;nav&gt;
</text>
</g>
</svg>
);
export default function LighthouseMetrics() {
const shouldReduceMotion = useReducedMotion();
const [active, setActive] = useState(METRICS[0]);
return (
<Section
<section
id="lighthouse"
eyebrow="Quality & Performance"
title="Engenharia de ponta a ponta: 100/100 não é vaidade."
description="Sistemas não podem apenas parecer bons. Eles precisam ser consistentes, rápidos, acessíveis, seguros e fáceis de processar. Entregar métricas máximas significa que a fundação técnica do projeto está preparada para escalar sem fricção."
dark
disableChildrenAnimation={false}
className="ds-section ds-section-dark quality-lab"
aria-labelledby="quality-title"
data-inspection={active.id}
>
<div className="mt-sp-4 grid grid-cols-1 gap-sp-4 md:grid-cols-2 lg:grid-cols-4">
{METRICS.map((metric, i) => (
<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 }}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: i * 0.1 }}
>
{/* The Ring Container */}
<div className="relative flex h-[100px] w-[100px] shrink-0 items-center justify-center rounded-full bg-[rgba(0,0,0,0.3)] shadow-inner">
<svg
className="absolute inset-0 h-full w-full rotate-[-90deg] drop-shadow-[0_0_12px_rgba(12,206,107,0.35)]"
viewBox="0 0 36 36"
aria-hidden="true"
>
{/* Background Track */}
<path
className="text-[rgba(255,255,255,0.1)]"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
{/* Animated Value Track */}
<motion.path
className="text-[#0cce6b]"
initial={{ strokeDasharray: "0, 100" }}
whileInView={{ strokeDasharray: `${metric.score}, 100` }}
viewport={{ once: true }}
transition={{
duration: 1.5,
ease: "easeOut",
delay: i * 0.15 + 0.2,
}}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
<div className="font-mono text-[24px] font-bold tracking-tight text-[#0cce6b]">
{metric.score}
</div>
</div>
<div className="ds-section-shell">
<div className="quality-intro">
<header className="quality-copy">
<p className="ds-eyebrow">Quality &amp; Performance</p>
<h2 id="quality-title" className="ds-section-title">
Engenharia de ponta a ponta.
<span>Qualidade em cada camada.</span>
</h2>
<p>
Sistemas precisam mais do que uma boa aparência. Explore as
decisões que tornam uma interface rápida, acessível, confiável e
fácil de encontrar.
</p>
<a
className="ds-link ds-link-accent"
href="/servicos/consultoria-nextjs"
>
Conversar sobre a qualidade do seu projeto
</a>
</header>
{/* Content Details */}
<div className="flex-1">
<h3 className="font-display text-[17px] font-bold text-[rgba(255,255,255,0.92)]">
{metric.label}
</h3>
<p className="mt-sp-3 text-[13px] leading-[1.8] text-[rgba(255,255,255,.75)]">
{metric.description}
</p>
<figure className="quality-visual bg-grid-light">
<div className="quality-visual-label">
<span>Laboratório de qualidade</span>
<span>Modelo interativo</span>
</div>
</motion.article>
))}
{BLUEPRINT}
<figcaption>
{METRICS.map((metric) => (
<span key={metric.id} aria-hidden={active.id !== metric.id}>
{metric.caption}
</span>
))}
</figcaption>
</figure>
</div>
<fieldset className="quality-controls" aria-describedby="quality-hint">
<legend className="sr-only">Escolha um aspecto da qualidade</legend>
{METRICS.map((metric) => (
<label key={metric.id} className="quality-control">
<input
type="radio"
name="quality-pillar"
value={metric.id}
checked={active.id === metric.id}
onChange={() => setActive(metric)}
aria-controls="quality-report"
/>
<span className="quality-control-content">
<span className="quality-control-name">{metric.label}</span>
<span className="quality-control-target">
Meta <strong>100</strong>
<span>/100</span>
</span>
</span>
</label>
))}
</fieldset>
<p id="quality-hint" className="quality-hint">
Selecione um pilar para explorar. No teclado, use as setas.
</p>
<section
id="quality-report"
className="quality-report-stack"
aria-label="Inspeção de qualidade"
aria-live="polite"
aria-atomic="true"
>
{METRICS.map((metric) => (
<div
key={metric.id}
className="quality-report"
aria-hidden={active.id !== metric.id}
>
<div className="quality-report-copy">
<p className="quality-code">{metric.code}</p>
<h3>{metric.title}</h3>
<p>{metric.description}</p>
</div>
<ul>
{metric.checks.map((check) => (
<li key={check}>
<span aria-hidden="true">/</span>
{check}
</li>
))}
</ul>
</div>
))}
</section>
<p className="quality-disclaimer">
Metas de qualidade, não uma auditoria em tempo real. Resultados
Lighthouse variam conforme página, dispositivo e condições do teste.
</p>
</div>
</Section>
</section>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface LogoProps {
variant?: "mark" | "wordmark" | "icon";
+56
View File
@@ -0,0 +1,56 @@
import Image from "next/image";
type CoverProject = { id: string; title: string };
/** Capturas de tela publicadas dos projetos que têm interface pública. */
const SCREENSHOTS: Record<string, string | undefined> = {
nutriscan: "/assets/notebook/nutriscan.webp",
"sc-plus": "/assets/notebook/sc-plus.webp",
"sev-modaintima": "/assets/notebook/sev-modaintima.webp",
"as-servicos": "/assets/notebook/as-servicos.webp",
};
function initials(title: string) {
return title
.split(/\s+/)
.filter((word) => word.length > 2 || /^[A-Z]/.test(word))
.slice(0, 2)
.map((word) => word[0])
.join("")
.toUpperCase();
}
/**
* Capa do card de projeto: a captura de tela real quando existe; sem captura, um painel gerado em código
* (só o monograma, sem repetir a stack que já aparece nas tags do card) em vez de uma imagem inventada.
*/
export function ProjectCover({
project,
priority = false,
}: {
project: CoverProject;
priority?: boolean;
}) {
const src = SCREENSHOTS[project.id];
if (src) {
return (
<div className="ds-cover">
<Image
src={src}
alt={`Captura de tela de ${project.title}`}
width={1600}
height={1000}
sizes="(min-width: 1360px) 33vw, (min-width: 1025px) 50vw, 100vw"
priority={priority}
/>
</div>
);
}
return (
<div className="ds-cover ds-cover-fallback" aria-hidden="true">
<span className="ds-cover-mark">{initials(project.title)}</span>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import Projects from "./Projects";
jest.mock("motion/react", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get: (_, tag: string) => {
const Component = ({
children,
animate: _animate,
initial: _initial,
transition: _transition,
variants: _variants,
viewport: _viewport,
whileHover: _whileHover,
whileInView: _whileInView,
whileTap: _whileTap,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
React.createElement(tag, props, children);
Component.displayName = `motion.${tag}`;
return Component;
},
},
);
return { motion };
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => true,
}));
jest.mock("./animations/DrawLine", () => ({
DrawLine: () => null,
}));
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}));
const [baseProject] = getFeaturedProjects(null);
const projects: PortfolioProjectView[] = [
{
...baseProject,
id: "available-demo",
title: "Demo disponível",
description: "Descrição disponível",
technologies: ["Stack disponível"],
status: "Disponível",
demoUrl: "https://available.example",
githubUrl: "https://github.com/example/available",
demoAvailability: "available",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unavailable-demo",
title: "Demo indisponível",
description: "Descrição indisponível",
technologies: ["Stack indisponível"],
status: "Indisponível",
demoUrl: "https://unavailable.example",
githubUrl: "https://github.com/example/unavailable",
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
},
{
...baseProject,
id: "unknown-demo",
title: "Demo desconhecida",
description: "Descrição desconhecida",
technologies: ["Stack desconhecida"],
status: "Não verificada",
demoUrl: "https://unknown.example",
githubUrl: "https://github.com/example/unknown",
demoAvailability: "unknown",
demoCheckedAt: null,
},
{
...baseProject,
id: "no-demo",
title: "Projeto sem demo",
description: "Descrição sem demo",
technologies: ["Stack sem demo"],
status: "Sem demo",
demoUrl: null,
githubUrl: "https://github.com/example/no-demo",
demoAvailability: "none",
demoCheckedAt: null,
},
];
describe("Projects", () => {
it("renders demo actions according to availability", () => {
render(<Projects projects={projects} />);
expect(
screen.getByRole("heading", { name: "Demo disponível" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Abrir projeto" })).toHaveAttribute(
"href",
"https://available.example",
);
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable).not.toHaveAttribute("href");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.getByRole("link", {
name: /Abrir projeto.*Disponibilidade não verificada/i,
}),
).toHaveAttribute("href", "https://unknown.example");
const noDemoCard = screen
.getByRole("heading", { name: "Projeto sem demo" })
.closest("article");
expect(noDemoCard).not.toBeNull();
expect(
within(noDemoCard as HTMLElement).queryByRole("link", {
name: /Abrir projeto/i,
}),
).not.toBeInTheDocument();
});
it("keeps project evidence visible when the demo is unavailable", () => {
render(<Projects projects={projects} />);
const card = screen
.getByRole("heading", { name: "Demo indisponível" })
.closest("article");
expect(card).not.toBeNull();
const unavailableCard = within(card as HTMLElement);
expect(unavailableCard.getByText("Descrição indisponível")).toBeVisible();
expect(unavailableCard.getByText("Stack indisponível")).toBeVisible();
expect(unavailableCard.getByText("Indisponível")).toBeVisible();
expect(
unavailableCard.getByRole("link", { name: "Ver codigo" }),
).toHaveAttribute("href", "https://github.com/example/unavailable");
expect(
unavailableCard.queryByRole("link", { name: /Abrir projeto/i }),
).not.toBeInTheDocument();
});
});
+63 -47
View File
@@ -1,30 +1,19 @@
"use client";
import { useMemo } from "react";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
import MagneticButton from "./cinematic/MagneticButton";
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { DrawLine } from "./animations/DrawLine";
import { SectionLoading } from "./SectionLoading";
import MagneticButton from "./cinematic/MagneticButton";
import { Section } from "./Section";
export default function Projects() {
const { data, loading, error } = usePortfolioData();
export default function Projects({
projects,
}: {
projects: PortfolioProjectView[];
}) {
const shouldReduceMotion = useReducedMotion();
const projects = useMemo(
() => data?.projects.filter((p) => p.featured) || [],
[data],
);
if (loading) {
return <SectionLoading dark message="Loading selected work" />;
}
if (error) {
return <div className="ds-error">Error loading selected work</div>;
}
const gridVariants: Variants = {
hidden: {},
visible: {
@@ -53,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
>
@@ -81,7 +70,7 @@ export default function Projects() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<div className="flex items-start justify-between gap-sp-6">
<div className="flex flex-wrap items-start justify-between gap-sp-6">
<div className="max-w-[560px]">
<div className="ds-kicker">{project.category}</div>
<DrawLine delay={0.05} />
@@ -92,20 +81,27 @@ export default function Projects() {
</p>
)}
</div>
<MagneticButton as="span" className="ds-tag" strength={0.15}>{project.status}</MagneticButton>
<MagneticButton
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">{project.description}</p>
<ul className="ds-tags mt-sp-6">
{project.technologies.slice(0, 5).map((tech: string) => (
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
<ul className="ds-tags mt-sp-6" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</MagneticButton>
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
{project.githubUrl && (
<MagneticButton
as="a"
@@ -118,34 +114,54 @@ export default function Projects() {
Ver codigo
</MagneticButton>
)}
{project.demoUrl && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
</MagneticButton>
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-accent"
strength={0.25}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
</MagneticButton>
)}
{project.demoAvailability === "unavailable" && (
<span className="ds-link ds-link-accent" aria-disabled="true">
Demo indisponível na última verificação
</span>
)}
</div>
</motion.article>
))}
</motion.div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href="/cases" className="ds-link ds-link-accent" strength={0.2}>
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-accent"
strength={0.2}
>
Explorar todos os cases
</MagneticButton>
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-muted" strength={0.2}>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver servicos relacionados
</MagneticButton>
</div>
</Section>
);
}
+2 -1
View File
@@ -1,8 +1,9 @@
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface RevealProps {
children: ReactNode;
-38
View File
@@ -1,38 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { usePathname } from "next/navigation";
interface RouteTransitionProps {
children: ReactNode;
}
export function RouteTransition({ children }: Readonly<RouteTransitionProps>) {
const pathname = usePathname();
const shouldReduceMotion = useReducedMotion();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -20 }}
transition={
shouldReduceMotion
? { duration: 0.2 }
: {
opacity: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
y: {
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
},
}
}
>
{children}
</motion.div>
</AnimatePresence>
);
}
+2 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { motion, type Variants } from "motion/react";
import type React from "react";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { DrawLine } from "./animations/DrawLine";
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
import { Reveal } from "./Reveal";
+8 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { useId } from "react";
import { TopProgressBar } from "./animations/TopProgressBar";
interface SectionLoadingProps {
@@ -14,6 +15,11 @@ export function SectionLoading({
skeletons = 2,
}: Readonly<SectionLoadingProps>) {
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
const skeletonId = useId();
const skeletonKeys = Array.from(
{ length: skeletons },
(_, index) => `${skeletonId}-${index}`,
);
return (
<section className={sectionClass}>
@@ -21,8 +27,8 @@ export function SectionLoading({
<div className="ds-section-shell">
<div className="ds-loading">{message}</div>
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
{Array.from({ length: skeletons }).map((_, index) => (
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
{skeletonKeys.map((skeletonKey) => (
<div key={skeletonKey} className="ds-card ds-skeleton-card">
<div className="ds-skeleton ds-skeleton-kicker" />
<div className="ds-skeleton ds-skeleton-title" />
<div className="ds-skeleton ds-skeleton-line" />
+19 -25
View File
@@ -1,24 +1,18 @@
"use client";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
import MagneticButton from "./cinematic/MagneticButton";
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";
import { SectionLoading } from "./SectionLoading";
import { Section } from "./Section";
export default function Skills() {
const { data, loading, error } = usePortfolioData();
export default function Skills({
categories,
}: {
categories: PortfolioSkillCategory[];
}) {
const shouldReduceMotion = useReducedMotion();
if (loading) {
return <SectionLoading dark message="Loading core stack" />;
}
if (error || !data) {
return <div className="ds-error">Error loading core stack</div>;
}
const gridVariants: Variants = {
hidden: {},
visible: {
@@ -47,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
>
@@ -59,7 +53,7 @@ export default function Skills() {
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
{data.skills.technical.map((category) => (
{categories.map((category) => (
<motion.article
key={category.category}
className="ds-card h-full"
@@ -75,17 +69,19 @@ export default function Skills() {
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<h3 className="ds-card-title !mt-0">{category.category}</h3>
<h3 className="ds-card-title !mt-0" data-reel="left">
{category.category}
</h3>
<DrawLine delay={0.05} />
<p className="ds-copy !mt-sp-4">
<p className="ds-copy !mt-sp-4" data-reel="right">
Ferramentas que uso para construir com clareza tecnica e ritmo de
entrega.
</p>
<ul className="ds-tags">
<ul className="ds-tags" data-reel-stagger="fade">
{category.skills.map((skill) => (
<MagneticButton key={skill} as="li" className="ds-tag" strength={0.15}>
<li key={skill} className="ds-tag">
{skill}
</MagneticButton>
</li>
))}
</ul>
</motion.article>
@@ -94,5 +90,3 @@ export default function Skills() {
</Section>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import WhatsAppLink from "./WhatsAppLink";
const CONSENT_KEY = "evandro.analytics-consent.v1";
const DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
beforeEach(() => {
jest.replaceProperty(process.env, "NODE_ENV", "production");
localStorage.clear();
window.gtag = jest.fn();
delete window[DISABLE_KEY];
window.history.replaceState(
{},
"",
"/[email protected]#private",
);
});
afterEach(() => {
jest.restoreAllMocks();
delete window.gtag;
delete window[DISABLE_KEY];
localStorage.clear();
window.history.replaceState({}, "", "/");
});
function renderLink() {
render(
<WhatsAppLink service="Sistemas web" placement="service_hub_hero">
Conversar no WhatsApp
</WhatsAppLink>,
);
return screen.getByRole("link", { name: "Conversar no WhatsApp" });
}
it.each([null, "denied", "{malformed", '"granted"'])(
"keeps a native destination without collecting or replaying when consent is %s",
async (consent) => {
if (consent !== null) localStorage.setItem(CONSENT_KEY, consent);
const link = renderLink();
const url = new URL(link.getAttribute("href") || "");
expect(url.origin).toBe("https://wa.me");
expect(url.pathname).toMatch(/^\/\d+$/);
expect(url.searchParams.get("text")).toContain("Sistemas web");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
localStorage.setItem(CONSENT_KEY, "granted");
await Promise.resolve();
expect(window.gtag).not.toHaveBeenCalled();
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
},
);
it("emits one GA4 handoff per primary, middle or keyboard activation without personal URL data", async () => {
const user = userEvent.setup();
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
await user.click(link);
expect(window.gtag).toHaveBeenCalledTimes(1);
expect(window.gtag).toHaveBeenLastCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Sistemas web",
placement: "service_hub_hero",
page_path: "/servicos",
page_location: "https://evandro.dev.br/servicos",
page_referrer: "",
});
expect(
fireEvent(
link,
new MouseEvent("auxclick", {
button: 1,
bubbles: true,
cancelable: true,
}),
),
).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(2);
fireEvent(link, new MouseEvent("auxclick", { button: 2, bubbles: true }));
expect(window.gtag).toHaveBeenCalledTimes(2);
link.focus();
await user.keyboard("{Enter}");
expect(window.gtag).toHaveBeenCalledTimes(3);
});
it("stops collecting immediately after revocation and honors the GA disabled flag", () => {
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledTimes(1);
localStorage.setItem(CONSENT_KEY, "denied");
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(1);
localStorage.setItem(CONSENT_KEY, "granted");
window[DISABLE_KEY] = true;
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).toHaveBeenCalledTimes(1);
});
it("keeps navigation available when storage, analytics or its loader is blocked", () => {
localStorage.setItem(CONSENT_KEY, "granted");
const link = renderLink();
const storage = jest
.spyOn(Storage.prototype, "getItem")
.mockImplementation(() => {
throw new Error("storage blocked");
});
expect(fireEvent.click(link, { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
storage.mockRestore();
delete window.gtag;
expect(fireEvent.click(link, { button: 0 })).toBe(true);
window.gtag = jest.fn(() => {
throw new Error("analytics blocked");
});
expect(fireEvent.click(link, { button: 0 })).toBe(true);
});
it("does not collect outside production", () => {
jest.replaceProperty(process.env, "NODE_ENV", "development");
localStorage.setItem(CONSENT_KEY, "granted");
expect(fireEvent.click(renderLink(), { button: 0 })).toBe(true);
expect(window.gtag).not.toHaveBeenCalled();
});
it("uses a route template rather than collecting arbitrary dynamic path segments", () => {
localStorage.setItem(CONSENT_KEY, "granted");
window.history.replaceState(
{},
"",
"/servicos/ana%40example.com?message=private",
);
fireEvent.click(renderLink(), { button: 0 });
expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", {
send_to: "G-QTT9QT0T3S",
service: "Sistemas web",
placement: "service_hub_hero",
page_path: "/servicos/[slug]",
page_location: "https://evandro.dev.br/servicos/[slug]",
page_referrer: "",
});
});
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { trackWhatsAppOpen } from "@/lib/analytics";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
interface WhatsAppLinkProps {
children: ReactNode;
className?: string;
service: string;
placement: string;
}
export default function WhatsAppLink({
children,
className = "",
service,
placement,
}: WhatsAppLinkProps) {
return (
<a
href={buildWhatsAppUrl(
`Olá, Evandro! Quero conversar sobre ${service}. Contato iniciado pelo site evandro.dev.br.`,
)}
target="_blank"
rel="noopener noreferrer"
className={`${className} 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={(event) => {
if (event.button === 0) trackWhatsAppOpen({ service, placement });
}}
onAuxClick={(event) => {
if (event.button === 1) trackWhatsAppOpen({ service, placement });
}}
>
{children}
</a>
);
}
+56 -38
View File
@@ -1,11 +1,12 @@
"use client";
import { motion, type Variants } from "motion/react";
import Link from "next/link";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { serviceHubLinks } from "@/lib/site-content";
import { Section } from "./Section";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { Section } from "./Section";
interface Tier {
id: string;
@@ -27,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",
},
{
@@ -50,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.",
@@ -68,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",
},
];
@@ -119,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]">
@@ -127,11 +128,17 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
</p>
</div>
<p className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<p
className="ds-copy !mt-sp-4 !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel="right"
>
{tier.description}
</p>
<ul className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]">
<ul
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[13px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel-stagger="fade"
>
{tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1">
{deliverable}
@@ -192,37 +199,49 @@ export function WorkModalities() {
return (
<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."
eyebrow="Serviços e contratação"
title="Sites, sistemas e aplicativos para o seu negócio."
description="Desenvolvimento para web, mobile e desktop Windows e Linux. Da primeira ideia à melhoria de um sistema existente, o trabalho começa pelo que você precisa resolver."
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>
<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.
<div className="ds-kicker">O que você pode contratar</div>
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
Uma solução para a sua rotina, não uma lista de tecnologias.
</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
Apresentar sua empresa na internet, organizar a operação, criar um
aplicativo ou conectar ferramentas. Veja exemplos, etapas e
limites de cada serviço; os detalhes técnicos estão disponíveis
nas páginas para quem quiser aprofundar.
</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}
>
Conhecer todos os serviços
</MagneticButton>
</div>
<div className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3">
<div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-2 xl:grid-cols-3"
data-reel-stagger="left"
>
{serviceHubLinks.map((service) => (
<Link
key={service.href}
href={service.href}
className="rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
>
<div className="text-[12px] font-semibold tracking-[0.02em] text-[var(--ink)]">
<div className="text-base font-semibold leading-snug text-[var(--ink)]">
{service.label}
</div>
<div className="mt-sp-2 text-[12px] leading-6 text-[var(--meta)]">
<div className="mt-sp-2 text-sm leading-6 text-[var(--ink-80)]">
{service.description}
</div>
</Link>
@@ -246,4 +265,3 @@ export function WorkModalities() {
</Section>
);
}
+3 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface CounterValueProps {
value: number;
@@ -41,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) {
+2 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { motion } from "motion/react";
import { useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface DrawLineProps {
className?: string;
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import { motion } from "motion/react";
import Image, { type StaticImageData } from "next/image";
import { motion, useReducedMotion } from "framer-motion";
import { useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface LazyRevealImageProps {
src: string | StaticImageData;
@@ -26,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
@@ -0,0 +1,81 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockReducedMotion = false;
let mockInView = true;
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
jest.mock("motion/react", () => {
const React = require("react");
return {
motion: {
span: ({
children,
initial: _initial,
animate,
transition: _transition,
onAnimationComplete: _onAnimationComplete,
...props
}: {
children?: ReactNode;
initial?: unknown;
animate?: Record<string, unknown>;
transition?: unknown;
onAnimationComplete?: () => void;
} & Record<string, unknown>) => {
mockAnimations.push({ animate });
return React.createElement("span", props, children);
},
},
useInView: () => mockInView,
useReducedMotion: () => mockReducedMotion,
};
});
describe("TextDiffuseReveal reduced motion", () => {
beforeEach(() => {
mockReducedMotion = false;
mockInView = true;
mockAnimations = [];
});
it("clears blur and scale when reduced motion resolves after mount", () => {
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
mockAnimations = [];
mockReducedMotion = true;
rerender(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations).toHaveLength(6);
expect(mockAnimations).toEqual(
expect.arrayContaining(
Array.from({ length: 6 }, () =>
expect.objectContaining({
animate: {
opacity: 1,
filter: "blur(0px)",
scale: 1,
},
}),
),
),
);
});
it("keeps the first render independent of the reduced-motion preference", () => {
mockReducedMotion = true;
mockInView = false;
render(<TextDiffuseReveal text="Reveal" />);
expect(mockAnimations[0]).toEqual({
animate: {
filter: "blur(12px)",
opacity: 0,
scale: 0.9,
},
});
});
});
@@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import { TextDiffuseReveal } from "./TextDiffuseReveal";
let mockInView = false;
jest.mock("motion/react", () => ({ useInView: () => mockInView }));
describe("TextDiffuseReveal", () => {
beforeEach(() => {
mockInView = false;
});
it("keeps the whole sentence readable once for assistive technology", () => {
const { container } = render(
<TextDiffuseReveal text="Software feito a partir do seu negócio" />,
);
expect(
screen.getByText("Software feito a partir do seu negócio"),
).toHaveClass("sr-only");
const words = container.querySelectorAll(".text-reveal-word");
expect(words).toHaveLength(7);
expect(words[0].closest("[aria-hidden='true']")).not.toBeNull();
});
it("marks the title as shown only after it enters the viewport", () => {
const { container, rerender } = render(
<TextDiffuseReveal text="Reveal me" />,
);
expect(container.querySelector(".text-reveal")).not.toHaveAttribute(
"data-shown",
);
mockInView = true;
rerender(<TextDiffuseReveal text="Reveal me" />);
expect(container.querySelector(".text-reveal")).toHaveAttribute(
"data-shown",
);
});
});
+60 -60
View File
@@ -1,7 +1,8 @@
"use client";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { motion, useInView } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TextDiffuseRevealProps {
text: string;
@@ -35,7 +36,17 @@ export function TextDiffuseReveal({
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
const tokens = useMemo(() => {
let offset = 0;
return text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((value) => {
const key = `${offset}:${value}`;
offset += value.length;
return { key, value };
});
}, [text]);
const animatedChars = useMemo(
() =>
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
@@ -57,77 +68,66 @@ export function TextDiffuseReveal({
<span
ref={ref}
className={className}
aria-label={text}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
>
{tokens.map((token, tokenIndex) => {
if (!token) {
return null;
}
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{tokens.map(({ key, value: token }) => {
if (token === "\n") {
return <br key={key} />;
}
if (token === "\n") {
return <br key={`line-${tokenIndex}`} />;
}
if (/^\s+$/.test(token)) {
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
}
if (/^\s+$/.test(token)) {
return (
<span key={`space-${tokenIndex}`} aria-hidden="true">
{token.replace(/ /g, "\u00A0")}
</span>
);
}
<span key={key} className="inline-block whitespace-nowrap">
{Array.from(token).map((char) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<span
key={`word-${tokenIndex}`}
className="inline-block whitespace-nowrap"
>
{Array.from(token).map((char, charIndex) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`char-${tokenIndex}-${charIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? shouldReduceMotion
? { opacity: 1 }
: { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
</span>
</span>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TopProgressBarProps {
className?: string;
@@ -1,4 +1,3 @@
import React from 'react';
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}
@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from "@testing-library/react";
import FilmGrain from "./FilmGrain";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
function mockReducedMotion(matches: boolean) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion") ? matches : false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
}
describe("static cinematic effects", () => {
beforeEach(() => mockReducedMotion(false));
it("renders grain and every divider without a canvas", () => {
const grain = render(<FilmGrain />);
expect(grain.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(grain.container.querySelector("canvas")).not.toBeInTheDocument();
grain.unmount();
for (const variant of ["gradient", "pulse", "waveform"] as const) {
const divider = render(<SectionDivider variant={variant} />);
expect(divider.container.firstElementChild).toHaveAttribute(
"aria-hidden",
"true",
);
expect(divider.container.querySelector("canvas")).not.toBeInTheDocument();
expect(divider.container.firstElementChild).not.toHaveAttribute(
"tabindex",
);
divider.unmount();
}
});
it("exposes a persistent pause control for an automatic marquee", () => {
render(<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />);
const pause = screen.getByRole("button", { name: "Pausar animação" });
const track = document.getElementById(
pause.getAttribute("aria-controls") ?? "",
);
expect(pause).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
expect(
screen.getByText("SOFTWARE ENGINEERING", {
selector: '[aria-hidden="false"]',
}),
).toBeVisible();
expect(screen.getAllByText("SOFTWARE ENGINEERING")).toHaveLength(2);
expect(
screen
.getAllByText("SOFTWARE ENGINEERING")
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
fireEvent.click(pause);
const resume = screen.getByRole("button", { name: "Retomar animação" });
expect(resume).toHaveAttribute("aria-pressed", "true");
expect(track).toHaveStyle({ animationPlayState: "paused" });
fireEvent.click(resume);
expect(
screen.getByRole("button", { name: "Pausar animação" }),
).toHaveAttribute("aria-pressed", "false");
expect(track).not.toHaveStyle({ animationPlayState: "paused" });
});
it("starts static under reduced motion without a redundant control", () => {
mockReducedMotion(true);
render(<ScrollVelocityText text="VAMOS CONVERSAR" direction="right" />);
expect(
screen.queryByRole("button", { name: /animação/i }),
).not.toBeInTheDocument();
const copies = screen.getAllByText("VAMOS CONVERSAR");
expect(copies).toHaveLength(2);
expect(copies[0]).toBeVisible();
expect(copies[0]).toHaveAttribute("aria-hidden", "false");
expect(copies[0].closest("[style]")).toHaveStyle({
animationPlayState: "paused",
});
expect(
copies
.slice(1)
.every((copy) => copy.getAttribute("aria-hidden") === "true"),
).toBe(true);
});
});
@@ -0,0 +1,103 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
function HomeNavigation() {
const onLayoutReady = useHomeFragmentNavigation();
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish notebook layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
<section id="about" aria-label="Sobre">
<button type="button">Read biography</button>
</section>
<section id="contact" aria-label="Contato">
<button type="button">Send message</button>
</section>
</>
);
}
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
window.history.replaceState(null, "", "/");
HTMLElement.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
window.history.replaceState(null, "", "/");
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
} else {
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}
});
describe("home fragment navigation", () => {
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
window.history.replaceState(null, "", "/#contact");
render(<HomeNavigation />);
const contact = screen.getByRole("region", { name: "Contato" });
expect(contact).not.toHaveFocus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(
screen.getByRole("button", { name: "Read biography" }),
).toHaveFocus();
});
it("abandons a pending arrival when the visitor intentionally scrolls", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(message).toHaveFocus();
});
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
expect(fireEvent.click(link)).toBe(true);
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
link.focus();
fireEvent.click(link, { ctrlKey: true });
expect(link).toHaveFocus();
});
it("does not resurrect a stale fragment when the URL changes before readiness", () => {
window.history.replaceState(null, "", "/#about");
render(<HomeNavigation />);
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
act(() => {
window.history.replaceState(null, "", "/#contact");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
expect(message).toHaveFocus();
});
});
+40 -51
View File
@@ -1,88 +1,77 @@
"use client";
import dynamic from "next/dynamic";
import { Suspense } from "react";
import type {
PortfolioExperience,
PortfolioProjectView,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import About from "../About";
import Contact from "../Contact";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero from "../Hero";
import { SectionLoading } from "../SectionLoading";
import LighthouseMetrics from "../LighthouseMetrics";
import Projects from "../Projects";
import Skills from "../Skills";
import NotebookStory from "../story/NotebookStory";
import { WorkModalities } from "../WorkModalities";
import ScrollVelocityText from "./ScrollVelocityText";
import SectionDivider from "./SectionDivider";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
const About = dynamic(() => import("../About"), { loading: () => <SectionLoading message="Carregando..." /> });
const Skills = dynamic(() => import("../Skills"), { loading: () => <SectionLoading message="Carregando..." /> });
const Experience = dynamic(() => import("../Experience"), { loading: () => <SectionLoading message="Carregando..." /> });
const WorkModalities = dynamic(() => import("../WorkModalities").then(mod => mod.WorkModalities), { loading: () => <SectionLoading message="Carregando..." /> });
const Contact = dynamic(() => import("../Contact"), { loading: () => <SectionLoading message="Carregando..." /> });
const Projects = dynamic(() => import("../Projects"));
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { loading: () => <SectionLoading message="Carregando..." /> });
const Footer = dynamic(() => import("../Footer"));
export default function CinematicHome({
projects,
experiences,
skillCategories,
}: {
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
const onLayoutReady = useHomeFragmentNavigation();
const CinematicSection = dynamic(() => import("./CinematicSection"), { ssr: false });
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), { ssr: false });
const SectionDivider = dynamic(() => import("./SectionDivider"), { ssr: false });
export default function CinematicHome() {
return (
<div className="flex min-h-screen flex-col overflow-x-hidden">
<div className="flex min-h-screen flex-col overflow-x-clip">
<main className="ds-main">
<Hero />
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText
text="SOFTWARE ENGINEERING"
baseVelocity={-1.5}
/>
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
</div>
<CinematicSection effect="parallax-depth">
<About />
</CinematicSection>
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<About />
<SectionDivider variant="waveform" />
<CinematicSection effect="clip-reveal">
<Skills />
</CinematicSection>
<Skills categories={skillCategories} />
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
<ScrollVelocityText
text="NEXT.JS TAURI RUST NODE.JS TYPESCRIPT"
baseVelocity={1}
direction="right"
/>
</div>
<CinematicSection effect="fade-scale">
<Experience />
</CinematicSection>
<Experience experiences={experiences} />
<SectionDivider variant="gradient" />
<CinematicSection effect="rotate-in">
<Suspense
fallback={<SectionLoading message="Carregando projetos..." />}
>
<Projects />
</Suspense>
</CinematicSection>
<Projects projects={projects} />
<CinematicSection effect="clip-reveal">
<LighthouseMetrics />
</CinematicSection>
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<CinematicSection effect="parallax-depth">
<WorkModalities />
</CinematicSection>
<WorkModalities />
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText
text="VAMOS CONVERSAR"
baseVelocity={-2}
/>
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
</div>
<CinematicSection effect="fade-scale">
<Contact />
</CinematicSection>
<Contact />
</main>
<Footer />
</div>
+13 -9
View File
@@ -1,21 +1,25 @@
"use client";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const PageIntro = dynamic(() => import("./PageIntro"), { 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 ? (
<>
<FilmGrain />
<ScrollProgress />
</>
) : null}
</>
);
}
@@ -1,173 +0,0 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
interface CinematicSectionProps {
children: ReactNode;
className?: string;
effect?: "fade-scale" | "clip-reveal" | "parallax-depth" | "rotate-in";
id?: string;
}
export default function CinematicSection({
children,
className = "",
effect = "fade-scale",
id,
}: CinematicSectionProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
// Check reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
switch (effect) {
case "fade-scale": {
gsap.set(el, {
opacity: 0,
scale: 0.92,
transformOrigin: "center center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 85%",
end: "top 20%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
scale: 1,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "clip-reveal": {
gsap.set(el, {
clipPath: "inset(15% 5% 15% 5%)",
opacity: 0.3,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 15%",
scrub: 1,
},
});
tl.to(el, {
clipPath: "inset(0% 0% 0% 0%)",
opacity: 1,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "parallax-depth": {
gsap.set(el, {
y: 80,
opacity: 0,
rotateX: 4,
transformPerspective: 1200,
transformOrigin: "center bottom",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 90%",
end: "top 30%",
scrub: 0.6,
},
});
tl.to(el, {
y: 0,
opacity: 1,
rotateX: 0,
ease: "power2.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
case "rotate-in": {
gsap.set(el, {
opacity: 0,
rotateY: -5,
x: -60,
transformPerspective: 1000,
transformOrigin: "left center",
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
end: "top 25%",
scrub: 0.8,
},
});
tl.to(el, {
opacity: 1,
rotateY: 0,
x: 0,
ease: "power3.out",
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
break;
}
}
};
void setup();
return () => {
cleanup?.();
};
}, [effect]);
return (
<div ref={sectionRef} className={className} id={id}>
{children}
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
"use client";
import { useRef, useEffect } from "react";
interface CinematicTextProps {
text: string;
className?: string;
tag?: "h1" | "h2" | "h3" | "p" | "span";
delay?: number;
splitBy?: "chars" | "words" | "lines";
}
export default function CinematicText({
text,
className = "",
tag: Tag = "h2",
delay = 0,
splitBy = "chars",
}: CinematicTextProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const spans = el.querySelectorAll("[data-char]");
if (spans.length === 0) return;
gsap.set(spans, {
opacity: 0,
y: 60,
rotateX: -90,
transformOrigin: "center bottom",
transformPerspective: 600,
});
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start: "top 80%",
toggleActions: "play none none none",
},
});
tl.to(spans, {
opacity: 1,
y: 0,
rotateX: 0,
duration: 0.8,
ease: "back.out(1.2)",
stagger: splitBy === "chars" ? 0.02 : splitBy === "words" ? 0.06 : 0.12,
delay,
});
cleanup = () => {
tl.scrollTrigger?.kill();
tl.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, [text, delay, splitBy]);
const renderContent = () => {
if (splitBy === "chars") {
return text.split("").map((char, i) => (
<span
key={`${char}-${i}`}
data-char
className="inline-block will-change-transform"
style={{ whiteSpace: char === " " ? "pre" : undefined }}
>
{char === " " ? "\u00A0" : char}
</span>
));
}
if (splitBy === "words") {
return text.split(" ").map((word, i) => (
<span key={`${word}-${i}`} className="inline-block overflow-hidden">
<span data-char className="inline-block will-change-transform">
{word}
</span>
{i < text.split(" ").length - 1 && "\u00A0"}
</span>
));
}
// lines
return text.split("\n").map((line, i) => (
<span key={`${line}-${i}`} className="block overflow-hidden">
<span data-char className="block will-change-transform">
{line}
</span>
</span>
));
};
return (
<div ref={containerRef} className="overflow-hidden">
<Tag className={className}>{renderContent()}</Tag>
</div>
);
}
@@ -0,0 +1,146 @@
import { act, render, waitFor } from "@testing-library/react";
import createGlobe from "cobe";
import { ControlRoomGlobe } from "./ControlRoomGlobe";
const mockDestroy = jest.fn();
jest.mock("cobe", () => ({
__esModule: true,
default: jest.fn(() => ({ destroy: mockDestroy })),
}));
jest.mock("motion/react", () => ({
useMotionValue: (initial: number) => ({
get: () => initial,
set: jest.fn(),
}),
useSpring: (value: unknown) => value,
}));
let intersectionCallback: IntersectionObserverCallback | undefined;
let resizeCallback: ResizeObserverCallback | undefined;
let measuredWidth = 400;
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setGlobeIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function renderCallback() {
expect(createGlobe).toHaveBeenCalledTimes(1);
const call = jest.mocked(createGlobe).mock.calls[0];
if (!call) throw new Error("Globe was not initialized");
return call[1].onRender;
}
describe("ControlRoomGlobe lifecycle", () => {
beforeEach(() => {
jest.clearAllMocks();
setDocumentHidden(false);
intersectionCallback = undefined;
resizeCallback = undefined;
measuredWidth = 400;
jest
.spyOn(HTMLElement.prototype, "offsetWidth", "get")
.mockImplementation(() => measuredWidth);
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({} as unknown as GPUCanvasContext);
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
disconnect = jest.fn();
observe = jest.fn();
unobserve = jest.fn();
}
global.ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("pauses for a hidden tab without losing the intersection flag", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
const renderFrame = renderCallback();
const state = { phi: 0, theta: 0, width: 0, height: 0 };
setGlobeIntersects(true);
renderFrame(state);
expect(state.phi).not.toBe(0);
setDocumentHidden(true);
const pausedPhi = state.phi;
renderFrame(state);
expect(state.phi).toBe(pausedPhi);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).not.toBe(pausedPhi);
setGlobeIntersects(false);
const offscreenPhi = state.phi;
setDocumentHidden(true);
setDocumentHidden(false);
renderFrame(state);
expect(state.phi).toBe(offscreenPhi);
});
it("keeps one Cobe instance while ResizeObserver updates render dimensions", async () => {
render(<ControlRoomGlobe />);
await waitFor(() => expect(createGlobe).toHaveBeenCalledTimes(1));
measuredWidth = 520;
const entry = {
contentRect: { width: measuredWidth },
} as ResizeObserverEntry;
act(() => resizeCallback?.([entry], {} as ResizeObserver));
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(createGlobe).toHaveBeenCalledTimes(1);
expect(state.width).toBe(1040);
expect(state.height).toBe(1040);
});
it("keeps the reduced-motion globe static while tracking dimensions", async () => {
render(<ControlRoomGlobe autoRotate={false} />);
await waitFor(() => expect(createGlobe).toHaveBeenCalled());
setGlobeIntersects(true);
const state = { phi: 0, theta: 0, width: 0, height: 0 };
renderCallback()(state);
expect(state).toEqual({ phi: 0, theta: 0, width: 800, height: 800 });
});
});
@@ -0,0 +1,210 @@
"use client";
import createGlobe, { type COBEOptions } from "cobe";
import { useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } 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 widthRef = useRef(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 intersectsRef = useRef(false);
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 canvas = canvasRef.current;
if (!canvas) return undefined;
const dpr = config.devicePixelRatio;
const updateWidth = (entry?: ResizeObserverEntry) => {
const nextWidth =
entry?.contentRect.width || canvas.offsetWidth || config.width / dpr;
if (nextWidth > 0) widthRef.current = nextWidth;
};
updateWidth();
const resizeObserver = new ResizeObserver(([entry]) => updateWidth(entry));
resizeObserver.observe(canvas);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
intersectsRef.current = entry.isIntersecting;
},
{ rootMargin: "100px" },
);
intersectionObserver.observe(canvas);
// Falha suave: sem WebGL (ou canvas já vinculado a outro tipo de contexto) o phenomenon
// lança "reading 'enable' of null" e derrubaria a árvore React inteira — card ficaria preto.
let globe: { destroy(): void } | null = null;
try {
const supportsWebGL =
canvas.getContext("webgl2") ?? canvas.getContext("webgl");
if (supportsWebGL) {
const initialRenderWidth = widthRef.current * dpr;
globe = createGlobe(canvas, {
...config,
width: initialRenderWidth,
height: initialRenderWidth,
onRender: (state) => {
const renderWidth = widthRef.current * dpr;
state.width = renderWidth;
state.height = renderWidth;
if (!autoRotate || !intersectsRef.current || document.hidden)
return;
if (!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;
},
});
}
} catch {
globe = null;
}
if (!globe) {
// Sem globo: fallback visível (gradiente do card permanece), página viva.
canvas.style.opacity = "1";
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
const revealTimer = window.setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
}, 0);
return () => {
window.clearTimeout(revealTimer);
globe.destroy();
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}, [rs, config, autoRotate, rotationSpeed]);
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>
);
}
-159
View File
@@ -1,159 +0,0 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
export default function CustomCursor() {
const [isHovering, setIsHovering] = useState(false);
const [isClicking, setIsClicking] = useState(false);
const [hoverText, setHoverText] = useState("");
const [isVisible, setIsVisible] = useState(false);
const cursorX = useMotionValue(0);
const cursorY = useMotionValue(0);
const springConfig = { damping: 25, stiffness: 250, mass: 0.5 };
const x = useSpring(cursorX, springConfig);
const y = useSpring(cursorY, springConfig);
const ringSpringConfig = { damping: 20, stiffness: 150, mass: 0.8 };
const ringX = useSpring(cursorX, ringSpringConfig);
const ringY = useSpring(cursorY, ringSpringConfig);
const rafRef = useRef<number>(0);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
cursorX.set(e.clientX);
cursorY.set(e.clientY);
if (!isVisible) setIsVisible(true);
});
},
[cursorX, cursorY, isVisible]
);
useEffect(() => {
// Only show custom cursor on desktop with fine pointer
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
if (!hasFinePointer) return;
const handleDown = () => setIsClicking(true);
const handleUp = () => setIsClicking(false);
const handleLeave = () => setIsVisible(false);
const handleEnter = () => setIsVisible(true);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleDown);
window.addEventListener("mouseup", handleUp);
document.addEventListener("mouseleave", handleLeave);
document.addEventListener("mouseenter", handleEnter);
// Detect hoverable elements
const observer = new MutationObserver(() => {
attachHoverListeners();
});
observer.observe(document.body, { childList: true, subtree: true });
attachHoverListeners();
function attachHoverListeners() {
const hoverables = document.querySelectorAll(
'a, button, [role="button"], input, textarea, select, [data-cursor]'
);
for (const el of hoverables) {
el.addEventListener("mouseenter", handleElementEnter as EventListener);
el.addEventListener("mouseleave", handleElementLeave as EventListener);
}
}
function handleElementEnter(e: Event) {
const el = e.currentTarget as HTMLElement;
setIsHovering(true);
const cursorData = el.getAttribute("data-cursor");
if (cursorData) setHoverText(cursorData);
}
function handleElementLeave() {
setIsHovering(false);
setHoverText("");
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleDown);
window.removeEventListener("mouseup", handleUp);
document.removeEventListener("mouseleave", handleLeave);
document.removeEventListener("mouseenter", handleEnter);
observer.disconnect();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [handleMouseMove]);
// Don't render on touch devices
if (typeof window !== "undefined" && !window.matchMedia("(pointer: fine)").matches) {
return null;
}
return (
<>
<style jsx global>{`
@media (pointer: fine) {
* { cursor: none !important; }
}
`}</style>
{/* Dot cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
style={{
x,
y,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
scale: isClicking ? 0.5 : isHovering ? 0.5 : 1,
opacity: isVisible ? 1 : 0,
}}
transition={{ duration: 0.15 }}
>
<div
className="rounded-full bg-[#ccff00]"
style={{ width: 8, height: 8 }}
/>
</motion.div>
{/* Ring cursor */}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
style={{
x: ringX,
y: ringY,
translateX: "-50%",
translateY: "-50%",
}}
animate={{
width: isHovering ? 80 : 40,
height: isHovering ? 80 : 40,
opacity: isVisible ? (isHovering ? 0.8 : 0.4) : 0,
borderWidth: isHovering ? 2 : 1,
}}
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
>
<div
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
style={{ borderStyle: "solid", borderColor: "#ccff00", borderWidth: "inherit" }}
>
{hoverText && (
<motion.span
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
className="text-[9px] font-bold uppercase tracking-widest text-[#ccff00]"
>
{hoverText}
</motion.span>
)}
</div>
</motion.div>
</>
);
}
@@ -0,0 +1,63 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
const TARGETS = new Set(["#experience", "#skills"]);
/**
* Skills e Experiência ficavam numa gaveta sempre fechada e sem link no menu.
* Agora abre sozinha em telas largas (onde há espaço) e quando um link aponta para #experience ou #skills;
* no celular continua recolhida para a página não crescer.
*/
export default function ExperienceDisclosure({
children,
}: {
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => {
const reveal = (hash: string) => {
if (!TARGETS.has(hash)) return;
setOpen(true);
// espera o React abrir a gaveta para o alvo existir no layout
requestAnimationFrame(() =>
document
.getElementById(hash.slice(1))
?.scrollIntoView({ behavior: "instant", block: "start" }),
);
};
if (window.matchMedia("(min-width: 1024px)").matches) setOpen(true);
else if (TARGETS.has(window.location.hash)) setOpen(true);
const onClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href*='#']")
: null;
if (!(link instanceof HTMLAnchorElement)) return;
const url = new URL(link.href);
if (url.pathname === window.location.pathname) reveal(url.hash);
};
const onHashChange = () => reveal(window.location.hash);
document.addEventListener("click", onClick);
window.addEventListener("hashchange", onHashChange);
return () => {
document.removeEventListener("click", onClick);
window.removeEventListener("hashchange", onHashChange);
};
}, []);
return (
<details
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
className="ds-section !pt-0"
>
<summary className="ds-section-shell cursor-pointer py-sp-5 text-lg font-semibold">
Experiência e tecnologias por trás das entregas
</summary>
{children}
</details>
);
}
+1 -63
View File
@@ -1,65 +1,3 @@
"use client";
import { useEffect, useRef } from "react";
export default function FilmGrain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let lastFrame = 0;
const fps = 24; // Cinematic film fps
const frameInterval = 1000 / fps;
const resize = () => {
canvas.width = window.innerWidth / 2;
canvas.height = window.innerHeight / 2;
};
resize();
window.addEventListener("resize", resize);
const render = (timestamp: number) => {
animId = requestAnimationFrame(render);
const delta = timestamp - lastFrame;
if (delta < frameInterval) return;
lastFrame = timestamp - (delta % frameInterval);
const { width, height } = canvas;
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = Math.random() * 20;
data[i] = noise;
data[i + 1] = noise;
data[i + 2] = noise;
data[i + 3] = 12; // Very subtle
}
ctx.putImageData(imageData, 0, 0);
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
style={{ imageRendering: "pixelated" }}
aria-hidden="true"
/>
);
return <div aria-hidden="true" className="film-grain" />;
}
@@ -1,156 +0,0 @@
"use client";
import { useRef, useMemo } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
import * as THREE from "three";
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = Math.sin(t * 0.3) * 0.2;
meshRef.current.rotation.z = Math.cos(t * 0.2) * 0.1;
});
return (
<Float speed={1.5} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={meshRef} position={position} scale={scale}>
<sphereGeometry args={[1, 32, 32]} />
<MeshDistortMaterial
color="#ccff00"
transparent
opacity={0.08}
distort={0.4}
speed={2}
wireframe
/>
</mesh>
</Float>
);
}
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (!meshRef.current) return;
const t = state.clock.elapsedTime;
meshRef.current.rotation.x = t * 0.1;
meshRef.current.rotation.y = t * 0.15;
});
return (
<Float speed={1} rotationIntensity={0.5} floatIntensity={0.4}>
<mesh ref={meshRef} position={position} scale={scale}>
<torusGeometry args={[1, 0.02, 16, 100]} />
<meshStandardMaterial
color="#ccff00"
transparent
opacity={0.15}
emissive="#ccff00"
emissiveIntensity={0.1}
/>
</mesh>
</Float>
);
}
function DataStream({ count = 50 }: { count?: number }) {
const pointsRef = useRef<THREE.Points>(null);
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
// Create a spiral/helix pattern
const angle = (i / count) * Math.PI * 8;
const radius = 2 + Math.sin(angle * 0.5) * 0.5;
pos[i3] = Math.cos(angle) * radius;
pos[i3 + 1] = (i / count - 0.5) * 12;
pos[i3 + 2] = Math.sin(angle) * radius;
}
return pos;
}, [count]);
useFrame((state) => {
if (!pointsRef.current) return;
const t = state.clock.elapsedTime;
pointsRef.current.rotation.y = t * 0.05;
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
arr[i3 + 1] += 0.02;
if (arr[i3 + 1] > 6) arr[i3 + 1] = -6;
}
posAttr.needsUpdate = true;
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
count={count}
/>
</bufferGeometry>
<pointsMaterial
color="#ccff00"
size={0.04}
transparent
opacity={0.4}
sizeAttenuation
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
interface FloatingElementsProps {
variant?: "sphere" | "ring" | "data" | "combined";
className?: string;
}
export default function FloatingElements({
variant = "combined",
className = "",
}: FloatingElementsProps) {
return (
<div className={`pointer-events-none absolute inset-0 ${className}`}>
<Canvas
camera={{ position: [0, 0, 6], fov: 50 }}
dpr={[1, 1.5]}
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
style={{ background: "transparent" }}
>
<ambientLight intensity={0.2} />
<pointLight position={[5, 5, 5]} intensity={0.3} color="#ccff00" />
{(variant === "sphere" || variant === "combined") && (
<>
<GlassSphere position={[3, 1, -2]} scale={0.8} />
<GlassSphere position={[-4, -1, -3]} scale={1.2} />
</>
)}
{(variant === "ring" || variant === "combined") && (
<>
<WireframeRing position={[0, 0, -2]} scale={1.5} />
<WireframeRing position={[-3, 2, -4]} scale={0.8} />
</>
)}
{(variant === "data" || variant === "combined") && (
<DataStream count={60} />
)}
</Canvas>
</div>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useReducedMotion } from "framer-motion";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
+133
View File
@@ -0,0 +1,133 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef, useState } from "react";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** Etiquetas presas às camadas da cena (a cena calcula a posição de cada uma a cada quadro). */
const TAGS = [
["A tela", "o que sua equipe e seus clientes usam"],
["As integrações", "seus sistemas conversando entre si"],
["Os dados", "suas informações organizadas"],
] as const;
const Scene = dynamic<HeroFieldSceneProps>(() => import("./HeroFieldScene"), {
ssr: false,
});
function supportsWebGL2() {
if (typeof window.WebGL2RenderingContext === "undefined") return false;
try {
const context = document.createElement("canvas").getContext("webgl2");
context?.getExtension("WEBGL_lose_context")?.loseContext();
return context !== null;
} catch {
return false;
}
}
/**
* Fundo do hero: liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho)
* e monta a cena WebGL só quando a página fica ociosa, com WebGL2 e sem preferência por movimento reduzido.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const labelsRef = useRef<(HTMLElement | null)[]>([]);
const [mount, setMount] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena já está no escuro e o hero se dissolve; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
let frame = 0;
const update = () => {
frame = 0;
const rect = section.getBoundingClientRect();
const span = Math.max(
rect.height - window.innerHeight,
window.innerHeight * 0.8,
);
const p = Math.min(1, Math.max(0, -rect.top / span));
progressRef.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
setDone(p >= 0.999);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", queue, { passive: true });
window.addEventListener("resize", queue);
let intersecting = false;
const sync = () => setVisible(intersecting && !document.hidden);
const observer = new IntersectionObserver(([entry]) => {
intersecting = entry.isIntersecting;
sync();
});
observer.observe(section);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", queue);
window.removeEventListener("resize", queue);
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
};
}, []);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!supportsWebGL2()) return;
// Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso.
if (typeof window.requestIdleCallback === "function") {
const id = window.requestIdleCallback(() => setMount(true), {
timeout: 2500,
});
return () => window.cancelIdleCallback(id);
}
const id = setTimeout(() => setMount(true), 1200);
return () => clearTimeout(id);
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => setFailed(true), []);
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
>
{mount && !failed && (
<Scene
progressRef={progressRef}
labelsRef={labelsRef}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
/>
)}
{mount &&
!failed &&
TAGS.map(([title, text], index) => (
<span
key={title}
ref={(element) => {
labelsRef.current[index] = element;
}}
className="hero-tag"
>
<span className="hero-tag-title">{title}</span>
{text}
</span>
))}
</div>
);
}
+625
View File
@@ -0,0 +1,625 @@
"use client";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
export type HeroFieldSceneProps = {
progressRef: RefObject<number>;
/** etiquetas em HTML presas às camadas (tela, integrações, dados), posicionadas a cada quadro */
labelsRef: RefObject<(HTMLElement | null)[]>;
visible: boolean;
onReady: () => void;
onError: () => void;
};
/*
* Um sistema desenhado com pontos, em três camadas que um leigo reconhece:
* em cima, a TELA de um aplicativo (janela, título, botão, cards); no meio, as INTEGRAÇÕES (sistemas
* ligados a uma central, com mensagens correndo pelas linhas); embaixo, os DADOS (uma tabela).
* Colunas de partículas sobem da tabela até a tela: a informação chegando a quem usa.
* Na entrada, os pontos saem de uma nuvem e se organizam; ao rolar, as camadas se afastam, ficam de
* frente e a câmera atravessa uma a uma (as legendas de HeroField explicam cada camada nessa hora).
* Tudo se move no vertex shader; o JavaScript só atualiza alguns uniforms por quadro.
*/
type Layer = 1 | 0 | -1; // 1 = tela, 0 = integrações, -1 = dados
type PointStyle = { tone?: number; size?: number; alpha?: number };
class FieldBuilder {
base: number[] = [];
to: number[] = [];
move: number[] = [];
tone: number[] = [];
size: number[] = [];
alpha: number[] = [];
constructor(private density: number) {}
point(
x: number,
y: number,
z: number,
style: PointStyle = {},
to?: [number, number, number],
) {
this.base.push(x, y, z);
this.to.push(...(to ?? [x, y, z]));
this.move.push(to ? 1 : 0);
this.tone.push(style.tone ?? 0);
this.size.push(style.size ?? 0.02);
this.alpha.push(style.alpha ?? 0.7);
}
line(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s));
for (let i = 0; i <= n; i++)
this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style);
}
rect(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
this.line(y, x0, z0, x1, z0, step, style);
this.line(y, x1, z0, x1, z1, step, style);
this.line(y, x1, z1, x0, z1, step, style);
this.line(y, x0, z1, x0, z0, step, style);
}
fill(
y: Layer,
x0: number,
z0: number,
x1: number,
z1: number,
step: number,
style?: PointStyle,
) {
const s = step * this.density;
for (let x = x0; x <= x1 + 1e-6; x += s)
for (let z = z0; z <= z1 + 1e-6; z += s)
this.point(
x + (Math.random() - 0.5) * s * 0.3,
y,
z + (Math.random() - 0.5) * s * 0.3,
style,
);
}
ring(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
const n = Math.max(
8,
Math.round((Math.PI * 2 * r) / (step * this.density)),
);
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style);
}
}
disc(
y: Layer,
cx: number,
cz: number,
r: number,
step: number,
style?: PointStyle,
) {
for (let rr = r; rr > 0.005; rr -= step * this.density)
this.ring(y, cx, cz, rr, step, style);
this.point(cx, y, cz, style);
}
/** partículas que correm de a até b (a posição é calculada no shader) */
flow(
a: [number, number, number],
b: [number, number, number],
count: number,
style?: PointStyle,
) {
const n = Math.max(2, Math.round(count / this.density));
for (let i = 0; i < n; i++) this.point(...a, style, b);
}
}
// z negativo = topo da tela quando a camada fica de frente para a câmera
function drawScreen(f: FieldBuilder) {
const y: Layer = 1;
f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela
f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 });
f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador
for (const x of [-1.9, -1.72, -1.54])
f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 });
f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço
f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título
f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto
f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 });
f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, {
tone: 1,
alpha: 0.95,
size: 0.022,
}); // botão
f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem
f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 });
for (const [x0, x1] of [
[-1.8, -0.7],
[-0.55, 0.55],
[0.7, 1.8],
]) {
f.rect(y, x0, 0.45, x1, 1.2, 0.035, { alpha: 0.55 }); // cards
f.line(y, x0 + 0.12, 0.62, x1 - 0.3, 0.62, 0.045, { alpha: 0.5 });
f.line(y, x0 + 0.12, 0.76, x1 - 0.5, 0.76, 0.045, { alpha: 0.35 });
}
}
/** posições dos sistemas ligados à central; as colunas de dados sobem por eles */
const NODES: [number, number][] = Array.from({ length: 6 }, (_, k) => {
const a = (k / 6) * Math.PI * 2 + Math.PI / 6;
return [Math.cos(a) * 1.7, Math.sin(a) * 1.05];
});
function drawIntegrations(f: FieldBuilder) {
const y: Layer = 0;
f.ring(y, 0, 0, 0.34, 0.02, { tone: 1, alpha: 0.95, size: 0.024 }); // central
f.disc(y, 0, 0, 0.2, 0.035, { tone: 1, alpha: 0.5 });
NODES.forEach(([x, z], k) => {
f.ring(y, x, z, 0.2, 0.02, { tone: 1, alpha: 0.9, size: 0.022 });
f.disc(y, x, z, 0.1, 0.03, { alpha: 0.6 });
const len = Math.hypot(x, z);
const ux = x / len;
const uz = z / len;
// linha da central até o sistema, e mensagens correndo nos dois sentidos
f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, {
alpha: 0.35,
});
f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, {
tone: 1,
alpha: 0.95,
size: 0.026,
});
f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, {
alpha: 0.9,
size: 0.024,
});
const [nx, nz] = NODES[(k + 1) % NODES.length];
f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 });
});
}
function drawData(f: FieldBuilder) {
const y: Layer = -1;
const x0 = -2;
const x1 = 2;
const z0 = -1.3;
const z1 = 1.3;
const cols = [x0, -0.9, 0.2, 1.1, x1];
f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 });
f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela
for (let z = z0 + 0.32; z < z1; z += 0.33)
f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 });
for (const x of cols.slice(1, -1))
f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 });
for (let z = z0 + 0.48; z < z1; z += 0.33) {
for (let c = 0; c < cols.length - 1; c++) {
const start = cols[c] + 0.12;
const width =
(cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6);
f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células
}
}
}
function buildField(dense: boolean) {
const f = new FieldBuilder(dense ? 1 : 1.5);
drawScreen(f);
drawIntegrations(f);
drawData(f);
// colunas: a informação sobe dos dados, passa pelos sistemas e chega à tela
for (const [x, z] of [[0, 0] as [number, number], ...NODES])
f.flow([x, -1, z], [x, 1, z], 40, { tone: 1, alpha: 0.6, size: 0.022 });
const count = f.move.length;
const scatter = new Float32Array(count * 3);
const rand = new Float32Array(count * 4);
for (let n = 0; n < count; n++) {
const u = Math.random() * 2 - 1;
const t = Math.random() * Math.PI * 2;
const s = Math.sqrt(1 - u * u);
const d = 4 + Math.random() * 7;
scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3);
rand.set(
[Math.random(), Math.random(), Math.random(), Math.random()],
n * 4,
);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(f.base, 3),
);
geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3));
geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1));
geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1));
geometry.setAttribute("aSize", new THREE.Float32BufferAttribute(f.size, 1));
geometry.setAttribute("aAlpha", new THREE.Float32BufferAttribute(f.alpha, 1));
geometry.setAttribute("aScatter", new THREE.BufferAttribute(scatter, 3));
geometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 4));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 12);
return geometry;
}
const vertexShader = /* glsl */ `
uniform float uTime;
uniform float uIntro;
uniform float uSpread;
uniform float uScroll;
uniform float uScale;
uniform float uFocus;
uniform float uFade;
uniform vec2 uMouse;
uniform float uMouseAmt;
attribute vec3 aTo;
attribute float aMove;
attribute float aTone;
attribute float aSize;
attribute float aAlpha;
attribute vec3 aScatter;
attribute vec4 aRand;
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
const vec3 PAPER = vec3(0.957, 0.949, 0.929);
const vec3 LIME = vec3(0.8, 1.0, 0.0);
void main() {
// partículas em movimento correm de position até aTo e recomeçam
float f = fract(uTime * (0.1 + aRand.x * 0.08) + aRand.y);
vec3 b = mix(position, aTo, aMove * f);
vec3 p = vec3(b.x, b.y * uSpread, b.z);
float still = 1.0 - aMove;
float wave = sin(b.x * 1.7 + uTime * 0.55 + b.y * 2.1) * cos(b.z * 1.3 - uTime * 0.4);
p.y += wave * (0.02 + uScroll * 0.2) * still;
// cursor: uma onda que levanta a camada e acende os pontos em verde
float d = length(b.xz - uMouse);
float lift = exp(-d * d * 2.2) * uMouseAmt * still;
p.y += lift * 0.3 + sin(d * 7.0 - uTime * 3.5) * lift * 0.05;
// entrada: cada ponto sai da nuvem com atraso próprio
float t = clamp((uIntro - aRand.z * 0.4) / 0.6, 0.0, 1.0);
float e = 1.0 - pow(1.0 - t, 4.0);
p = mix(aScatter, p, e);
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
float depth = -mv.z;
float blur = clamp(abs(depth - uFocus) * 0.3, 0.0, 1.6);
float size = aSize * (0.8 + aRand.w * 0.4);
gl_PointSize = min(size * uScale / max(depth, 0.05) * (1.0 + blur * 1.3), 64.0);
float alpha = aAlpha * mix(1.0, sin(f * 3.14159), aMove);
alpha *= (0.25 + 0.75 * e) / (1.0 + blur * 1.5);
alpha *= smoothstep(0.25, 1.6, depth) * uFade;
vAlpha = alpha;
vSoft = clamp(0.35 + blur * 0.45, 0.35, 1.0);
vColor = mix(PAPER, LIME, clamp(aTone + lift * 1.6, 0.0, 1.0));
}
`;
const fragmentShader = /* glsl */ `
varying vec3 vColor;
varying float vAlpha;
varying float vSoft;
void main() {
float r = length(gl_PointCoord - 0.5) * 2.0;
float a = 1.0 - smoothstep(1.0 - vSoft, 1.0, r);
if (a <= 0.0) discard;
gl_FragColor = vec4(vColor, a * vAlpha);
}
`;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
/** Interpola entre quadros-chave [progresso, valor] com suavização em cada trecho. */
function keyframes(frames: readonly (readonly [number, number])[], p: number) {
if (p <= frames[0][0]) return frames[0][1];
for (let i = 1; i < frames.length; i++) {
const [p1, v1] = frames[i];
if (p <= p1) {
const [p0, v0] = frames[i - 1];
return v0 + (v1 - v0) * smooth(p0, p1, p);
}
}
return frames[frames.length - 1][1];
}
/** Distância mínima entre as camadas no fim do mergulho: cada uma vira um "cômodo" que a câmera visita. */
const SPREAD = 5.5;
/** Distância para enquadrar uma camada inteira numa tela larga. */
const FRAME = 5.2;
/*
* Coreografia da câmera (z): afasta para enquadrar a tela inteira, atravessa, enquadra as integrações,
* atravessa, enquadra os dados, atravessa. As legendas de Hero.tsx (--from/--to em globals.css) seguem
* estes mesmos intervalos: tela 0,26–0,46; integrações 0,52–0,71; dados 0,76–0,92.
* Em tela vertical (celular) a distância de enquadramento cresce para a camada caber na largura, e as
* camadas se afastam mais para a câmera caber entre elas.
*/
function cameraFrames(rest: number, spread: number, frame: number) {
return [
[0, rest],
[0.28, spread + frame],
[0.44, spread + frame * 0.87],
[0.5, spread - 0.3],
[0.55, frame],
[0.69, frame * 0.87],
[0.74, -0.3],
[0.78, -spread + frame],
[0.89, -spread + frame * 0.87],
[0.95, -spread - 0.3],
[1, -spread - 2],
] as const;
}
// ponto de cada camada onde a etiqueta se prende (canto direito da frente), na ordem tela, integrações, dados
const LABEL_ANCHORS: [number, Layer, number][] = [
[2.1, 1, 1.4],
[1.95, 0, 0.9],
[2, -1, 1.3],
];
function Field({
progressRef,
labelsRef,
onReady,
onError,
}: HeroFieldSceneProps) {
const { gl, camera, size } = useThree();
const group = useRef<THREE.Group>(null);
const dense = useMemo(
() => window.innerWidth >= 900 && (navigator.hardwareConcurrency ?? 4) >= 4,
[],
);
const geometry = useMemo(() => buildField(dense), [dense]);
const material = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uIntro: { value: 0 },
uSpread: { value: 1.05 },
uScroll: { value: 0 },
uScale: { value: 1 },
uFocus: { value: 9 },
uFade: { value: 1 },
uMouse: { value: new THREE.Vector2(9, 9) },
uMouseAmt: { value: 0 },
},
}),
[],
);
const state = useRef({
progress: 0,
pointer: new THREE.Vector2(),
lastMove: -10,
mouse: new THREE.Vector2(),
tilt: new THREE.Vector2(),
frames: 0,
});
const helpers = useMemo(
() => ({
ray: new THREE.Raycaster(),
plane: new THREE.Plane(),
hit: new THREE.Vector3(),
anchor: new THREE.Vector3(),
zero: new THREE.Vector2(),
target: new THREE.Vector2(),
}),
[],
);
useEffect(() => {
const onMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
const s = state.current;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
s.lastMove = performance.now() / 1000;
};
const onLost = (event: Event) => {
event.preventDefault();
onError();
};
window.addEventListener("pointermove", onMove, { passive: true });
gl.domElement.addEventListener("webglcontextlost", onLost);
return () => {
window.removeEventListener("pointermove", onMove);
gl.domElement.removeEventListener("webglcontextlost", onLost);
geometry.dispose();
material.dispose();
};
}, [gl, geometry, material, onError]);
useFrame((frame, delta) => {
const g = group.current;
if (!g) return;
const dt = Math.min(delta, 1 / 20);
const s = state.current;
const u = material.uniforms;
const time = frame.clock.elapsedTime;
// rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera
s.progress +=
((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5));
const p = s.progress;
const aspect = size.width / size.height;
const wide = aspect > 1.1;
const tanHalf = Math.tan(
THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2),
);
// enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé
const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect));
const spreadMax = Math.max(SPREAD, frameDist + 0.6);
const rest = Math.max(9, frameDist * 1.1);
u.uTime.value = time;
u.uIntro.value = Math.min(1, u.uIntro.value + dt / 2.6);
u.uScroll.value = p;
// as camadas só se afastam enquanto ficam de frente: inclinadas e afastadas sairiam do quadro
const face = smooth(0.02, 0.28, p);
u.uSpread.value = 1.05 + (spreadMax - 1.05) * face * face;
u.uFade.value = 1 - smooth(0.93, 1, p);
// com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir
u.uScale.value =
((gl.getPixelRatio() * size.height * 0.5) / tanHalf) *
Math.sqrt(frameDist / FRAME);
// sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela
const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0;
s.tilt.lerp(mouseActive ? s.pointer : helpers.zero, 1 - Math.exp(-dt * 3));
// em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas
g.rotation.set(
0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08,
(-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) +
s.tilt.x * 0.15 * (1 - face),
0,
);
const settle = 1 - smooth(0, 0.28, p);
// em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões
g.position.set(
wide ? 1.7 * settle : 0,
(wide ? -0.1 : -rest * tanHalf * 0.38) * settle,
0,
);
camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p);
// foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente)
const spread = u.uSpread.value;
const ahead =
[spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread;
u.uFocus.value = Math.max(1.5, camera.position.z - ahead);
g.updateMatrixWorld();
if (mouseActive) {
helpers.ray.setFromCamera(s.pointer, camera);
helpers.plane
.set(new THREE.Vector3(0, 1, 0), -u.uSpread.value)
.applyMatrix4(g.matrixWorld);
if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) {
g.worldToLocal(helpers.hit);
s.mouse.lerp(
helpers.target.set(helpers.hit.x, helpers.hit.z),
1 - Math.exp(-dt * 10),
);
}
} else {
s.mouse.lerp(
helpers.target.set(
Math.sin(time * 0.31) * 1.5,
Math.sin(time * 0.23 + 1.3) * 1.1,
),
1 - Math.exp(-dt * 1.5),
);
}
u.uMouse.value.copy(s.mouse);
const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p));
u.uMouseAmt.value +=
(targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4));
// etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa
const labels = labelsRef.current ?? [];
const shown = smooth(0.75, 1, u.uIntro.value) * (1 - smooth(0.04, 0.16, p));
LABEL_ANCHORS.forEach(([x, layer, z], index) => {
const label = labels[index];
if (!label) return;
helpers.anchor
.set(x, layer * u.uSpread.value, z)
.applyMatrix4(g.matrixWorld)
.project(camera);
const px = ((helpers.anchor.x + 1) / 2) * size.width;
const py = ((1 - helpers.anchor.y) / 2) * size.height;
label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`;
label.style.opacity = shown.toFixed(3);
});
if (++s.frames === 2) onReady();
});
return (
<group ref={group}>
<points geometry={geometry} material={material} frustumCulled={false} />
</group>
);
}
class SceneBoundary extends Component<
{ children: ReactNode; onError: () => void },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onError();
}
render() {
return this.state.failed ? null : this.props.children;
}
}
export default function HeroFieldScene(props: HeroFieldSceneProps) {
return (
<SceneBoundary onError={props.onError}>
<Canvas
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }}
dpr={[1, 1.75]}
frameloop={props.visible ? "always" : "never"}
gl={{
alpha: true,
antialias: false,
powerPreference: "high-performance",
}}
>
<Field {...props} />
</Canvas>
</SceneBoundary>
);
}
+16 -180
View File
@@ -1,180 +1,9 @@
"use client";
import { useRef, useMemo, useCallback, useEffect } from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Float, MeshDistortMaterial } from "@react-three/drei";
import { useEffect, useMemo, useRef } from "react";
import * as THREE from "three";
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() {
const meshRef = useRef<THREE.Mesh>(null);
const mesh2Ref = useRef<THREE.Mesh>(null);
const mesh3Ref = useRef<THREE.Mesh>(null);
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 = 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 = 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={[4, 1.5, -3]} scale={1.2}>
<icosahedronGeometry args={[1, 1]} />
<MeshDistortMaterial
color="#ccff00"
wireframe
transparent
opacity={0.15}
distort={0.3}
speed={2}
/>
</mesh>
</Float>
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
<mesh ref={mesh2Ref} position={[-5, -2, -4]} scale={0.8}>
<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={[2, -3, -5]} scale={1.5}>
<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);
@@ -226,7 +55,7 @@ function GridFloor() {
}
`,
}),
[]
[],
);
return (
@@ -266,7 +95,11 @@ function CameraRig() {
return null;
}
export default function HeroScene() {
export default function HeroScene({
isVisible = true,
}: {
isVisible?: boolean;
}) {
return (
<div
className="absolute inset-0 z-0"
@@ -275,6 +108,7 @@ export default function HeroScene() {
<Canvas
camera={{ position: [0, 0.5, 8], fov: 60, near: 0.1, far: 100 }}
dpr={[1, 1.5]}
frameloop={isVisible ? "always" : "never"}
gl={{
antialias: false,
alpha: true,
@@ -290,14 +124,16 @@ export default function HeroScene() {
display: "block",
}}
>
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
<ambientLight intensity={0.3} />
<directionalLight position={[5, 5, 5]} intensity={0.5} color="#ccff00" />
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
<ambientLight intensity={0.25} />
<directionalLight
position={[5, 5, 5]}
intensity={0.4}
color="#ccff00"
/>
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
<CameraRig />
<ParticleField count={1500} />
<FloatingGeometry />
<GridFloor />
</Canvas>
</div>
+404
View File
@@ -0,0 +1,404 @@
"use client";
import { useEffect, useRef } from "react";
const MAX_DPR = 1.5;
const ASSEMBLE_MS = 2000;
const REPEL_RADIUS_PX = 80;
// Densidade: ~1 star por STAR_AREA_DIVISOR px² CSS, entre piso e teto absolutos
const STAR_AREA_DIVISOR = 700;
const MIN_STARS = 300;
const MAX_STARS = 850;
const OFFSCREEN_POINTER = -100000;
type Particle = {
sx: number; // origem da assemblei (espalhada) no primeiro load
sy: number;
tx: number; // âncora na grade com jitter
ty: number;
ox: number; // deslocamento acumulado do repel do ponteiro
oy: number;
delay: number;
fillStyle: string;
baseAlpha: number;
size: number;
phase: number;
speed: number;
amp: number;
ttl: number;
birth: number;
};
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;
}
export function HeroStarfield() {
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 sampleRetryTimer = 0;
let sampleAttempts = 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;
// Grade estratificada: uma star por célula com jitter interno → cobertura
// uniforme do card, sem os aglomerados/vazios do random puro
const grid = {
cols: 1,
rows: 1,
cellW: canvas.width || 1,
cellH: canvas.height || 1,
};
const jitteredCellPosition = (cell: number): [number, number] => {
const cx = cell % grid.cols;
const cy = Math.floor(cell / grid.cols);
return [
(cx + Math.random()) * grid.cellW,
(cy + Math.random()) * grid.cellH,
];
};
const makeParticle = (tx: number, ty: number): Particle => {
const useSlash = Math.random() < 0.45;
const rgb = useSlash ? slashRgb : paperRgb;
return {
tx,
ty,
sx: hasAssembled ? tx : (Math.random() * 1.3 - 0.15) * canvas.width,
sy: hasAssembled ? ty : (Math.random() * 1.3 - 0.15) * canvas.height,
ox: 0,
oy: 0,
delay: Math.random() * 0.35,
fillStyle: `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`,
baseAlpha: 0.5 + Math.random() * 0.45,
size: (Math.random() < 0.72 ? 2 : 3) * dpr,
phase: Math.random() * Math.PI * 2,
// deriva lenta: período ~2–5s com amplitude 2,5–5,5px CSS → "levemente"
speed: 0.0011 + Math.random() * 0.0016,
amp: (2.5 + Math.random() * 3) * dpr,
ttl: 4000 + Math.random() * 6000,
// birth negativo: estagia os ciclos de vida para o spawn/death já rodar disperso no load
birth: -Math.random() * 10000,
};
};
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 count = Math.round(
Math.min(
MAX_STARS,
Math.max(MIN_STARS, (width * height) / STAR_AREA_DIVISOR),
),
);
// células quadradas-ish para a proporção do card
grid.cols = Math.max(
1,
Math.round(Math.sqrt(count * (canvas.width / canvas.height))),
);
grid.rows = Math.max(1, Math.ceil(count / grid.cols));
grid.cellW = canvas.width / grid.cols;
grid.cellH = canvas.height / grid.rows;
// ordem embaralhada das células: cada star ocupa uma célula distinta no spawn
const cells = Array.from({ length: grid.cols * grid.rows }, (_, i) => i);
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = cells[i];
cells[i] = cells[j];
cells[j] = tmp;
}
particles = cells.slice(0, count).map((cell) => {
const [tx, ty] = jitteredCellPosition(cell);
return makeParticle(tx, ty);
});
};
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;
// wander Lissajous: harmônico principal + secundário em razão
// não-inteira → trajetória contínua sem repetição óbvia
const t = elapsed * particle.speed;
const driftX =
(Math.sin(t + particle.phase) +
0.5 * Math.sin(t * 0.43 + particle.phase * 1.7)) *
particle.amp *
progress;
const driftY =
(Math.cos(t * 0.9 + particle.phase) +
0.5 * Math.cos(t * 0.51 + particle.phase * 2.3)) *
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;
const fadeIn = Math.min(elapsed / 1200, 1);
const twinkle = 0.6 + 0.4 * Math.sin(elapsed * 0.003 + particle.phase);
let life = elapsed - particle.birth;
if (life > particle.ttl) {
// renasce em outra célula da grade → uniforme também entre ciclos
const [tx, ty] = jitteredCellPosition(
Math.floor(Math.random() * grid.cols * grid.rows),
);
particle.tx = tx;
particle.ty = ty;
particle.sx = tx;
particle.sy = ty;
particle.phase = Math.random() * Math.PI * 2;
particle.baseAlpha = 0.5 + Math.random() * 0.45;
particle.size = (Math.random() < 0.72 ? 2 : 3) * dpr;
particle.ttl = 4000 + Math.random() * 6000;
particle.birth = elapsed;
life = 0;
}
// fade-in de 800ms ao nascer, fade-out nos últimos 800ms de vida
const lifeFade = Math.min(life / 800, (particle.ttl - life) / 800, 1);
ctx.globalAlpha = Math.min(
1,
particle.baseAlpha * fadeIn * twinkle * lifeFade,
);
ctx.fillStyle = particle.fillStyle;
ctx.fillRect(
baseX + driftX + particle.ox,
baseY + driftY + particle.oy,
particle.size,
particle.size,
);
}
ctx.globalAlpha = 1;
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.fillStyle;
ctx.globalAlpha = particle.baseAlpha;
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
}
ctx.globalAlpha = 1;
};
const tick = (now: number) => {
if (!running) return;
elapsed += Math.min(now - lastTime, 50);
lastTime = now;
render();
rafId = window.requestAnimationFrame(tick);
};
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);
}
};
// Warm-up determinístico: corrida de layout pode deixar o container com
// 0px no primeiro sample; re-tenta até existirem partículas
const sampleUntilBuilt = (onBuilt?: () => void) => {
sample();
if (particles.length > 0 || cancelled) {
onBuilt?.();
return;
}
if (sampleAttempts++ > 50) return;
sampleRetryTimer = window.setTimeout(
() => sampleUntilBuilt(onBuilt),
120,
);
};
const scheduleResample = () => {
window.clearTimeout(resampleTimer);
resampleTimer = window.setTimeout(() => {
sampleUntilBuilt(() => {
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
hasAssembled = true;
sampleUntilBuilt(() => drawStaticFrame());
return () => {
cancelled = true;
window.clearTimeout(resampleTimer);
window.clearTimeout(sampleRetryTimer);
resizeObserver.disconnect();
};
}
// Start determinístico: intersecting derivado de geometry no setup (o callback do IO
// pode chegar tarde e deixar o loop rAF morto na carga)
const syncIntersecting = () => {
const current = container.getBoundingClientRect();
intersecting =
current.bottom > 0 &&
current.right > 0 &&
current.top < window.innerHeight &&
current.left < window.innerWidth;
};
syncIntersecting();
sampleUntilBuilt(() => syncLoop());
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);
return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
window.clearTimeout(resampleTimer);
window.clearTimeout(sampleRetryTimer);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener("pointermove", handlePointerMove);
document.documentElement.removeEventListener(
"pointerleave",
handlePointerLeave,
);
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return (
<div
ref={containerRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<canvas ref={canvasRef} className="h-full w-full" />
</div>
);
}
-66
View File
@@ -1,66 +0,0 @@
"use client";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import MagneticButton from "./MagneticButton";
export default function HomeButton() {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<AnimatePresence>
{!isHome && (
<motion.div
className="fixed left-6 top-6 z-[9000]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1], delay: 0.1 }}
>
<MagneticButton
as="a"
href="/"
strength={0.3}
aria-label="Voltar para o início"
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.72)] px-4 py-2.5 text-[11px] font-mono uppercase tracking-[0.1em] text-[rgba(244,242,237,0.76)] backdrop-blur-[16px] transition-all duration-300 hover:border-[rgba(204,255,0,0.5)] hover:text-[var(--slash)]"
>
{/* Glow effect */}
<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)",
}}
/>
{/* Arrow left icon */}
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="relative z-10 -translate-x-0 transition-transform duration-300 group-hover:-translate-x-0.5"
>
<path d="M7.5 1.5 L2.5 6 L7.5 10.5" />
</svg>
<span className="relative z-10">Home</span>
{/* Animated neon dot */}
<motion.span
className="relative z-10 ml-0.5 h-1.5 w-1.5 rounded-full bg-[var(--slash)]"
animate={{ opacity: [1, 0.3, 1], scale: [1, 0.8, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
/>
</MagneticButton>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,104 +0,0 @@
"use client";
import { useRef, useEffect, type ReactNode } from "react";
interface HorizontalScrollProps {
children: ReactNode;
className?: string;
}
export default function HorizontalScroll({
children,
className = "",
}: HorizontalScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
const track = trackRef.current;
if (!container || !track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Disable on mobile for better UX
if (window.innerWidth < 768) return;
let cleanup: (() => void) | undefined;
const setup = async () => {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
gsap.registerPlugin(ScrollTrigger);
const totalWidth = track.scrollWidth;
const viewportWidth = window.innerWidth;
const tween = gsap.to(track, {
x: -(totalWidth - viewportWidth),
ease: "none",
scrollTrigger: {
trigger: container,
pin: true,
scrub: 1,
end: () => `+=${totalWidth - viewportWidth}`,
invalidateOnRefresh: true,
},
});
// Animate individual items as they come into view
const items = track.querySelectorAll("[data-scroll-item]");
for (const item of items) {
gsap.fromTo(
item,
{
opacity: 0.3,
scale: 0.9,
rotateY: -8,
transformPerspective: 1000,
},
{
opacity: 1,
scale: 1,
rotateY: 0,
scrollTrigger: {
trigger: item,
containerAnimation: tween,
start: "left 80%",
end: "left 30%",
scrub: true,
},
}
);
}
cleanup = () => {
ScrollTrigger.getAll().forEach((st) => {
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
st.kill();
}
});
tween.kill();
};
};
void setup();
return () => {
cleanup?.();
};
}, []);
return (
<div ref={containerRef} className={`relative overflow-hidden ${className}`}>
<div
ref={trackRef}
className="flex items-stretch gap-sp-6 will-change-transform"
>
{children}
</div>
</div>
);
}
@@ -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();
});
});
+48 -28
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;
@@ -13,7 +13,6 @@ interface MagneticButtonProps {
target?: string;
rel?: string;
download?: boolean | string;
"data-cursor"?: string;
type?: "button" | "submit" | "reset";
wrapperClassName?: string;
"aria-label"?: string;
@@ -29,19 +28,14 @@ export default function MagneticButton({
target,
rel,
download,
"data-cursor": dataCursor,
type,
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 +49,57 @@ 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 } : {};
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} ${wrapperClassName}`.trim()}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
onClick={onClick}
{...motionProps}
{...linkProps}
{...buttonProps}
aria-label={ariaLabel}
>
{children}
</Tag>
);
}
return (
<div
ref={ref}
onMouseMove={handleMouse}
onMouseLeave={handleMouseLeave}
className={`inline-block ${wrapperClassName}`.trim()}
>
<div className={`inline-block ${wrapperClassName}`.trim()}>
<Tag
className={className}
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}
-177
View File
@@ -1,177 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
const INTRO_LINES = [
"EVANDRO",
"FONSECA",
"JUNIOR",
];
export default function PageIntro() {
const [isVisible, setIsVisible] = useState(true);
const [counter, setCounter] = useState(0);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Remove the immediate blocking overlay now that the animated loader is ready
const blocker = document.getElementById("__loading-blocker");
if (blocker) blocker.remove();
}, []);
useEffect(() => {
if (!mounted) return;
// Animate counter from 0 to 100
const duration = 2000;
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// Eased progress
const eased = 1 - Math.pow(1 - progress, 3);
setCounter(Math.floor(eased * 100));
if (progress < 1) {
requestAnimationFrame(tick);
} else {
// Wait a beat then hide
setTimeout(() => setIsVisible(false), 400);
}
};
requestAnimationFrame(tick);
}, [mounted]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{isVisible && (
<motion.div
className="fixed inset-0 z-[99999] flex items-center justify-center bg-[#0a0a0a]"
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, width: "100vw", height: "100vh" }}
exit={{
clipPath: "inset(0 0 100% 0)",
transition: { duration: 1, ease: [0.76, 0, 0.24, 1] },
}}
>
{/* Horizontal lines decoration */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="absolute left-0 h-px w-full bg-[rgba(204,255,0,0.06)]"
style={{ top: `${20 + i * 15}%` }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{
duration: 1.5,
delay: 0.1 * i,
ease: [0.76, 0, 0.24, 1],
}}
/>
))}
</div>
{/* Counter */}
<motion.div
className="absolute bottom-12 right-12 font-mono text-[clamp(48px,8vw,96px)] font-bold leading-none tracking-tighter text-[rgba(204,255,0,0.15)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{counter.toString().padStart(3, "0")}
</motion.div>
{/* Progress bar */}
<div className="absolute bottom-8 left-12 right-12 h-px bg-[rgba(255,255,255,0.06)]">
<motion.div
className="h-full bg-[var(--slash)]"
initial={{ width: "0%" }}
animate={{ width: `${counter}%` }}
transition={{ duration: 0.1 }}
/>
</div>
{/* Name reveal */}
<div className="relative flex flex-col items-center gap-0">
{INTRO_LINES.map((line, i) => (
<div key={line} className="overflow-hidden">
<motion.div
className="font-display text-[clamp(2.5rem,10vw,7rem)] font-extrabold leading-[0.9] tracking-[-0.06em] text-[#f4f2ed]"
initial={{ y: "100%", rotateX: -40 }}
animate={{ y: "0%", rotateX: 0 }}
transition={{
duration: 0.8,
delay: 0.3 + i * 0.12,
ease: [0.16, 1, 0.3, 1],
}}
>
{line}
</motion.div>
</div>
))}
{/* Subtitle */}
<motion.div
className="mt-6 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.3em] text-[rgba(204,255,0,0.7)]"
initial={{ y: 20 }}
animate={{ y: 0 }}
transition={{ delay: 1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
Software Engineer
</motion.p>
</motion.div>
</div>
{/* Corner markers */}
<div className="pointer-events-none absolute inset-0">
{/* Top-left */}
<motion.div
className="absolute left-8 top-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.5 }}
>
<div className="h-6 w-px bg-[var(--slash)]" />
<div className="absolute left-0 top-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
{/* Top-right */}
<motion.div
className="absolute right-8 top-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.6 }}
>
<div className="absolute right-0 top-0 h-6 w-px bg-[var(--slash)]" />
<div className="absolute right-0 top-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
{/* Bottom-left */}
<motion.div
className="absolute bottom-8 left-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.7 }}
>
<div className="h-6 w-px bg-[var(--slash)]" />
<div className="absolute bottom-0 left-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body
);
}
+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 (
<>
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useId, useState } from "react";
interface ScrollVelocityTextProps {
text: string;
direction: "left" | "right";
className?: string;
}
const GROUPS = [0, 1] as const;
export default function ScrollVelocityText({
text,
direction,
className = "",
}: ScrollVelocityTextProps) {
const trackId = useId();
const [isPaused, setIsPaused] = useState(false);
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
setShouldReduceMotion(media.matches);
const updatePreference = (event: MediaQueryListEvent) => {
setShouldReduceMotion(event.matches);
};
media.addEventListener("change", updatePreference);
return () => media.removeEventListener("change", updatePreference);
}, []);
const animationPaused = shouldReduceMotion || isPaused;
return (
<div className={`scroll-velocity-text ${className}`.trim()}>
<div className="scroll-velocity-viewport">
<div
id={trackId}
className={`scroll-velocity-track ${
direction === "left" ? "animate-marquee" : "animate-marquee-reverse"
}`}
style={{
animationPlayState: animationPaused ? "paused" : undefined,
}}
>
{GROUPS.map((group) => (
<span
key={group}
className="scroll-velocity-group"
aria-hidden={group === 0 ? undefined : "true"}
>
<span className="scroll-velocity-item">
<span aria-hidden={group === 0 ? "false" : "true"}>{text}</span>
<span aria-hidden="true" className="scroll-velocity-dot">
&#x2022;
</span>
</span>
</span>
))}
</div>
</div>
{!shouldReduceMotion ? (
<button
type="button"
className="scroll-velocity-control"
aria-controls={trackId}
aria-pressed={isPaused}
onClick={() => setIsPaused((paused) => !paused)}
>
{isPaused ? "Retomar animação" : "Pausar animação"}
</button>
) : null}
</div>
);
}
@@ -1,98 +0,0 @@
"use client";
import { useRef, useEffect, useState } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useTransform,
useSpring,
useScroll,
} from "framer-motion";
interface ScrollVelocityTextProps {
text: string;
baseVelocity?: number;
className?: string;
}
function wrap(min: number, max: number, v: number) {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
}
export default function ScrollVelocityText({
text,
baseVelocity = 2,
className = "",
}: ScrollVelocityTextProps) {
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useMotionValue(0);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 50,
stiffness: 300,
});
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
clamp: false,
});
const [repetitions, setRepetitions] = useState(4);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleResize = () => {
setRepetitions(Math.ceil(window.innerWidth / 400) + 2);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Track scroll velocity
const prevScrollY = useRef(0);
useEffect(() => {
return scrollY.on("change", (latest) => {
const delta = latest - prevScrollY.current;
prevScrollY.current = latest;
scrollVelocity.set(Math.abs(delta));
});
}, [scrollY, scrollVelocity]);
const directionFactor = useRef(1);
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
useAnimationFrame((_, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
const vf = velocityFactor.get();
if (vf < 0) {
directionFactor.current = -1;
} else if (vf > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * vf;
baseX.set(baseX.get() + moveBy);
});
return (
<div
ref={containerRef}
className={`overflow-hidden whitespace-nowrap ${className}`}
>
<motion.div className="inline-flex" style={{ x }}>
{[...Array(repetitions)].map((_, i) => (
<span
key={i}
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>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,44 @@
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
className?: string;
}
const PULSE_DOTS = ["dot-1", "dot-2", "dot-3", "dot-4", "dot-5"] as const;
export default function SectionDivider({
variant = "gradient",
className = "",
}: SectionDividerProps) {
const rootClassName =
`section-divider section-divider-${variant} ${className}`.trim();
if (variant === "gradient") {
return (
<div aria-hidden="true" className={rootClassName}>
<span />
<span />
</div>
);
}
if (variant === "pulse") {
return (
<div aria-hidden="true" className={rootClassName}>
{PULSE_DOTS.map((dot) => (
<span key={dot} />
))}
</div>
);
}
return (
<div aria-hidden="true" className={rootClassName}>
<svg viewBox="0 0 1200 60" preserveAspectRatio="none" focusable="false">
<title>Divisor decorativo</title>
<path d="M0 30 C100 8 180 52 280 30 S460 8 560 30 740 52 840 30 1020 8 1200 30" />
<path d="M0 30 C120 48 220 12 340 30 S560 48 680 30 900 12 1020 30 1140 48 1200 30" />
<path d="M0 30 C80 20 160 40 240 30 S400 20 480 30 640 40 720 30 880 20 960 30 1120 40 1200 30" />
</svg>
</div>
);
}
-112
View File
@@ -1,112 +0,0 @@
"use client";
import { useRef, useEffect } from "react";
interface SectionDividerProps {
variant?: "gradient" | "pulse" | "waveform";
className?: string;
}
export default function SectionDivider({
variant = "gradient",
className = "",
}: SectionDividerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (variant !== "waveform") return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = 60;
};
resize();
window.addEventListener("resize", resize);
const render = () => {
animId = requestAnimationFrame(render);
const { width, height } = canvas;
const t = performance.now() * 0.001;
ctx.clearRect(0, 0, width, height);
// Draw multiple waveforms
for (let wave = 0; wave < 3; wave++) {
ctx.beginPath();
ctx.strokeStyle = `rgba(204, 255, 0, ${0.1 - wave * 0.03})`;
ctx.lineWidth = 1;
for (let x = 0; x < width; x++) {
const frequency = 0.005 + wave * 0.002;
const amplitude = 8 + wave * 4;
const speed = t * (0.5 + wave * 0.2);
const y =
height / 2 +
Math.sin(x * frequency + speed) * amplitude +
Math.sin(x * frequency * 2.3 + speed * 1.5) * (amplitude * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
};
animId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", resize);
};
}, [variant]);
if (variant === "gradient") {
return (
<div className={`relative h-px w-full overflow-hidden ${className}`}>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-30" />
<div
className="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--slash)] to-transparent opacity-60"
style={{
animation: "shimmerDivider 3s ease-in-out infinite",
}}
/>
</div>
);
}
if (variant === "pulse") {
return (
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-1 w-1 rounded-full bg-[var(--slash)]"
style={{
animation: `pulseDot 2s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</div>
);
}
// waveform
return (
<div className={`relative ${className}`}>
<canvas
ref={canvasRef}
className="w-full opacity-60"
style={{ height: 60 }}
aria-hidden="true"
/>
</div>
);
}
+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>
);
}
@@ -0,0 +1,378 @@
"use client";
import { type RefObject, useEffect } from "react";
type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
touch: boolean,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
*
* Quem segura as seções paradas durante uma passagem:
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
const visibleCenter = (el: HTMLElement) => () =>
`50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next, touch) => {
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
if (touch) {
tl.fromTo(
next,
{
opacity: 0.3,
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
},
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
return;
}
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
(tl, prev, next, touch) => {
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
);
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
const radius = () =>
touch
? Math.hypot(window.innerWidth / 2, vh())
: Math.hypot(window.innerWidth / 2, vh() / 2);
if (!touch) holdNext(tl, next);
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
tl.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${radius()}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ xPercent: 0, opacity: 1 },
{ xPercent: -35, opacity: 0.3 },
0,
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
},
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next, touch) => {
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
if (!touch)
tl.fromTo(
next,
{ y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ opacity: 1, xPercent: 0 },
{ opacity: 0.25, xPercent: 20 },
0,
).fromTo(
next,
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ xPercent: 0, rotation: 0, ease: "power2.out" },
0,
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: touch ? 0.8 : 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
)
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
},
];
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
const touch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
const scenes = Array.from(main.children) as HTMLElement[];
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
const sceneTops = () => {
let top = pageTop(main);
return scenes.map((scene) => {
const current = top;
top += scene.offsetHeight;
return current;
});
};
const stick = () => {
for (const scene of scenes)
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
};
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, {
position: "sticky",
zIndex: index === 0 ? 30 : index + 1,
});
});
stick();
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
if (!prev || !next) return;
const tl = gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => sceneTops()[index + 1] - vh(),
end: () => sceneTops()[index + 1],
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
onLeave: () => gsap.set(next, { clipPath: "none" }),
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
onEnter: () => next.classList.add("is-drawing"),
},
});
transition(tl, prev, next, touch);
});
}, main);
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = sceneTops();
scenes.forEach((scene, index) => {
const from =
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
const to =
index === scenes.length - 1
? Infinity
: tops[index + 1] + vh() * 0.25;
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
});
};
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
const onRefresh = () => {
stick();
queueSync();
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", onRefresh);
window.addEventListener("scroll", queueSync, { passive: true });
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
});
resizeObserver.observe(main);
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
const index = scenes.findIndex((candidate) =>
candidate.contains(target),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = index < 0 ? 0 : sceneTops()[index];
const top =
index <= 0
? 0
: Math.max(
sceneTop,
scenes[index] === target
? 0
: sceneTop +
pageTop(target) -
pageTop(scenes[index]) -
offset,
);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
easing: easeInOutCubic,
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
onComplete: () => {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
},
});
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", onRefresh);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) {
scene.style.visibility = "";
scene.style.top = "";
}
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}
@@ -0,0 +1,125 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
function fragmentTarget(hash: string) {
if (!hash) return null;
try {
return document.getElementById(decodeURIComponent(hash.slice(1)));
} catch {
return null;
}
}
function focusFragment(target: HTMLElement) {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
}
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
export function useHomeFragmentNavigation() {
const ready = useRef(false);
const resolveArrival = useRef<(() => void) | null>(null);
useEffect(() => {
const navigation = performance.getEntriesByType?.("navigation")[0] as
| PerformanceNavigationTiming
| undefined;
// A history restoration owns its saved position, not the URL's old fragment.
let pending =
navigation?.type === "back_forward" ? "" : window.location.hash;
const resolve = () => {
if (!ready.current || !pending) return;
const hash = pending;
pending = "";
if (hash !== window.location.hash) return;
const target = fragmentTarget(hash);
if (!target) return;
focusFragment(target);
target.scrollIntoView({ block: "start", behavior: "instant" });
};
const cancelArrival = () => {
pending = "";
};
const onKeyDown = (event: KeyboardEvent) => {
if (
[
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ",
"Tab",
].includes(event.key)
) {
cancelArrival();
}
};
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== window.location.origin ||
url.pathname !== window.location.pathname ||
url.search !== window.location.search
)
return;
const target = fragmentTarget(url.hash);
if (!target) return;
pending = ready.current ? "" : url.hash;
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
focusFragment(target);
};
const onHashChange = () => {
if (pending && pending !== window.location.hash) cancelArrival();
if (!ready.current) return;
const target = fragmentTarget(window.location.hash);
if (target) focusFragment(target);
};
resolveArrival.current = resolve;
resolve();
document.addEventListener("click", onClick, true);
window.addEventListener("hashchange", onHashChange);
window.addEventListener("wheel", cancelArrival, { passive: true });
window.addEventListener("touchmove", cancelArrival, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", cancelArrival, { passive: true });
return () => {
resolveArrival.current = null;
document.removeEventListener("click", onClick, true);
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener("wheel", cancelArrival);
window.removeEventListener("touchmove", cancelArrival);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", cancelArrival);
};
}, []);
return useCallback(() => {
ready.current = true;
resolveArrival.current?.();
}, []);
}
@@ -0,0 +1,427 @@
"use client";
import Image from "next/image";
import {
type Ref,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline";
type CursorFrame = Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
export type NotebookBrowserHandle = {
updateFrame: (frame: CursorFrame) => void;
};
export type NotebookBrowserProps = {
projects: NotebookProject[];
activeIndex: number;
frame: CursorFrame;
ref?: Ref<NotebookBrowserHandle>;
onSelectTab: (index: number) => void;
interactive?: boolean;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
function toDomId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
export default function NotebookBrowser({
projects,
activeIndex,
frame,
onSelectTab,
interactive = true,
ref,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
const rootRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef<SVGSVGElement>(null);
const cursorFrame = useRef(frame);
const tabPoints = useRef<Array<{ x: number; y: number }>>([]);
const browserSize = useRef({ width: 0, height: 0 });
const [displayed, setDisplayed] = useState<NotebookProject | null>(null);
const [outgoing, setOutgoing] = useState<NotebookProject | null>(null);
const [failedSrc, setFailedSrc] = useState<string | null>(null);
const requestedSrc = selectedProject?.screenshotSrc;
const requestRef = useRef(requestedSrc);
const requestVersion = useRef(0);
const displayedRef = useRef(displayed);
const mounted = useRef(true);
useLayoutEffect(() => {
requestRef.current = requestedSrc;
requestVersion.current += 1;
setFailedSrc(null);
}, [requestedSrc]);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
// Only the immediate neighbours are fetched; the browser cache handles reuse.
useEffect(() => {
const images = [projects[selectedIndex - 1], projects[selectedIndex + 1]]
.filter((project): project is NotebookProject => Boolean(project))
.map((project) => {
const image = new window.Image();
image.decoding = "async";
image.src = project.screenshotSrc;
return image;
});
return () => {
images.forEach((image) => {
image.removeAttribute("src");
});
};
}, [projects, selectedIndex]);
const updateFrame = useCallback((next: CursorFrame) => {
cursorFrame.current = next;
const cursor = cursorRef.current;
const points = tabPoints.current;
if (!cursor || points.length === 0) return;
const position = Math.max(
0,
Math.min(points.length - 1, next.cursorX * points.length - 0.5),
);
const left = Math.floor(position);
const right = Math.min(left + 1, points.length - 1);
const blend = position - left;
const entry = Math.max(0, Math.min(1, (0.95 - next.cursorY) / 0.65));
const tabX = points[left].x + (points[right].x - points[left].x) * blend;
const tabY = points[left].y + (points[right].y - points[left].y) * blend;
const x = browserSize.current.width * 0.5 * (1 - entry) + tabX * entry;
const y = browserSize.current.height * 0.95 * (1 - entry) + tabY * entry;
cursor.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${next.cursorClicking ? 0.88 : 1})`;
cursor.style.opacity = next.cursorVisible ? "1" : "0";
cursor.classList.toggle("is-clicking", next.cursorClicking);
}, []);
useImperativeHandle(ref, () => ({ updateFrame }), [updateFrame]);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return undefined;
const measure = () => {
browserSize.current = {
width: root.clientWidth,
height: root.clientHeight,
};
const points = [];
for (const project of projects) {
const tab: HTMLButtonElement | null =
root.querySelector<HTMLButtonElement>(
`#notebook-tab-${toDomId(project.id)}`,
);
if (!tab) continue;
let x = tab.offsetWidth / 2;
let y = tab.offsetHeight / 2;
let element: HTMLElement | null = tab;
while (element && element !== root) {
x += element.offsetLeft;
y += element.offsetTop;
element = element.offsetParent as HTMLElement | null;
}
points.push({ x, y });
}
tabPoints.current = points;
updateFrame(cursorFrame.current);
};
measure();
if (!("ResizeObserver" in window)) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [projects, updateFrame]);
useLayoutEffect(() => {
updateFrame(frame);
}, [frame, updateFrame]);
const revealImage = useCallback(
async (image: HTMLImageElement, project: NotebookProject) => {
const version = requestVersion.current;
try {
if (typeof image.decode === "function") await image.decode();
} catch {
if (
mounted.current &&
version === requestVersion.current &&
requestRef.current === project.screenshotSrc
) {
setFailedSrc(project.screenshotSrc);
}
return;
}
if (
!mounted.current ||
version !== requestVersion.current ||
requestRef.current !== project.screenshotSrc
)
return;
if (displayedRef.current?.screenshotSrc === project.screenshotSrc) return;
setOutgoing(
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? null
: displayedRef.current,
);
displayedRef.current = project;
setDisplayed(project);
setFailedSrc(null);
},
[],
);
useLayoutEffect(() => {
if (!selectedProject) return;
// A quick reversal can select the still-mounted outgoing image: no new load event.
const source = new URL(selectedProject.screenshotSrc, document.baseURI)
.href;
const image = Array.from(
rootRef.current?.querySelectorAll("img") ?? [],
).find((item) => item.src === source);
if (image?.complete && image.naturalWidth > 0) {
void revealImage(image, selectedProject);
}
}, [selectedProject, revealImage]);
const imageProjects = [outgoing, displayed, selectedProject].filter(
(project, index, list): project is NotebookProject =>
Boolean(project) &&
list.findIndex(
(item) => item?.screenshotSrc === project?.screenshotSrc,
) === index,
);
const waiting = displayed?.screenshotSrc !== requestedSrc;
const interactionProps = interactive
? {}
: {
"aria-hidden": true,
inert: true,
};
function handleTabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) {
if (projects.length < 1) return;
let nextIndex: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = Math.min(index + 1, projects.length - 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = Math.max(index - 1, 0);
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = projects.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
event.currentTarget.parentElement
?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
[nextIndex]?.focus({ preventScroll: true });
onSelectTab(nextIndex);
}
if (!selectedProject) {
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
data-empty
>
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
</div>
);
}
const selectedId = toDomId(selectedProject.id);
const panelId = `notebook-panel-${selectedId}`;
return (
<div
{...interactionProps}
className="notebook-browser"
data-testid="notebook-browser"
ref={rootRef}
>
<div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-address">
evandro.dev.br / projetos
</span>
</div>
<div className="notebook-browser-body">
<div className="notebook-browser-heading">
<span className="notebook-browser-eyebrow">Projetos em produção</span>
<span className="notebook-browser-counter">
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
{String(projects.length).padStart(2, "0")}
</span>
</div>
<output className="sr-only" aria-live="polite">
Projeto ativo: {selectedProject.title}
</output>
<div
className="notebook-tablist"
role="tablist"
aria-label="Projetos em produção"
>
{projects.map((project, index) => {
const tabId = `notebook-tab-${toDomId(project.id)}`;
const isSelected = index === selectedIndex;
return (
<button
aria-controls={panelId}
aria-selected={isSelected}
className="notebook-tab"
id={tabId}
key={project.id}
onClick={() => onSelectTab(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
<span className="notebook-tab-number">
{String(index + 1).padStart(2, "0")}
</span>
<span className="notebook-tab-title">{project.title}</span>
</button>
);
})}
</div>
<div
aria-labelledby={`notebook-tab-${selectedId}`}
className="notebook-panel"
id={panelId}
role="tabpanel"
>
<div className="notebook-panel-copy">
<h3>{selectedProject.title}</h3>
<p>{selectedProject.subtitle}</p>
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
{selectedProject.technologies.slice(0, 4).map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
{selectedProject.demoAvailability === "unavailable" ? (
<span aria-disabled="true">
Demo indisponível na última verificação
</span>
) : selectedProject.demoAvailability === "none" ||
!selectedProject.demoUrl ? null : (
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
{selectedProject.demoAvailability === "unknown" ? (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
) : null}
</a>
)}
</div>
<div
className="notebook-screenshot-wrap"
aria-busy={waiting && failedSrc !== requestedSrc}
>
{imageProjects.map((project) => {
const current =
project.screenshotSrc === displayed?.screenshotSrc;
const previous =
project.screenshotSrc === outgoing?.screenshotSrc;
return (
<Image
key={project.screenshotSrc}
alt={current ? `Captura de tela de ${project.title}` : ""}
aria-hidden={!current}
className={`notebook-screenshot ${current ? "notebook-screenshot-current" : previous ? "notebook-screenshot-outgoing" : "notebook-screenshot-pending"}`}
width={project.screenshotWidth}
height={project.screenshotHeight}
loading="eager"
sizes="(min-width: 1025px) 800px, 100vw"
src={project.screenshotSrc}
unoptimized
onLoad={(event) => {
void revealImage(event.currentTarget, project);
}}
onError={() => {
if (project.screenshotSrc === requestRef.current)
setFailedSrc(project.screenshotSrc);
}}
onAnimationEnd={() => {
if (current) setOutgoing(null);
}}
/>
);
})}
{waiting ? (
<p className="notebook-screenshot-status" aria-live="polite">
{failedSrc === requestedSrc
? `Não foi possível carregar a captura de ${selectedProject.title}.`
: `Carregando captura de ${selectedProject.title}…`}
{displayed
? ` Exibindo ${displayed.title} enquanto isso.`
: null}
</p>
) : null}
</div>
</div>
</div>
<svg
aria-hidden="true"
className="notebook-cursor"
ref={cursorRef}
viewBox="0 0 28 36"
width="28"
height="36"
>
<title>Cursor decorativo</title>
<path
d="M1 1 L1 26 L8 20 L14 33 L19 30 L13 18 L23 18 Z"
fill="#0a0a0a"
stroke="#f4f2ed"
strokeWidth="1.8"
strokeLinejoin="round"
/>
<circle
className="notebook-cursor-pulse"
cx="2"
cy="2"
r="8"
fill="none"
stroke="#ccff00"
strokeWidth="2"
/>
</svg>
</div>
);
}
@@ -0,0 +1,285 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
cursorY: 0.6,
cursorClicking: false,
};
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
return render(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
{...overrides}
/>,
);
}
function imageFor(container: HTMLElement, index: number) {
const source = new URL(projects[index].screenshotSrc, document.baseURI).href;
const image = Array.from(container.querySelectorAll("img")).find(
(candidate) => candidate.src === source,
);
if (!image) throw new Error(`Missing screenshot for ${projects[index].id}`);
return image;
}
describe("NotebookBrowser", () => {
it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 });
expect(
screen.getByRole("tablist", { name: /projetos em produção/i }),
).toBeInTheDocument();
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SC Plus Aquicultura/i,
);
expect(screen.getByRole("status")).toHaveTextContent(
"Projeto ativo: SC Plus Aquicultura",
);
});
it("keeps decoded content visible and ignores a superseded decode", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
let finishDecode!: () => void;
imageFor(view.container, 1).decode = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockImplementation(
() =>
new Promise<void>((resolve) => {
finishDecode = resolve;
}),
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={2}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
await act(async () => {
finishDecode();
});
expect(screen.getByRole("img")).toHaveAccessibleName(/SEV Moda Íntima/i);
});
it("keeps the last capture with a truthful notice when the requested image fails", async () => {
const view = renderBrowser();
await act(async () => {
fireEvent.load(imageFor(view.container, 0));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
fireEvent.error(imageFor(view.container, 1));
expect(screen.getByRole("img")).toHaveAccessibleName(/NutriScan/i);
expect(
screen.getByText(/não foi possível carregar.*SC Plus/i),
).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/SC Plus/i);
});
it("can reverse to an already-loaded outgoing image without another load event", async () => {
const view = renderBrowser();
const first = imageFor(view.container, 0);
Object.defineProperties(first, {
complete: { value: true },
naturalWidth: { value: 1280 },
});
await act(async () => {
fireEvent.load(first);
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={1}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
await act(async () => {
fireEvent.load(imageFor(view.container, 1));
});
view.rerender(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
/>,
);
expect(
await screen.findByRole("img", { name: /NutriScan/i }),
).toBeInTheDocument();
});
it("moves between tabs with arrow keys and Home/End", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[1]);
await user.keyboard("{ArrowRight}");
await user.keyboard("{Home}");
await user.keyboard("{End}");
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("moves DOM focus with the active tab", async () => {
const user = userEvent.setup();
const tabs =
renderBrowser().container.querySelectorAll<HTMLButtonElement>(
'[role="tab"]',
);
tabs[0].focus();
await user.keyboard("{ArrowRight}");
expect(document.activeElement).toBe(tabs[1]);
});
it("removes the closed browser from the accessibility tree", () => {
renderBrowser({ interactive: false });
const browser = screen.getByTestId("notebook-browser");
expect(browser).toHaveAttribute("aria-hidden", "true");
expect(browser).toHaveAttribute("inert");
});
it("bounds arrow navigation at the first and last tab", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[0]);
await user.keyboard("{ArrowLeft}");
await user.click(tabs.at(-1) as HTMLElement);
await user.keyboard("{ArrowRight}");
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("keeps an unknown demo link and announces its state", () => {
const unknownProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unknown" as const,
demoCheckedAt: null,
}
: project,
);
renderBrowser({ projects: unknownProjects });
const link = screen.getByRole("link", {
name: /abrir projeto.*disponibilidade não verificada/i,
});
expect(link).toHaveAttribute("href", projects[0].demoUrl);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("renders an unavailable demo as non-navigable text", () => {
const unavailableProjects = projects.map((project, index) =>
index === 0
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
renderBrowser({ projects: unavailableProjects });
const unavailable = screen.getByText(
"Demo indisponível na última verificação",
);
expect(unavailable).toHaveAttribute("aria-disabled", "true");
expect(unavailable.closest("a")).toBeNull();
expect(
screen.queryByRole("link", { name: /abrir projeto/i }),
).not.toBeInTheDocument();
});
it("renders no demo action for a project without a demo", () => {
const noDemoProjects = [
{
...projects[0],
demoUrl: null,
demoAvailability: "none",
demoCheckedAt: null,
},
] as unknown as NotebookBrowserProps["projects"];
renderBrowser({ projects: noDemoProjects });
expect(screen.queryByText(/abrir projeto/i)).not.toBeInTheDocument();
expect(
screen.queryByText("Demo indisponível na última verificação"),
).not.toBeInTheDocument();
});
it("uses the selected tab to label the panel and decoded screenshot", async () => {
const view = renderBrowser({ activeIndex: 2 });
await act(async () => {
fireEvent.load(imageFor(view.container, 2));
});
const tab = screen.getAllByRole("tab")[2];
const panel = screen.getByRole("tabpanel");
const image = screen.getByRole("img");
expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
expect(image).toHaveProperty(
"src",
new URL(projects[2].screenshotSrc, document.baseURI).href,
);
});
});
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import NotebookBrowser from "./NotebookBrowser";
import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = {
projects: NotebookProject[];
initialIndex?: number;
onSelectTab?: (index: number) => void;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookFallback({
projects,
initialIndex = 0,
onSelectTab,
}: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length),
);
return (
<div className="notebook-fallback" data-testid="notebook-fallback">
<div className="notebook-fallback-intro">
<span className="notebook-fallback-kicker">Interface em produção</span>
<p>
Navegue pelos projetos com as abas abaixo. A experiência permanece
completa sem movimento ou WebGL.
</p>
</div>
<NotebookBrowser
activeIndex={activeIndex}
frame={{
cursorClicking: false,
cursorVisible: false,
cursorX: 0,
cursorY: 0,
}}
onSelectTab={(index) => {
setActiveIndex(index);
onSelectTab?.(index);
}}
projects={projects}
/>
</div>
);
}
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(getFeaturedProjects(null));
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
const { container } = render(<NotebookFallback projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("allows manual tab changes without an animated stage", async () => {
const user = userEvent.setup();
const { container } = render(<NotebookFallback projects={projects} />);
await user.click(screen.getAllByRole("tab")[2]);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SEV Moda Íntima/i,
);
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("respects the requested initial project", () => {
render(<NotebookFallback initialIndex={3} projects={projects} />);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
"aria-selected",
"true",
);
});
});
@@ -0,0 +1,445 @@
"use client";
import { Html, RoundedBox } from "@react-three/drei";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import {
Component,
type ReactNode,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import * as THREE from "three";
import NotebookBrowser, { type NotebookBrowserHandle } from "./NotebookBrowser";
import NotebookFallback from "./NotebookFallback";
import type { NotebookProject } from "./notebookProjects";
import { getNotebookFrame, type NotebookFrame } from "./notebookTimeline";
type ProgressRef = { current: number };
export type NotebookStage3DProps = {
progress: ProgressRef;
requestRender: { current: (() => void) | null };
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
isVisible: boolean;
onStageError?: () => void;
};
type NotebookSceneProps = NotebookStage3DProps;
/**
* Maps the timeline lid angle onto the hinge rotation. 0° = closed (lid
* resting on the deck), 112° = open (screen facing the camera, reclined
* ~22° past vertical). The lid pivots around the hinge axis — never around
* the display center.
*/
export function getNotebookLidRotation(lidAngle: number): number {
const angle = THREE.MathUtils.clamp(lidAngle, 0, 180);
const radians = THREE.MathUtils.degToRad(angle);
return radians === 0 ? 0 : -radians;
}
// The laptop and its DOM screen share the same world-space geometry.
const RIG_POSITION: [number, number, number] = [0, -0.45, 0];
const CAMERA_POSITION: [number, number, number] = [0, 1.65, 5.7];
const CAMERA_ROTATION: [number, number, number] = [-0.19, 0, 0];
const BASE = { width: 3.2, height: 0.16, depth: 2.2 };
const HINGE: [number, number, number] = [0, 0.16, -1.06];
const LID = { width: 3.2, thickness: 0.1, depth: 2.2 };
const SCREEN_INSET = { width: 2.9, depth: 1.96 };
const SCREEN_GLASS = { width: 2.8, depth: 1.86 };
const KEY_COLUMNS = 12;
const KEY_ROWS = 5;
const KEY_SIZE: [number, number, number] = [0.185, 0.05, 0.155];
const KEY_GAP_X = 0.212;
const KEY_GAP_Z = 0.2;
const KEYBOARD_Z_START = -0.9;
const KEYBOARD_Y = 0.185;
const SPACEBAR_Z = 0.12;
const SCREEN_DOM_WIDTH = 800;
const SCREEN_DOM_HEIGHT =
(SCREEN_DOM_WIDTH * SCREEN_GLASS.depth) / SCREEN_GLASS.width;
const SCREEN_PIXELS_TO_WORLD = new THREE.Matrix4()
.makeScale(
SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
-SCREEN_GLASS.width / SCREEN_DOM_WIDTH,
1,
)
.setPosition(-SCREEN_GLASS.width / 2, SCREEN_GLASS.depth / 2, 0);
const SCREEN_ORIGIN: [number, number] = [0, 0];
const getScreenOrigin = () => SCREEN_ORIGIN;
export function updateNotebookScreenMatrix(
matrix: THREE.Matrix4,
screen: THREE.Object3D,
camera: THREE.Camera,
size: { width: number; height: number },
): THREE.Matrix4 {
screen.updateWorldMatrix(true, false);
camera.updateMatrixWorld();
const halfWidth = size.width / 2;
const halfHeight = size.height / 2;
// One pixel-space matrix keeps CSS paint and hit-testing on the LCD plane.
matrix
.set(
halfWidth,
0,
0,
halfWidth,
0,
-halfHeight,
0,
halfHeight,
0,
0,
1,
0,
0,
0,
0,
1,
)
.multiply(camera.projectionMatrix)
.multiply(camera.matrixWorldInverse)
.multiply(screen.matrixWorld)
.multiply(SCREEN_PIXELS_TO_WORLD);
return matrix.multiplyScalar(1 / matrix.elements[15]);
}
type LaptopMaterials = {
chassis: THREE.MeshStandardMaterial;
keys: THREE.MeshStandardMaterial;
dark: THREE.MeshStandardMaterial;
accent: THREE.MeshStandardMaterial;
screen: THREE.MeshStandardMaterial;
};
function createLaptopMaterials(): LaptopMaterials {
return {
chassis: new THREE.MeshStandardMaterial({
color: "#3f545b",
metalness: 0.35,
roughness: 0.32,
transparent: true,
}),
keys: new THREE.MeshStandardMaterial({
color: "#5a747c",
metalness: 0.2,
roughness: 0.5,
transparent: true,
}),
dark: new THREE.MeshStandardMaterial({
color: "#131f24",
metalness: 0.15,
roughness: 0.55,
transparent: true,
}),
accent: new THREE.MeshStandardMaterial({
color: "#465f66",
metalness: 0.3,
roughness: 0.4,
transparent: true,
}),
screen: new THREE.MeshStandardMaterial({
color: "#0d1417",
emissive: "#22343a",
emissiveIntensity: 0.55,
metalness: 0.1,
roughness: 0.4,
transparent: true,
}),
};
}
function setLaptopOpacity(materials: LaptopMaterials, opacity: number) {
materials.chassis.opacity = opacity;
materials.keys.opacity = opacity;
materials.dark.opacity = opacity;
materials.accent.opacity = opacity;
materials.screen.opacity = opacity;
}
function disposeLaptopMaterials(materials: LaptopMaterials) {
for (const material of Object.values(materials)) {
material.dispose();
}
}
function LaptopKeys({ material }: { material: THREE.MeshStandardMaterial }) {
const ref = useRef<THREE.InstancedMesh>(null);
const positions = useMemo(() => {
const list: Array<[number, number]> = [];
const startX = (-(KEY_COLUMNS - 1) * KEY_GAP_X) / 2;
for (let row = 0; row < KEY_ROWS; row += 1) {
for (let column = 0; column < KEY_COLUMNS; column += 1) {
list.push([
startX + column * KEY_GAP_X,
KEYBOARD_Z_START + row * KEY_GAP_Z,
]);
}
}
return list;
}, []);
useLayoutEffect(() => {
const mesh = ref.current;
if (!(mesh instanceof THREE.InstancedMesh)) return;
const matrix = new THREE.Matrix4();
positions.forEach(([x, z], index) => {
matrix.makeTranslation(x, KEYBOARD_Y, z);
mesh.setMatrixAt(index, matrix);
});
mesh.instanceMatrix.needsUpdate = true;
}, [positions]);
return (
<instancedMesh
args={[undefined, undefined, positions.length]}
material={material}
ref={ref}
>
<boxGeometry args={KEY_SIZE} />
</instancedMesh>
);
}
function LaptopBase({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[BASE.width, BASE.height, BASE.depth]}
material={materials.chassis}
position={[0, BASE.height / 2, 0]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[2.66, 0.05, 1.3]}
material={materials.dark}
position={[0, 0.155, -0.38]}
radius={0.02}
smoothness={2}
/>
<LaptopKeys material={materials.keys} />
<mesh material={materials.keys} position={[0, KEYBOARD_Y, SPACEBAR_Z]}>
<boxGeometry args={[1.05, KEY_SIZE[1], KEY_SIZE[2]]} />
</mesh>
<RoundedBox
args={[1.15, 0.02, 0.6]}
material={materials.accent}
position={[0, 0.17, 0.68]}
radius={0.01}
smoothness={2}
/>
<mesh
material={materials.dark}
position={[0, 0.16, -1.06]}
rotation={[0, 0, Math.PI / 2]}
>
<cylinderGeometry args={[0.05, 0.05, 2.9, 16]} />
</mesh>
</group>
);
}
function LaptopLid({ materials }: { materials: LaptopMaterials }) {
return (
<group>
<RoundedBox
args={[LID.width, LID.thickness, LID.depth]}
material={materials.chassis}
position={[0, LID.thickness / 2, LID.depth / 2]}
radius={0.05}
smoothness={3}
/>
<RoundedBox
args={[SCREEN_INSET.width, 0.05, SCREEN_INSET.depth]}
material={materials.dark}
position={[0, 0, LID.depth / 2]}
radius={0.02}
smoothness={2}
/>
</group>
);
}
function ScreenSurface({
frame,
material,
projects,
activeIndex,
onSelectTab,
}: {
frame: RefObject<NotebookFrame>;
material: THREE.MeshStandardMaterial;
projects: NotebookProject[];
activeIndex: number;
onSelectTab: (index: number) => void;
}) {
const screen = useRef<THREE.Group>(null);
const surface = useRef<HTMLDivElement>(null);
const browser = useRef<NotebookBrowserHandle>(null);
const projection = useMemo(() => new THREE.Matrix4(), []);
useFrame(({ camera, size }) => {
if (surface.current && screen.current) {
updateNotebookScreenMatrix(projection, screen.current, camera, size);
surface.current.style.transform = `matrix3d(${projection.elements.join(",")})`;
surface.current.style.opacity = String(frame.current.screenOpacity);
surface.current.inert = frame.current.screenOpacity < 0.95;
surface.current.setAttribute(
"aria-hidden",
String(frame.current.screenOpacity < 0.95),
);
}
browser.current?.updateFrame(frame.current);
});
return (
<group
ref={screen}
position={[0, -0.028, LID.depth / 2]}
rotation={[Math.PI / 2, 0, 0]}
>
<mesh material={material}>
<planeGeometry args={[SCREEN_GLASS.width, SCREEN_GLASS.depth]} />
</mesh>
<Html calculatePosition={getScreenOrigin} zIndexRange={[20, 0]}>
<div
ref={surface}
className="notebook-screen-surface"
style={{
width: SCREEN_DOM_WIDTH,
height: SCREEN_DOM_HEIGHT,
opacity: frame.current.screenOpacity,
}}
inert={frame.current.screenOpacity < 0.95}
aria-hidden={frame.current.screenOpacity < 0.95}
>
<NotebookBrowser
ref={browser}
activeIndex={activeIndex}
frame={frame.current}
onSelectTab={onSelectTab}
projects={projects}
/>
</div>
</Html>
</group>
);
}
function NotebookScene({
activeIndex,
onSelectTab,
progress,
projects,
requestRender,
isVisible,
}: NotebookSceneProps) {
const lidRig = useRef<THREE.Group>(null);
const frame = useRef(getNotebookFrame(progress.current, projects.length));
const materials = useMemo(createLaptopMaterials, []);
const invalidate = useThree((state) => state.invalidate);
useEffect(() => () => disposeLaptopMaterials(materials), [materials]);
useEffect(() => {
requestRender.current = isVisible ? invalidate : null;
if (isVisible) invalidate();
return () => {
requestRender.current = null;
};
}, [invalidate, isVisible, requestRender]);
useFrame(() => {
const nextFrame = getNotebookFrame(
progress.current,
projects.length,
frame.current,
);
if (lidRig.current) {
lidRig.current.rotation.x = getNotebookLidRotation(nextFrame.lidAngle);
}
setLaptopOpacity(materials, nextFrame.deviceOpacity);
}, -1);
return (
<group position={RIG_POSITION}>
<ambientLight intensity={1.35} />
<hemisphereLight args={["#f4f2ed", "#0a0a0a", 1.15]} />
<directionalLight color="#f4f2ed" intensity={2.6} position={[3, 6, 6]} />
<directionalLight color="#ccff00" intensity={0.7} position={[-5, 2, 3]} />
<LaptopBase materials={materials} />
<group position={HINGE} ref={lidRig}>
<LaptopLid materials={materials} />
<ScreenSurface
activeIndex={activeIndex}
frame={frame}
material={materials.screen}
onSelectTab={onSelectTab}
projects={projects}
/>
</group>
</group>
);
}
class NotebookStageBoundary extends Component<
NotebookStage3DProps,
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch() {
this.props.onStageError?.();
}
render(): ReactNode {
if (this.state.failed) {
return (
<NotebookFallback
initialIndex={this.props.activeIndex}
projects={this.props.projects}
/>
);
}
return (
<Canvas
camera={{
fov: 30,
position: CAMERA_POSITION,
rotation: CAMERA_ROTATION,
}}
className="notebook-story-canvas"
dpr={[1, 1.5]}
frameloop={this.props.isVisible ? "demand" : "never"}
gl={{
alpha: true,
antialias: true,
powerPreference: "high-performance",
}}
>
<NotebookScene {...this.props} />
</Canvas>
);
}
}
export default function NotebookStage3D(props: NotebookStage3DProps) {
return (
<div className="notebook-story-stage" data-testid="notebook-stage">
<NotebookStageBoundary {...props} />
</div>
);
}
@@ -0,0 +1,131 @@
import { render, screen } from "@testing-library/react";
import * as THREE from "three";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStage3D, {
getNotebookLidRotation,
type NotebookStage3DProps,
updateNotebookScreenMatrix,
} from "./NotebookStage3D";
import { selectNotebookProjects } from "./notebookProjects";
type MockCanvasProps = {
children: React.ReactNode;
frameloop?: string;
};
const mockCanvas = jest.fn(({ children, frameloop }: MockCanvasProps) => (
<div data-frameloop={frameloop} data-testid="mock-canvas">
{children}
</div>
));
jest.mock("@react-three/fiber", () => ({
Canvas: (props: MockCanvasProps) => mockCanvas(props),
useFrame: jest.fn(),
}));
jest.mock("@react-three/drei", () => ({
Html: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-html">{children}</div>
),
RoundedBox: () => <div data-testid="mock-roundedbox" />,
}));
const projects = selectNotebookProjects(getFeaturedProjects(null));
const props: NotebookStage3DProps = {
activeIndex: 0,
isVisible: true,
onSelectTab: jest.fn(),
progress: { current: 0.56 },
requestRender: { current: null },
projects,
};
describe("getNotebookLidRotation", () => {
it("keeps the lid closed at rest and opens around the hinge", () => {
expect(getNotebookLidRotation(0)).toBe(0);
expect(getNotebookLidRotation(112)).toBeCloseTo(
-THREE.MathUtils.degToRad(112),
6,
);
expect(getNotebookLidRotation(56)).toBeCloseTo(
-THREE.MathUtils.degToRad(56),
6,
);
});
it("clamps angles outside the physical hinge range", () => {
expect(getNotebookLidRotation(-10)).toBe(0);
expect(getNotebookLidRotation(200)).toBe(getNotebookLidRotation(180));
});
});
it("keeps browser corners on the physical LCD after opening and resizing", () => {
const matrix = new THREE.Matrix4();
const rig = new THREE.Group();
rig.position.set(0, -0.45, 0);
const hinge = new THREE.Group();
hinge.position.set(0, 0.16, -1.06);
rig.add(hinge);
const lcd = new THREE.Object3D();
lcd.position.set(0, -0.028, 1.1);
lcd.rotation.x = Math.PI / 2;
hinge.add(lcd);
const domWidth = 800;
const domHeight = (domWidth * 1.86) / 2.8;
for (const size of [
{ width: 939.390625, height: 738, lidAngle: 74 },
{ width: 1123.375, height: 629.75, lidAngle: 112 },
]) {
const camera = new THREE.PerspectiveCamera(30, size.width / size.height);
camera.position.set(0, 1.65, 5.7);
camera.rotation.x = -0.19;
hinge.rotation.x = getNotebookLidRotation(size.lidAngle);
updateNotebookScreenMatrix(matrix, lcd, camera, size);
for (const [u, v] of [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
]) {
const expected = new THREE.Vector3((u - 0.5) * 2.8, (0.5 - v) * 1.86, 0)
.applyMatrix4(lcd.matrixWorld)
.project(camera);
const actual = new THREE.Vector3(
u * domWidth,
v * domHeight,
0,
).applyMatrix4(matrix);
expect(actual.x).toBeCloseTo(((expected.x + 1) * size.width) / 2, 6);
expect(actual.y).toBeCloseTo(((1 - expected.y) * size.height) / 2, 6);
}
}
});
describe("NotebookStage3D", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns usable browser content when the stage boundary fails", () => {
const onStageError = jest.fn();
mockCanvas.mockImplementation(() => {
throw new Error("canvas failed");
});
render(<NotebookStage3D {...props} onStageError={onStageError} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(screen.queryByTestId("mock-canvas")).not.toBeInTheDocument();
expect(onStageError).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,302 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
const NotebookStage3D = dynamic(() => import("./NotebookStage3D"), {
ssr: false,
});
type CapabilityWindow = Pick<Window, "innerWidth" | "matchMedia">;
export type NotebookStoryProps = {
projects: PortfolioProjectView[];
onLayoutReady?: () => void;
};
export function canUseNotebook3D(view: CapabilityWindow): boolean {
if (typeof view.matchMedia !== "function") return false;
return (
view.innerWidth >= 1025 &&
!view.matchMedia("(prefers-reduced-motion: reduce)").matches &&
!view.matchMedia("(pointer: coarse)").matches &&
view.matchMedia("(hover: hover)").matches
);
}
export function getNotebookRunwayVh(tabCount: number): number {
const count = Math.max(
1,
Number.isFinite(tabCount) ? Math.floor(tabCount) : 1,
);
return 220 + count * 85;
}
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookStory({
projects,
onLayoutReady,
}: NotebookStoryProps) {
const notebookProjects = useMemo(
() => selectNotebookProjects(projects),
[projects],
);
const sectionRef = useRef<HTMLElement>(null);
const progressRef = useRef(0);
const requestRenderRef = useRef<(() => void) | null>(null);
const seekRef = useRef<((progress: number) => void) | null>(null);
const layoutReadyRef = useRef(onLayoutReady);
const failedRef = useRef(false);
const frameRef = useRef(getNotebookFrame(0, notebookProjects.length));
const [capabilityResolved, setCapabilityResolved] = useState(false);
const activeIndexRef = useRef(notebookProjects.length > 0 ? 0 : -1);
const [engine, setEngine] = useState(false);
const [sectionIntersects, setSectionIntersects] = useState(false);
const [isPageVisible, setIsPageVisible] = useState(
() => typeof document === "undefined" || !document.hidden,
);
const [activeIndex, setActiveIndex] = useState(activeIndexRef.current);
useEffect(() => {
layoutReadyRef.current = onLayoutReady;
}, [onLayoutReady]);
useEffect(() => {
if (!engine && capabilityResolved) layoutReadyRef.current?.();
}, [engine, capabilityResolved]);
useEffect(() => {
const updateEngine = () => {
const nextEngine =
!failedRef.current &&
notebookProjects.length > 0 &&
canUseNotebook3D(window);
if (!nextEngine) setSectionIntersects(false);
setEngine(nextEngine);
setCapabilityResolved(true);
};
updateEngine();
if (typeof window.matchMedia !== "function") return undefined;
const mediaQueries = [
window.matchMedia("(prefers-reduced-motion: reduce)"),
window.matchMedia("(pointer: coarse)"),
window.matchMedia("(hover: hover)"),
];
window.addEventListener("resize", updateEngine, { passive: true });
mediaQueries.forEach((mediaQuery) => {
mediaQuery.addEventListener("change", updateEngine);
});
return () => {
window.removeEventListener("resize", updateEngine);
mediaQueries.forEach((mediaQuery) => {
mediaQuery.removeEventListener("change", updateEngine);
});
};
}, [notebookProjects.length]);
useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
useEffect(() => {
const section = sectionRef.current;
if (!engine || !section) return undefined;
if (!("IntersectionObserver" in window)) {
setSectionIntersects(true);
return undefined;
}
const observer = new IntersectionObserver(
([entry]) => setSectionIntersects(entry?.isIntersecting ?? false),
{ threshold: 0 },
);
observer.observe(section);
return () => observer.disconnect();
}, [engine]);
useEffect(() => {
if (!engine) return undefined;
const sectionElement = sectionRef.current;
if (!sectionElement) return undefined;
const sectionRoot: HTMLElement = sectionElement;
let cancelled = false;
let cleanup = () => {};
async function setupTimeline() {
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const context = gsap.context(() => {
const driver = { value: 0 };
const applyProgress = () => {
progressRef.current = driver.value;
const nextIndex = getNotebookFrame(
driver.value,
notebookProjects.length,
frameRef.current,
).activeTabIndex;
if (nextIndex !== activeIndexRef.current) {
activeIndexRef.current = nextIndex;
setActiveIndex(nextIndex);
}
requestRenderRef.current?.();
};
const timeline = gsap.timeline({
defaults: { ease: "none" },
onUpdate: applyProgress,
scrollTrigger: {
trigger: sectionRoot,
start: "top top",
end: "bottom bottom",
scrub: true,
invalidateOnRefresh: true,
onRefresh: () => layoutReadyRef.current?.(),
},
});
timeline.to(driver, { duration: 1, value: 1 });
seekRef.current = (progress) => {
const trigger = timeline.scrollTrigger;
if (!trigger) return;
// Instant overrides the site's smooth CSS scrolling: no tour of intermediate tabs.
window.scrollTo({
top: trigger.start + (trigger.end - trigger.start) * progress,
behavior: "instant",
});
ScrollTrigger.update();
timeline.progress(progress);
applyProgress();
};
timeline.scrollTrigger?.refresh();
}, sectionRoot);
cleanup = () => {
seekRef.current = null;
context.revert();
};
}
void setupTimeline().catch(() => {
if (!cancelled) {
failedRef.current = true;
setEngine(false);
}
});
return () => {
cancelled = true;
cleanup();
};
}, [engine, notebookProjects.length]);
const handleStageError = useCallback(() => {
failedRef.current = true;
setEngine(false);
}, []);
const handleSelectTab = useCallback(
(index: number) => {
const safeIndex = clampIndex(index, notebookProjects.length);
if (safeIndex < 0) return;
activeIndexRef.current = safeIndex;
setActiveIndex(safeIndex);
if (engine) {
seekRef.current?.(
getNotebookSeekProgress(safeIndex, notebookProjects.length),
);
}
},
[engine, notebookProjects.length],
);
if (!engine) {
return (
<section
aria-label="Projetos em produção no notebook"
className={`notebook-story notebook-story-static${!capabilityResolved && notebookProjects.length > 0 ? " notebook-story-pending" : ""}`}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
>
<div className="notebook-story-static-content">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Uma seleção navegável de sistemas reais, com suas interfaces e
tecnologias em produção.
</p>
</div>
<NotebookFallback
initialIndex={activeIndex}
onSelectTab={handleSelectTab}
projects={notebookProjects}
/>
</div>
</section>
);
}
return (
<section
aria-label="Projetos em produção no notebook"
className="notebook-story notebook-story-runway"
data-notebook-engine="3d"
data-notebook-runway-vh={getNotebookRunwayVh(notebookProjects.length)}
data-testid="notebook-story"
id="story"
ref={sectionRef}
style={
{
"--notebook-runway": `${getNotebookRunwayVh(notebookProjects.length)}vh`,
} as CSSProperties
}
>
<div className="notebook-story-sticky">
<div className="notebook-story-grid">
<div className="notebook-story-copy">
<span className="notebook-story-kicker">O notebook</span>
<h2>Software que opera.</h2>
<p>
Role para abrir a engenharia por trás de cada entrega. O scroll é
o controle de play e rebobinar.
</p>
<span className="notebook-story-count" aria-live="polite">
{String(Math.max(activeIndex + 1, 1)).padStart(2, "0")} /{" "}
{String(notebookProjects.length).padStart(2, "0")}
</span>
</div>
<NotebookStage3D
activeIndex={activeIndex}
isVisible={sectionIntersects && isPageVisible}
onSelectTab={handleSelectTab}
onStageError={handleStageError}
progress={progressRef}
requestRender={requestRenderRef}
projects={notebookProjects}
/>
</div>
</div>
</section>
);
}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more