Compare commits

...
Author SHA1 Message Date
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
103 changed files with 5035 additions and 6087 deletions

No files matched your search

+1
View File
@@ -47,6 +47,7 @@ next-env.d.ts
.superpowers/
/.worktrees/
/.impeccable/
/.serena/
# generated analysis artifacts
/graphify-out/
+5
View File
@@ -4,3 +4,8 @@
- `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.
## WhatsApp icon
- The WhatsApp silhouette in `app/components/Contact.tsx` comes from
[Simple Icons](https://github.com/simple-icons/simple-icons/blob/develop/icons/whatsapp.svg).
It identifies the contact channel; it does not imply endorsement by WhatsApp.
+70 -12
View File
@@ -1,8 +1,8 @@
<div align="center">
# Evandro.dev.br: portfólio
# Evandro.dev.br: software sob medida
Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviços, currículo e contato por WhatsApp.
Site comercial de Evandro Fonseca Junior para projetos de software sob medida: sistemas empresariais, aplicativos e integrações, com cases e contato direto por WhatsApp.
</div>
@@ -11,7 +11,7 @@ Portfólio profissional de Evandro Fonseca Junior, com projetos, cases, serviço
- 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.
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
## Início rápido
@@ -35,7 +35,7 @@ corepack [email protected] start
| `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 typecheck` | Gera os tipos de rota com `next typegen` e 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. |
@@ -45,10 +45,10 @@ corepack [email protected] start
| Área | Pacotes usados |
| --- | --- |
| Aplicação | Next.js 16.3.0, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
| Aplicação | Next.js 16.3.3, 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. |
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
| Interface | Lucide React. |
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
@@ -58,7 +58,7 @@ Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
| Rota | Conteúdo |
| --- | --- |
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato. |
| `/` | Homepage comercial com hero, notebook 3D, projetos, soluções, processo de trabalho, engenharia com IA, critérios de entrega, perfil e contato. |
| `/cases` | Índice dos seis cases. |
| `/cases/nutriscan` | Case NutriScan. |
| `/cases/twins-lab` | Case Twins Lab. |
@@ -66,18 +66,19 @@ Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
| `/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` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
| `/servicos/criacao-de-sites` | Criação de sites. |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
| `/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`. |
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.
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.png`, `/apple-icon.png` 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.
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. Na homepage, competências e experiência também ficam recolhidas. O header prioriza Soluções (`/#modalities`), Projetos (`/cases`), Como trabalho (`/#process`), Sobre (`/#about`) e Contato (`/#contact`), com ajuste de chegada após a definição do layout do notebook. Currículo e download do PDF são links secundários no final do rodapé.
## Comportamento da aplicação
@@ -87,12 +88,53 @@ O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts
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).
### Posicionamento comercial e uso de IA
A comunicação apresenta Evandro como responsável direto pelo projeto, sem sugerir equipe, disponibilidade garantida, prazo fixo ou certificação. Os cards de projetos reutilizam os desafios e resumos dos cases quando disponíveis. O antigo painel de telemetria fictícia foi removido; os critérios de qualidade não representam notas de auditoria.
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
`app/components/EngineeringWithAI.tsx` explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 e links junto de cada afirmação:
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
- Base44: falha de autenticação identificada pela Wiz, corrigida em menos de 24 horas, sem evidência reportada de exploração maliciosa.
As obrigações dos pequenos agentes de tratamento são referenciadas à Resolução CD/ANPD nº 2/2022, arts. 6º, 12 e 13. Ao atualizar esse conteúdo, preservar datas, limites dos relatos e a distinção entre vulnerabilidade, exposição e incidente confirmado.
O formulário valida os campos no navegador e abre um rascunho no WhatsApp para revisão. Ele não envia a mensagem automaticamente e orienta a não informar senhas nem dados pessoais de clientes.
A seção de contato tem título estático único, canais diretos à esquerda e formulário à direita (empilhados no celular). A silhueta do WhatsApp aparece no contato direto, no cabeçalho do formulário e no botão verde. O formulário não se move no hover; campos mantêm rótulos, validação nativa e foco visível. O link direto dispensa preenchimento e o e-mail usa `mailto:`.
Na página `/servicos` e nos seis detalhamentos, `WhatsAppLink` oferece um link nativo com mensagem contextual, sem exigir o formulário da home. Funciona mesmo sem consentimento de analytics ou JavaScript; a medição depende da hidratação e da tag. A página principal prioriza as três intenções dos anúncios antes das especialidades técnicas e usa os cases existentes NutriScan e SC Plus como exemplos de trabalho, não como promessa de retorno comercial.
### 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.
O hero combina a mensagem comercial com três placas 3D: interface, integrações e dados. A interface usa a captura real do NutriScan, recortada acima do rodapé; as outras camadas são ilustrativas, não uma declaração da arquitetura do projeto. Ponteiro, arraste horizontal e quatro botões permitem explorar a composição. O toque vertical continua rolando a página. A renderização para quando os movimentos terminam, fora do viewport e com a aba oculta. Movimento reduzido ou falha gráfica mantêm a captura HTML, os controles e o link do case disponíveis.
O notebook 3D permanece em celulares, tablets e desktop: tampa, tela, abas e cursor seguem o progresso do scroll. No mobile, a câmera adapta o enquadramento e botões externos à tela do notebook permitem selecionar os projetos. A cena renderiza sob demanda e pausa fora do viewport ou com a página oculta. Movimento reduzido, falha de WebGL ou perda de contexto ativam a navegação HTML sem animação.
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.
Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo.
### Identidade visual
O header usa `--site-header-height`: 60px abaixo de 1280px e 68px a partir desse breakpoint. A mesma variável define a posição do menu, o respiro inicial do hero e a compensação das âncoras. O menu mobile tem fundo opaco; a barra fica levemente translúcida ao rolar. Header e hero usam Space Grotesk para leitura comercial, reservando a fonte monoespaçada aos detalhes técnicos das demais seções.
Os quatro arquivos recebidos estão preservados, com nomes descritivos, em `imagens/originais/`. As versões prontas para uso ficam em `public/brand/`:
| Arquivo | Aplicação |
| --- | --- |
| `wordmark-dark.webp` | Logo horizontal transparente, branca e lima, no cabeçalho escuro. |
| `wordmark-light.webp` | Logo horizontal transparente, escura e lima, no rodapé claro. |
| `stacked-light.webp` | Logo empilhada transparente, reservada para composições maiores em fundo claro. |
| `mark-dark.svg` / `mark-light.svg` | Símbolo vetorial para fundos escuros e claros, traçado a partir do original limpo. |
| `mark-dark.png` | Símbolo de 512px incorporado aos cards Open Graph de todas as rotas. |
| `icon-192.png` | Ícone de instalação do manifesto. |
`app/icon.png` (512px), `app/apple-icon.png` (180px) e `app/favicon.ico` (16–256px) usam o símbolo sobre fundo escuro. As variantes mantêm as formas originais; o recorte transparente com bordas danificadas permanece arquivado, sem uso na interface.
### 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.
@@ -101,6 +143,22 @@ O formulário da homepage valida os campos nativos de nome, e-mail, tipo e conte
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.
O único loader `gtag.js` configura GA4 `G-QTT9QT0T3S` e Google Ads `AW-18432694647`. Os IDs `GT-M3K4CKQT` e `GT-NBBR3PC2` informados no painel são identificadores das tags, não instalações adicionais. A integração segue https://developers.google.com/tag-platform/gtagjs/configure. `ad_storage`, `ad_user_data` e `ad_personalization` permanecem negados, com `ads_data_redaction` ativo. A tag só é montada em produção.
### Medição do contato e Google Ads
`lib/analytics.ts` envia `whatsapp_open` exclusivamente ao GA4 após o acionamento de um link ou a submissão válida do formulário. O evento representa **tentativa de abrir uma conversa**, não mensagem recebida, lead confirmado ou venda. O contato não espera a resposta do Analytics. Eventos recusados ou ocorridos antes do consentimento não são guardados para envio posterior.
Parâmetros enviados: `service` (rótulo estático), `placement` (posição do CTA) e `page_path`. A URL do evento não inclui query, fragmento ou referenciador; rotas dinâmicas são agrupadas como `/servicos/[slug]` e `/cases/[slug]`. Nome, e-mail, mensagem digitada e URL de destino do WhatsApp não entram nesse evento. A distinção do serviço usa o parâmetro `service`.
Posições: `service_hub_hero`, `service_hub_intent`, `service_hub_footer`, `service_detail_hero`, `service_detail_footer`, `contact_direct` e `contact_form`. Isso permite comparar entradas de contato pelo tipo de serviço e pela posição na página. Cliques pelo menu de contexto, bloqueadores e falhas de rede podem não ser medidos.
**Configuração externa ainda necessária:** verificar `whatsapp_open` no GA4, registrá-lo como evento principal, vincular a propriedade à conta Ads correta e importar essa ação de conversão. Conferir a ação existente antes para evitar dupla contagem; não importar simultaneamente um clique automático como a mesma conversão. Para analisar os parâmetros em Explorações, cadastrar dimensões personalizadas de escopo de evento para `service` e `placement`. Nenhum rótulo de conversão Ads foi inventado e nenhuma configuração da conta foi alterada. A medição otimiza intenção de contato, não vendas.
Também revisar as opções de medição otimizada da propriedade, sobretudo eventos automáticos de cliques externos e formulários, pois essa configuração não é controlada por este código. Referência dos eventos: https://developers.google.com/analytics/devguides/collection/ga4/events.
Verificação local: build de produção, navegação responsiva, redirecionamento com parâmetros e requisição real `whatsapp_open` gerada pela tag. Requisições de coleta foram interceptadas antes do envio para não contaminar os relatórios; isso **não comprova recebimento no GA4 nem atribuição no Ads**.
## Estrutura
```text
@@ -119,7 +177,7 @@ 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 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 passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+10 -12
View File
@@ -58,16 +58,12 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
}
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");
const destinations = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(destinations).toContain("/#contact");
expect(destinations).toContain("/servicos");
expect(destinations).not.toContain("/curriculo");
}
it("keeps the six public params and factual case metadata", async () => {
@@ -213,7 +209,7 @@ it("renders the authorized detailed case in order with traceable sources", async
});
it("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("gcc-installer");
const caseStudy = getCase("sc-plus");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
@@ -226,7 +222,9 @@ it("keeps the fallback concise and links its declared services", async () => {
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: "Abrir demonstração" }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
+12 -3
View File
@@ -35,10 +35,11 @@ export async function generateMetadata({
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: `${caseStudy.title} | Case de Engenharia de Software`,
title: `${caseStudy.title} | Case de Engenharia`,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title: caseStudy.title,
description: caseStudy.summary,
url,
@@ -109,6 +110,14 @@ export default async function CasePage({ params }: CasePageProps) {
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.detail?.answer.text ?? caseStudy.summary}
</p>
{caseStudy.role ? (
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
<strong className="font-semibold text-white">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
@@ -236,9 +245,9 @@ export default async function CasePage({ params }: CasePageProps) {
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.hiring.href}
href={intentCtas.project.href}
>
{intentCtas.hiring.label}
{intentCtas.project.label}
</Link>
<Link
className="ds-link ds-link-muted"
+3 -2
View File
@@ -18,6 +18,7 @@ export const metadata: Metadata = {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
title: pageTitle,
description: pageDescription,
url: pageUrl,
@@ -136,11 +137,11 @@ export default function CasesPage() {
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href={intentCtas.hiring.href}
href={intentCtas.project.href}
className="ds-link ds-link-accent"
strength={0.25}
>
{intentCtas.hiring.label}
{intentCtas.project.label}
</MagneticButton>
<MagneticButton
as="a"
+16 -16
View File
@@ -15,16 +15,16 @@ const STORY_DATA = [
"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",
label: "Decisões técnicas",
title: "Entrega clara mesmo sob restrição",
content:
"Trabalho bem em contextos com legado, integrações complexas e pouca margem para erro, priorizando estabilidade, leitura técnica e evolução sustentável.",
"Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.",
},
{
label: "Production mindset",
label: "Operação",
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.",
"Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.",
},
];
@@ -40,9 +40,9 @@ const PROFILE_POINTS = [
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
},
{
title: "Ownership ponta a ponta",
title: "Responsabilidade técnica",
description:
"Responsabilidade que cobre discovery, implementação, qualidade e operação.",
"Contato direto com quem define a arquitetura e desenvolve o sistema.",
},
];
@@ -86,14 +86,14 @@ export default function About() {
return (
<Section
id="about"
eyebrow="Operating context"
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."
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior. Engenharia com contato direto."
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
disableChildrenAnimation
aside={
<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
Base em Garuva, SC | Atendimento remoto
</div>
}
>
@@ -140,14 +140,14 @@ export default function About() {
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>
<div className="ds-kicker">Da arquitetura à implementação</div>
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
<h3 className="ds-card-title">Uma visão do sistema inteiro</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.
Meu trabalho conecta interface, serviços, dados e, quando
necessário, equipamentos. Entender essas partes em conjunto ajuda a
definir o que construir, o que integrar e o que pode ser
aproveitado.
</p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
+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");
});
+217 -196
View File
@@ -1,231 +1,252 @@
"use client";
import { motion, type Variants } from "motion/react";
import type React from "react";
import { ArrowUpRight, Mail, MapPin } from "lucide-react";
import type { FormEvent } from "react";
import { useEffect, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { trackWhatsAppOpen } from "@/lib/analytics";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { Section } from "./Section";
import WhatsAppLink from "./WhatsAppLink";
// WhatsApp brand silhouette from Simple Icons (simple-icons/simple-icons).
function WhatsAppMark({ className }: { className: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
className={className}
aria-hidden="true"
focusable="false"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
</svg>
);
}
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)]";
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
const labelClassName = "block font-display text-sm font-medium text-white/85";
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "full-stack",
subject: "Sistemas web sob medida",
message: "",
});
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
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 whatsappUrl = buildWhatsAppUrl(messageTemplate);
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
};
const gridVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.05,
staggerChildren: 0.08,
},
},
};
const cardVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
},
if (!e.currentTarget.reportValidity()) return;
const messageTemplate = `Olá, Evandro! Quero conversar sobre um projeto.\n\nNome: ${formData.name}\nE-mail: ${formData.email}\nTipo de projeto: ${formData.subject}\n\nContexto, objetivo e prazo:\n${formData.message}\n\nContato iniciado pelo site evandro.dev.br.`;
window.open(
buildWhatsAppUrl(messageTemplate),
"_blank",
"noopener,noreferrer",
);
trackWhatsAppOpen({ service: "Contato", placement: "contact_form" });
};
return (
<Section
<section
id="contact"
eyebrow="Contact"
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
aria-labelledby="contact-title"
className="ds-section ds-section-dark"
>
<motion.div
className="ds-grid-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
<motion.div
className="ds-card"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
>
<div className="ds-kicker" data-reel="left">
Availability
</div>
<DrawLine delay={0.04} />
<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.
<div className="ds-section-shell grid items-start gap-sp-10 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-16">
<div className="min-w-0">
<p className="ds-eyebrow">Contato</p>
<h2
id="contact-title"
className="ds-section-title mt-sp-4 max-w-[19ch] text-balance"
>
Vamos conversar sobre o que sua empresa precisa.
</h2>
<p className="mt-sp-5 max-w-[44ch] font-display text-base leading-8 text-white/70">
Conte o que você quer resolver. Pode ser um projeto novo, uma
melhoria ou uma integração. Não precisa chegar com a solução
definida.
</p>
<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 className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
<h3 className="font-display text-lg font-semibold text-white">
Você fala direto com quem desenvolve.
</h3>
<p className="mt-sp-2 max-w-[44ch] font-display text-sm leading-7 text-white/65">
Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois.
</p>
</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)]"
data-reel="fade"
>
Envie contexto, prazo e objetivo. Eu cuido de transformar isso em
conversa tecnica objetiva.
<div className="mt-sp-8 divide-y divide-white/10 border-y border-white/10">
<WhatsAppLink
service="Contato"
placement="contact_direct"
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-sm font-semibold text-white group-hover:text-[#25d366]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-sm text-white/65">
WhatsApp · {whatsAppDisplay}
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
aria-hidden="true"
/>
</WhatsAppLink>
<a
href="mailto:[email protected]"
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-all font-display text-sm">
evandrofonsecajunior@gmail.com
</span>
<ArrowUpRight
className="ml-auto h-4 w-4 shrink-0"
aria-hidden="true"
/>
</a>
</div>
</motion.div>
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-sm leading-6 text-white/55">
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
</p>
</div>
<motion.form
<form
onSubmit={handleSubmit}
className="ds-card space-y-sp-5"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
}
}
transition={{ type: "spring", stiffness: 180, damping: 18 }}
aria-labelledby="contact-form-title"
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div>
<label
htmlFor="contact-name"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Nome completo
</label>
<input
id="contact-name"
name="name"
required
type="text"
autoComplete="name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className={fieldClassName}
/>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
<h3
id="contact-form-title"
className="font-display text-xl font-semibold leading-7 text-white"
>
Prepare sua mensagem
</h3>
<p className="mt-1 font-display text-sm leading-6 text-white/65">
Você revisa no WhatsApp antes de enviar.
</p>
</div>
</div>
<div>
<label
htmlFor="contact-email"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
<div className="space-y-sp-5">
<div className="grid gap-sp-5 xl:grid-cols-2">
<div>
<label htmlFor="contact-name" className={labelClassName}>
Nome completo
</label>
<input
id="contact-name"
name="name"
required
type="text"
autoComplete="name"
placeholder="Como posso te chamar?"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className={fieldClassName}
/>
</div>
<div>
<label htmlFor="contact-email" className={labelClassName}>
E-mail
</label>
<input
id="contact-email"
name="email"
required
type="email"
autoComplete="email"
placeholder="[email protected]"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className={fieldClassName}
/>
</div>
</div>
<div>
<label htmlFor="contact-subject" className={labelClassName}>
Tipo de projeto
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) =>
setFormData({ ...formData, subject: e.target.value })
}
className={`${fieldClassName} [color-scheme:dark]`}
>
<option value="Sistemas web sob medida">
Sistemas web sob medida
</option>
<option value="Aplicativos">Aplicativos</option>
<option value="Integrações e automações">
Integrações e automações
</option>
<option value="Evolução de um sistema existente">
Evolução de um sistema existente
</option>
<option value="Ainda preciso definir">
Ainda preciso definir
</option>
</select>
</div>
<div>
<label htmlFor="contact-message" className={labelClassName}>
Contexto do projeto
</label>
<textarea
id="contact-message"
name="message"
required
rows={4}
autoComplete="off"
placeholder="O que você precisa resolver? Qual resultado espera e existe um prazo?"
aria-describedby="contact-privacy-note"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
className={`${fieldClassName} min-h-32 resize-y`}
/>
</div>
<button
type="submit"
className="flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 font-display text-base font-semibold leading-6 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
>
E-mail
</label>
<input
id="contact-email"
name="email"
required
type="email"
autoComplete="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className={fieldClassName}
/>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
<ArrowUpRight className="h-5 w-5 shrink-0" aria-hidden="true" />
</button>
<p
id="contact-privacy-note"
className="font-display text-xs leading-6 text-white/55"
>
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
para você revisar. Não inclua senhas nem dados pessoais de
clientes.
</p>
</div>
<div>
<label
htmlFor="contact-subject"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Tipo de projeto
</label>
<select
id="contact-subject"
name="subject"
value={formData.subject}
onChange={(e) =>
setFormData({ ...formData, subject: e.target.value })
}
className={fieldClassName}
style={{ color: "#fff", backgroundColor: "#1a1a1a" }}
>
<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 Técnica Sênior</option>
</select>
</div>
<div>
<label
htmlFor="contact-message"
className="block text-[12px] font-medium text-[rgba(255,255,255,.85)]"
>
Contexto do projeto
</label>
<textarea
id="contact-message"
name="message"
required
rows={6}
autoComplete="off"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
className={`${fieldClassName} resize-none`}
/>
</div>
<MagneticButton
as="button"
type="submit"
className="ds-link ds-link-accent min-h-12 border-0"
strength={0.2}
>
Enviar briefing pelo WhatsApp
</MagneticButton>
</motion.form>
</motion.div>
</Section>
</form>
</div>
</section>
);
}
+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>
);
}
+24 -39
View File
@@ -2,15 +2,13 @@ import { render, screen } from "@testing-library/react";
import Footer from "./Footer";
describe("Footer contact and navigation", () => {
it("renders the contact card and a mailto link", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render(<Footer />);
expect(screen.getByText("Contato")).toBeInTheDocument();
// o convite principal fica na secao Contato, logo acima do rodape
expect(
screen.getByRole("heading", {
name: "Engenharia com menos ruído e mais clareza de execução",
}),
).toBeInTheDocument();
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
).not.toBeInTheDocument();
const emailLink = screen.getByRole("link", {
name: "[email protected]",
@@ -33,32 +31,32 @@ describe("Footer contact and navigation", () => {
"/privacidade",
);
});
it("keeps the résumé page and PDF download as the final secondary links", () => {
render(<Footer />);
const résuméLink = screen.getByRole("link", { name: "Currículo" });
const downloadLink = screen.getByRole("link", {
name: "Baixar currículo (PDF)",
});
expect(résuméLink).toHaveAttribute("href", "/curriculo");
expect(downloadLink).toHaveAttribute("href", "/Profile.pdf");
expect(downloadLink).toHaveAttribute(
"download",
"Evandro_Fonseca_Junior_CV.pdf",
);
expect(screen.getAllByRole("link").slice(-2)).toEqual([
résuméLink,
downloadLink,
]);
});
});
describe("Footer social links", () => {
it("renders each social link with an accessible name", () => {
render(<Footer />);
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("opens social links securely in a new tab", () => {
render(<Footer />);
const socialLinks = Array.from(
document.querySelectorAll("footer a[aria-label]"),
);
const socialLinks = screen.getAllByRole("link", { name: /profile/i });
for (const link of socialLinks) {
expect(link).toHaveAttribute("target", "_blank");
@@ -75,16 +73,3 @@ describe("Footer social links", () => {
).toContain("https://www.linkedin.com/in/evandrodevbr/");
});
});
describe("Footer copyright", () => {
it("renders the current year without hardcoded copyright text", () => {
render(<Footer />);
const currentYear = new Date().getFullYear();
expect(
screen.getByText(
new RegExp(`Copyright ${currentYear} Evandro Fonseca Junior`),
),
).toBeInTheDocument();
});
});
+30 -12
View File
@@ -37,7 +37,9 @@ const quickLinks = [
},
];
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton";
import { Logo } from "./Logo";
export default function Footer() {
const currentYear = new Date().getFullYear();
@@ -50,23 +52,14 @@ export default function Footer() {
<div>
<div className="ds-kicker">Contato</div>
<span className="ds-draw-line" />
<h3 className="ds-card-title">
Engenharia com menos ruído e mais clareza de execução
</h3>
<p className="ds-copy max-w-[48ch]">
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>
<div className="mt-sp-6">
<div className="mt-sp-5">
<p className="mb-2 text-[12px] tracking-[0.02em] text-[var(--meta)]">
E-mail direto
</p>
<MagneticButton
as="a"
href="mailto:[email protected]"
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
className="inline-flex min-h-11 items-center break-all text-[16px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)] sm:text-[18px]"
strength={0.2}
>
evandrofonsecajunior@gmail.com
@@ -75,6 +68,13 @@ export default function Footer() {
</div>
<div className="mt-sp-8">
<a
href="/#home"
aria-label="Evandro.dev.br — início"
className="mb-sp-6 inline-flex min-h-11 max-w-full items-center"
>
<Logo theme="light" className="w-60" />
</a>
<div className="ds-metric">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-slash" />
Garuva, SC | Remote worldwide
@@ -143,9 +143,27 @@ export default function Footer() {
</div>
</div>
<div className="mt-sp-8 ds-metric">Open to collaboration</div>
<div className="mt-sp-8 ds-metric">
Software sob medida, contato direto
</div>
</div>
</div>
<div className="mt-sp-8 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2 border-t border-[var(--border)] pt-sp-4 text-[12px] text-[var(--meta)]">
<span>Trajetória profissional</span>
<a
href="/curriculo"
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
>
Currículo
</a>
<a
href={CV_URL}
download={CV_DOWNLOAD_NAME}
className="inline-flex min-h-11 items-center underline-offset-4 hover:text-[var(--ink)] hover:underline"
>
Baixar currículo (PDF)
</a>
</div>
</div>
</footer>
);
+12 -7
View File
@@ -79,10 +79,10 @@ describe("GoogleAnalytics consent", () => {
"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([
const commands = analyticsWindow.dataLayer?.map((entry) =>
Array.from(entry),
);
expect(commands).toContainEqual([
"consent",
"default",
{
@@ -92,8 +92,7 @@ describe("GoogleAnalytics consent", () => {
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[1] ?? [])).toEqual(["js", expect.any(Date)]);
expect(Array.from(dataLayer?.[2] ?? [])).toEqual([
expect(commands).toContainEqual([
"consent",
"update",
{
@@ -103,7 +102,7 @@ describe("GoogleAnalytics consent", () => {
ad_personalization: "denied",
},
]);
expect(Array.from(dataLayer?.[3] ?? [])).toEqual([
expect(commands).toContainEqual([
"config",
"G-QTT9QT0T3S",
{
@@ -111,6 +110,12 @@ describe("GoogleAnalytics consent", () => {
anonymize_ip: true,
},
]);
expect(commands).toContainEqual([
"config",
"AW-18432694647",
{ allow_ad_personalization_signals: false },
]);
expect(commands).toContainEqual(["set", "ads_data_redaction", true]);
expect(
screen.getByRole("button", { name: "Preferências de analytics" }),
).not.toHaveFocus();
+22 -27
View File
@@ -2,10 +2,16 @@
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
import {
type AnalyticsConsent,
CONSENT_STORAGE_KEY,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
type Gtag,
readConsent,
} from "@/lib/analytics";
const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
const GOOGLE_ADS_ID = "AW-18432694647";
const GA_SCRIPT_SRC = `https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`;
const SITE_DOMAIN = "evandro.dev.br";
@@ -16,26 +22,6 @@ const deniedConsent = {
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);
@@ -107,6 +93,7 @@ function GoogleAnalyticsConsent() {
window[GA_DISABLE_KEY] = false;
if (!configuredRef.current) {
gtag("consent", "default", deniedConsent);
gtag("set", "ads_data_redaction", true);
gtag("js", new Date());
configuredRef.current = true;
}
@@ -118,6 +105,10 @@ function GoogleAnalyticsConsent() {
allow_ad_personalization_signals: false,
anonymize_ip: true,
});
// One loader serves both destinations; GT IDs are aliases, not extra installs.
gtag("config", GOOGLE_ADS_ID, {
allow_ad_personalization_signals: false,
});
}, []);
useEffect(() => {
@@ -198,8 +189,9 @@ function GoogleAnalyticsConsent() {
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.
Você decide se permite métricas de navegação com a tag compartilhada
do Google Analytics e Google Ads. Cookies de publicidade e
personalização continuam desativados.
</p>
{consentError && (
<p
@@ -254,7 +246,10 @@ function GoogleAnalyticsConsent() {
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
<span className="max-sm:hidden">Preferências de analytics</span>
<span aria-hidden="true" className="sm:hidden">
Cookies
</span>
</button>
{preferencesOpen && (
<section
@@ -276,7 +271,7 @@ function GoogleAnalyticsConsent() {
className="mt-sp-2 text-[13px] leading-6 text-white/85"
>
Analytics está {consent === "granted" ? "ativo" : "desativado"}.
Dados de publicidade permanecem negados.
Cookies de publicidade e personalização permanecem negados.
</p>
{consentError && (
<p
+69 -73
View File
@@ -1,32 +1,8 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { type ReactNode, useEffect } from "react";
import { 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(() => {
@@ -38,42 +14,38 @@ function HomeWithHeader() {
<section id="about" aria-label="Sobre">
<h2>Sobre</h2>
</section>
<section id="contact" aria-label="Contato">
<h2>Contato</h2>
</section>
</>
);
}
describe("Header", () => {
it("exposes the primary navigation and the existing CV asset", () => {
it("links to commercial destinations without promoting the résumé", () => {
render(<Header />);
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
"href",
"/#home",
);
expect(screen.getByRole("link", { name: "Casos" })).toHaveAttribute(
"href",
const navigation = screen.getByRole("navigation", {
name: "Navegação principal",
});
expect(
within(navigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/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",
"/#process",
"/#about",
);
expect(screen.getByRole("link", { name: "Contato" })).toHaveAttribute(
"href",
"/#contact",
);
"/#experience",
]);
const contactLink = screen
.getAllByRole("link")
.find((link) => link.getAttribute("href") === "/#contact");
expect(contactLink).toHaveAccessibleName();
expect(
screen.queryByRole("link", { name: /currículo|download cv/i }),
).not.toBeInTheDocument();
});
it("announces and controls the mobile menu", () => {
@@ -98,6 +70,23 @@ describe("Header", () => {
expect(
document.getElementById(menuButton.getAttribute("aria-controls") ?? ""),
).toBe(mobileNavigation);
expect(
within(mobileNavigation)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"/#modalities",
"/cases",
"/#process",
"/#about",
"/#experience",
"/#contact",
]);
expect(
within(mobileNavigation).queryByRole("link", {
name: /currículo|download cv/i,
}),
).not.toBeInTheDocument();
const mobileLink = within(mobileNavigation).getByRole("link", {
name: "Sobre",
@@ -109,27 +98,34 @@ describe("Header", () => {
expect(document.activeElement).toBe(openButton);
});
it("closes the mobile menu without taking focus away from a home destination", () => {
window.history.replaceState(null, "", "/");
render(<HomeWithHeader />);
it.each([
["/#about", "Sobre"],
["/#contact", "Contato"],
])(
"closes the mobile menu and preserves destination focus for %s",
(href, 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);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileNavigation = screen.getByRole("navigation", {
name: "Navegação mobile",
});
const mobileLink = within(mobileNavigation)
.getAllByRole("link")
.find((link) => link.getAttribute("href") === href);
if (!mobileLink)
throw new Error(`Missing navigation destination: ${href}`);
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();
});
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: destination })).toHaveFocus();
},
);
});
+55 -43
View File
@@ -1,11 +1,7 @@
"use client";
import { type KeyboardEvent, useRef, useState } from "react";
import {
CV_DOWNLOAD_NAME,
CV_URL,
NAVIGATION_ITEMS,
} from "@/lib/site-navigation";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import MagneticButton from "./cinematic/MagneticButton";
import { Logo } from "./Logo";
@@ -13,8 +9,23 @@ const MENU_ID = "site-navigation-menu";
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
let wasScrolled = false;
const updateScrolled = () => {
const nextScrolled = window.scrollY > 0;
if (nextScrolled === wasScrolled) return;
wasScrolled = nextScrolled;
setScrolled(nextScrolled);
};
updateScrolled();
window.addEventListener("scroll", updateScrolled, { passive: true });
return () => window.removeEventListener("scroll", updateScrolled);
}, []);
const closeMobileMenu = () => setMobileMenuOpen(false);
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
@@ -26,49 +37,53 @@ export function Header() {
};
return (
<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">
<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>
<header
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
: "border-transparent bg-[var(--ink)]"
}`}
>
<div className="ds-section-shell flex h-full items-center justify-between gap-sp-6">
<a
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11 shrink-0 items-center no-underline"
>
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
</a>
<nav
aria-label="Navegação principal"
className="site-header-nav items-center"
className="hidden items-center xl:flex"
>
<ul className="flex list-none items-center gap-sp-2">
<ul className="flex list-none items-center gap-sp-4">
{NAVIGATION_ITEMS.map((item) => (
<li key={item.href}>
<MagneticButton
as="a"
<a
href={item.href}
className="ds-link rounded-sm px-sp-3 py-sp-2 text-white/85 transition-colors hover:bg-white/10 hover:text-white"
strength={0.15}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
>
{item.label}
</MagneticButton>
</a>
</li>
))}
</ul>
</nav>
<div className="flex items-center gap-sp-4">
<MagneticButton
as="a"
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>
<div className="flex shrink-0 items-center">
<div className="hidden xl:block">
<a
href="/#contact"
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
>
Falar sobre meu projeto
</a>
</div>
<button
ref={menuButtonRef}
type="button"
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)]"
className="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)] xl:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
@@ -89,7 +104,7 @@ export function Header() {
</div>
{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="absolute inset-x-0 top-[var(--site-header-height)] max-h-[calc(100dvh-var(--site-header-height))] overflow-y-auto border-b border-white/12 bg-[var(--ink)] px-sp-6 lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-col py-sp-6">
<nav
id={MENU_ID}
@@ -102,10 +117,11 @@ export function Header() {
<li key={item.href}>
<MagneticButton
as="a"
strength={0}
wrapperClassName="w-full"
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"
className="flex min-h-11 items-center rounded-sm px-sp-3 py-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
onClick={closeMobileMenu}
strength={0.15}
>
{item.label}
</MagneticButton>
@@ -114,16 +130,12 @@ export function Header() {
<li>
<MagneticButton
as="a"
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}
strength={0}
href="/#contact"
className="mt-sp-4 inline-flex min-h-11 items-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
onClick={closeMobileMenu}
>
Download CV
Falar sobre meu projeto
</MagneticButton>
</li>
</ul>
@@ -8,13 +8,6 @@ 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,
}));
afterEach(() => {
jest.restoreAllMocks();
+25 -100
View File
@@ -3,12 +3,6 @@ import type { ReactNode } from "react";
import Hero from "./Hero";
jest.mock("next/dynamic", () => () => {
const DynamicComponent = () => <div data-testid="hero-scene" />;
DynamicComponent.displayName = "MockHeroScene";
return DynamicComponent;
});
jest.mock("motion/react", () => {
const React = require("react");
@@ -20,9 +14,6 @@ jest.mock("motion/react", () => {
children,
initial: _initial,
animate: _animate,
whileHover: _whileHover,
whileTap: _whileTap,
variants: _variants,
transition: _transition,
...props
}: { children?: ReactNode } & Record<string, unknown>) =>
@@ -33,53 +24,11 @@ jest.mock("motion/react", () => {
},
);
return {
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
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,
};
return { motion, useReducedMotion: () => false };
});
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", () => {
beforeEach(() => {
mockMatchMedia(false);
jest.spyOn(window, "open").mockImplementation(() => null);
});
@@ -87,37 +36,31 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("renders the published command-center content and primary actions", () => {
it("routes project conversations to contact without opening an external service", () => {
render(<Hero />);
expect(
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(
const contactLink = screen.getByRole("link", {
name: /conversar/i,
});
expect(contactLink).toHaveAttribute("href", "/#contact");
expect(contactLink).not.toHaveAttribute("target", "_blank");
fireEvent.click(contactLink);
expect(window.open).not.toHaveBeenCalled();
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
"href",
"/Profile.pdf",
"/cases",
);
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
it("keeps social destinations, operational proof and WhatsApp action", () => {
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sistemas sob medida para operações que não cabem em soluções prontas.",
);
});
it("keeps social destinations with isolated external tabs", () => {
render(<Hero />);
expect(screen.getByRole("link", { name: "GitHub" })).toHaveAttribute(
@@ -132,28 +75,10 @@ describe("Hero", () => {
"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();
fireEvent.click(
screen.getByRole("button", {
name: "Conversar sobre uma oportunidade",
}),
);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining("https://wa.me/"),
"_blank",
"noopener,noreferrer",
);
for (const name of ["GitHub", "LinkedIn"]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
});
});
+138 -457
View File
@@ -1,507 +1,188 @@
"use client";
import { motion, type Variants } from "motion/react";
import dynamic from "next/dynamic";
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,
});
import Link from "next/link";
import { type CSSProperties, Fragment } from "react";
import HeroField from "./cinematic/HeroField";
const SOCIAL_LINKS = [
{
href: "https://github.com/evandrodevbr",
label: "GitHub",
icon: (
<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" />
),
},
{
href: "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
label: "LinkedIn",
icon: (
<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" />
),
},
{
href: "mailto:[email protected]",
label: "Email",
icon: (
<>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</>
),
stroke: true,
},
];
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;
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
export type HeroStats = {
years?: number;
projects?: number;
demos?: number;
};
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;
function statItems(stats?: HeroStats) {
if (!stats) return [];
return [
stats.years ? [String(stats.years), "anos de experiência"] : null,
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
].filter((item): item is [string, string] => item !== null);
}
export default function Hero() {
const heroRef = useRef<HTMLElement>(null);
const shouldReduceMotion = useReducedMotion();
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [isHeroVisible, setIsHeroVisible] = useState(true);
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE =
"Sistemas sob medida para operações que não cabem em soluções prontas.".split(
" ",
);
const ACCENT = new Set([1, 2]);
useEffect(() => {
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
/** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
[
"As integrações",
"Os programas que você já usa conversando entre si, sem retrabalho.",
],
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
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",
);
};
const heroContainer: Variants = {
hidden: shouldReduceMotion ? { opacity: 1 } : { opacity: 0 },
visible: {
opacity: 1,
transition: shouldReduceMotion
? { duration: 0 }
: { staggerChildren: 0.1, delayChildren: 0.3 },
},
};
const easeOut: [number, number, number, number] = [0.16, 1, 0.3, 1];
const heroItem: Variants = {
hidden: shouldReduceMotion
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 40, filter: "blur(10px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1, ease: easeOut },
},
};
const heroItemSlow: Variants = {
hidden: shouldReduceMotion
? { opacity: 1, y: 0, filter: "blur(0px)" }
: { opacity: 0, y: 60, filter: "blur(12px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: shouldReduceMotion
? { duration: 0 }
: { duration: 1.2, ease: easeOut },
},
};
export default function Hero({ stats }: { stats?: HeroStats }) {
const items = statItems(stats);
return (
<section
ref={heroRef}
id="home"
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
className="hero relative w-full bg-[var(--ink)] font-display text-[#f4f2ed]"
>
{shouldRenderHeroScene && <HeroScene isVisible={isHeroVisible} />}
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<HeroField />
<div className="hero-scrim" aria-hidden="true" />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{CAPTIONS.map(([title, text], index) => (
<li key={title} style={{ "--i": index } as CSSProperties}>
<span className="hero-caption-title">{title}</span>
{text}
</li>
))}
</ol>
<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)]" />
<div
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
}}
/>
<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%)]" />
{/* 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={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={heroContainer}
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-[800px]">
<motion.div
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
variants={heroItem}
>
<div className="cinematic-glass inline-flex items-center gap-sp-3 px-4 py-2">
<motion.span
className="h-2 w-2 rounded-sm bg-[var(--slash)]"
animate={
shouldReduceMotion
? { opacity: 1 }
: {
opacity: [1, 0.25, 1],
boxShadow: [
"0 0 4px rgba(204,255,0,0.6)",
"0 0 12px rgba(204,255,0,0.2)",
"0 0 4px rgba(204,255,0,0.6)",
],
}
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 2,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
}
}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
{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)]">
{HERO_CONTENT.chip}
</div>
</motion.div>
<motion.p
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
variants={heroItem}
>
{HERO_CONTENT.eyebrow}
</motion.p>
<motion.h1
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}
>
{HERO_CONTENT.headline}
</motion.h1>
<motion.p
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
variants={heroItem}
>
{HERO_CONTENT.subtitle}
</motion.p>
<motion.ul
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
variants={heroItem}
>
{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
? false
: { opacity: 0, y: 30, scale: 0.95 }
}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.8,
delay: 0.8 + i * 0.1,
ease: [0.16, 1, 0.3, 1],
}
}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+32px)] pb-sp-8 lg:px-sp-12">
<p className="hero-in mb-sp-5 flex items-center gap-sp-3 font-mono text-[11px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)] sm:text-[12px]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
<h1 className="hero-title max-w-[12.5em] lg:max-w-[min(11em,54vw)] text-[clamp(38px,min(6vw,10.5svh),124px)] font-semibold leading-[0.98] tracking-[-0.05em]">
{TITLE.map((word, index) => (
<Fragment key={word}>
<span
className={`hero-word${ACCENT.has(index) ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
<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>
</motion.li>
))}
</motion.ul>
{word}
</span>{" "}
</Fragment>
))}
</h1>
<p className="hero-in mt-sp-5 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s]">
Projeto e desenvolvo sistemas web, aplicativos e integrações, do
entendimento do negócio à arquitetura e à entrega em produção.
</p>
<motion.div
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
variants={heroItem}
<div className="hero-in mt-sp-6 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link
href="/#contact"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
<MagneticButton
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"
strength={0.25}
Conversar sobre meu projeto
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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"
strength={0.25}
>
Ver casos selecionados
</MagneticButton>
<MagneticButton
as="a"
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}
>
Download CV
</MagneticButton>
</motion.div>
<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}
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</Link>
<Link
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/15 bg-[rgba(10,10,10,.35)] px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline backdrop-blur-sm transition-colors hover:border-white/40 motion-reduce:transition-none sm:text-[16px]"
>
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
<span className="h-2 w-2 rounded-full bg-[var(--slash)] shadow-[0_0_8px_var(--slash)]" />
Garuva, SC / Brasil
</div>
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.08em] text-[rgba(244,242,237,.68)]">
<span className="h-[1px] w-6 bg-[rgba(255,255,255,.2)]" />
Next.js, Tauri, Rust, Node.js
</div>
<div className="flex items-center gap-sp-2">
Ver projetos entregues
</Link>
</div>
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(([value, label]) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{value}
</dd>
</div>
))}
</dl>
)}
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
<li key={service} className="flex items-center gap-sp-2">
<span
aria-hidden="true"
className="h-1 w-1 bg-[var(--slash)]"
/>
{service}
</li>
),
)}
</ul>
<div className="flex flex-wrap items-center gap-x-sp-5">
{SOCIAL_LINKS.map((link) => (
<MagneticButton
<a
key={link.label}
as="a"
href={link.href}
aria-label={link.label}
target={link.href.startsWith("http") ? "_blank" : undefined}
rel={
link.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
className="cinematic-social-link inline-flex h-11 w-11 items-center justify-center rounded-full"
strength={0.4}
className="inline-flex min-h-11 items-center text-[13px] text-[rgba(244,242,237,.64)] no-underline underline-offset-4 hover:text-[#f4f2ed] hover:underline"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill={link.stroke ? "none" : "currentColor"}
stroke={link.stroke ? "currentColor" : "none"}
strokeWidth={link.stroke ? "2" : undefined}
aria-hidden="true"
>
{link.icon}
</svg>
</MagneticButton>
{link.label}
</a>
))}
<span className="text-[13px] text-[rgba(244,242,237,.55)]">
Santa Catarina · Atendimento remoto
</span>
</div>
</motion.div>
</div>
</div>
<motion.aside
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
initial={
shouldReduceMotion
? false
: { opacity: 0, x: 60, rotateY: -8, transformPerspective: 1000 }
}
animate={{ opacity: 1, x: 0, rotateY: 0 }}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 1.2,
delay: 0.5,
ease: [0.16, 1, 0.3, 1],
}
}
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
<motion.div
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
initial={shouldReduceMotion ? false : { scaleX: 0 }}
animate={{ scaleX: 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)]">
{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]">
{HERO_CONTENT.sideTitle}
</h2>
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
{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">
{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
? false
: { opacity: 0, y: 20, filter: "blur(6px)" }
}
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}
</div>
<p className="mt-sp-2 text-[14px] leading-7 text-[rgba(244,242,237,.92)]">
{item.value}
</p>
</motion.article>
))}
</div>
</motion.aside>
</motion.div>
Role para mergulhar
<span className="hero-cue-line" />
</p>
</div>
</div>
<motion.div
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
initial={shouldReduceMotion ? false : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={
shouldReduceMotion ? { duration: 0 } : { delay: 2, duration: 1 }
}
>
{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>
);
}
+5 -11
View File
@@ -242,7 +242,7 @@ export default function LighthouseMetrics() {
<div className="ds-section-shell">
<div className="quality-intro">
<header className="quality-copy">
<p className="ds-eyebrow">Quality &amp; Performance</p>
<p className="ds-eyebrow">Critérios de entrega</p>
<h2 id="quality-title" className="ds-section-title">
Engenharia de ponta a ponta.
<span>Qualidade em cada camada.</span>
@@ -252,10 +252,7 @@ export default function LighthouseMetrics() {
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"
>
<a className="ds-link ds-link-accent" href="/#contact">
Conversar sobre a qualidade do seu projeto
</a>
</header>
@@ -290,10 +287,7 @@ export default function LighthouseMetrics() {
/>
<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 className="quality-control-target">Ver critérios</span>
</span>
</label>
))}
@@ -334,8 +328,8 @@ export default function LighthouseMetrics() {
</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.
Exemplos de critérios avaliados conforme o projeto. Este painel não
representa uma auditoria, certificação ou garantia de resultado.
</p>
</div>
</section>
+14 -174
View File
@@ -1,185 +1,25 @@
"use client";
import { motion } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import Image from "next/image";
interface LogoProps {
variant?: "mark" | "wordmark" | "icon";
size?: "sm" | "md" | "lg" | "xl";
theme?: "dark" | "light" | "accent";
theme?: "dark" | "light";
className?: string;
showTagline?: boolean;
loading?: "eager" | "lazy";
}
const sizeMap = {
sm: { width: 32, height: 32 },
md: { width: 48, height: 48 },
lg: { width: 64, height: 64 },
xl: { width: 120, height: 80 },
};
export function Logo({
variant = "mark",
size = "md",
theme = "dark",
className = "",
showTagline = false,
className = "w-48",
loading = "lazy",
}: Readonly<LogoProps>) {
const dimensions = sizeMap[size];
const shouldReduceMotion = useReducedMotion();
const colors = {
dark: {
primary: "#FFFFFF",
slash: "#CCFF00",
},
light: {
primary: "#0A0A0A",
slash: "#0A0A0A",
},
accent: {
primary: "#0A0A0A",
slash: "rgba(0,0,0,.3)",
},
};
const c = colors[theme];
const hoverMotion = shouldReduceMotion
? undefined
: {
scale: 1.03,
rotate: -2,
};
const hoverTransition = {
type: "spring" as const,
stiffness: 280,
damping: 18,
};
if (variant === "icon") {
return (
<motion.svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 64 64"
fill="none"
className={className}
role="img"
aria-label="Evandro icon logo"
whileHover={hoverMotion}
transition={hoverTransition}
>
<title>Evandro icon logo</title>
<rect width="64" height="64" rx="14" fill={c.primary} />
<rect
x="10"
y="10"
width="7"
height="44"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="10"
width="18"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="28.5"
width="14"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<rect
x="17"
y="47"
width="18"
height="7"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
<polygon points="37,38 41,38 50,26 46,26" fill={c.slash} />
<circle
cx="52"
cy="47"
r="5"
fill={theme === "dark" ? "#0A0A0A" : c.primary}
/>
</motion.svg>
);
}
if (variant === "wordmark") {
return (
<motion.div
className={`flex items-center gap-3 ${className}`}
whileHover={hoverMotion}
transition={hoverTransition}
>
<svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 48 32"
fill="none"
role="img"
aria-label="Evandro wordmark logo"
>
<title>Evandro wordmark logo</title>
<rect x="2" y="2" width="7" height="28" fill={c.primary} />
<rect x="9" y="2" width="20" height="7" fill={c.primary} />
<rect x="9" y="12.5" width="16" height="7" fill={c.primary} />
<rect x="9" y="23" width="20" height="7" fill={c.primary} />
<polygon points="30,22 34,22 42,10 38,10" fill={c.slash} />
<circle cx="44" cy="25" r="4" fill={c.primary} />
</svg>
<div>
<div
className="font-display text-lg font-extrabold tracking-tight"
style={{ color: c.primary }}
>
evandro<span style={{ color: c.slash }}>.</span>dev
<span style={{ color: c.slash }}>.</span>br
</div>
{showTagline && (
<div
className="mt-1 text-[9px] tracking-widest"
style={{
color:
theme === "dark"
? "rgba(255,255,255,.25)"
: "rgba(10,10,10,.4)",
}}
>
DESENVOLVIMENTO DE SOFTWARE
</div>
)}
</div>
</motion.div>
);
}
return (
<motion.svg
width={dimensions.width}
height={dimensions.height}
viewBox="0 0 120 80"
fill="none"
className={className}
role="img"
aria-label="Evandro mark logo"
whileHover={hoverMotion}
transition={hoverTransition}
>
<title>Evandro mark logo</title>
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
<rect x="19" y="8" width="28" height="9" fill={c.primary} />
<rect x="19" y="35.5" width="22" height="9" fill={c.primary} />
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
<circle cx="80" cy="63" r="7" fill={c.primary} />
</motion.svg>
<Image
src={`/brand/wordmark-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className={`block h-auto max-w-full ${className}`}
/>
);
}
+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>
);
}
+119 -103
View File
@@ -3,8 +3,10 @@
import { motion, type Variants } from "motion/react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { caseStudies } from "@/lib/site-content";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { ProjectCover } from "./ProjectCover";
import { Section } from "./Section";
export default function Projects({
@@ -14,24 +16,11 @@ export default function Projects({
}) {
const shouldReduceMotion = useReducedMotion();
const gridVariants: Variants = {
hidden: {},
visible: {
transition: {
delayChildren: 0.08,
staggerChildren: 0.08,
},
},
};
const cardVariants: Variants = {
hidden: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
clipPath: "inset(0% 0 0 0)",
transition: shouldReduceMotion
? { duration: 0.25 }
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
@@ -41,108 +30,135 @@ export default function Projects({
return (
<Section
id="projects"
eyebrow="Selected work"
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."
eyebrow="Projetos"
title="Veja o que já saiu do papel."
description="Sistemas e aplicações com contexto, decisões técnicas e limites documentados. Explore os cases para entender o problema e a solução, além da interface."
dark
disableChildrenAnimation
>
<motion.div
className="ds-grid-2"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
{projects.map((project) => (
<motion.article
key={project.id}
className="ds-card h-full"
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<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} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
</div>
<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" data-reel-stagger="fade">
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
{project.githubUrl && (
<div className="ds-grid-fluid">
{projects.map((project) => {
const caseStudy = caseStudies.find(
(entry) => entry.id === project.id,
);
return (
<motion.article
key={project.id}
className="ds-card h-full"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: "some" }}
variants={cardVariants}
whileHover={
shouldReduceMotion
? undefined
: {
y: -4,
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
}
}
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
transition={{ type: "spring", stiffness: 170, damping: 18 }}
>
<ProjectCover project={project} />
<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} />
<h3 className="ds-card-title">{project.title}</h3>
{project.subtitle && (
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(255,255,255,.56)]">
{project.subtitle}
</p>
)}
</div>
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
as="span"
className="ds-tag"
strength={0.15}
wrapperClassName="shrink-0 max-sm:ml-auto"
>
Ver codigo
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">
{caseStudy?.summary ?? project.description}
</p>
{caseStudy && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">O desafio</p>
<p className="ds-copy">{caseStudy.impact}</p>
</div>
)}
{project.demoUrl &&
(project.demoAvailability === "available" ||
project.demoAvailability === "unknown") && (
{caseStudy?.role && (
<div className="mt-sp-4 border-l border-[var(--slash)] pl-sp-4">
<p className="ds-kicker">Minha participação</p>
<p className="ds-copy">{caseStudy.role}</p>
</div>
)}
<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}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3" data-reel="top">
{caseStudy && (
<MagneticButton
as="a"
href={project.demoUrl}
target="_blank"
rel="noreferrer"
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
strength={0.25}
strength={0.2}
>
Abrir projeto
{project.demoAvailability === "unknown" && (
<span className="sr-only">
{" "}
Disponibilidade não verificada
</span>
)}
Entender a solução
</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>
{project.githubUrl && (
<MagneticButton
as="a"
href={project.githubUrl}
target="_blank"
rel="noreferrer"
className="ds-link ds-link-muted"
strength={0.25}
>
Ver codigo
</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>
);
})}
</div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3" data-reel-stagger="left">
<MagneticButton
+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>
);
}
+102 -65
View File
@@ -11,7 +11,6 @@ import { Section } from "./Section";
interface Tier {
id: string;
badge: string;
level: string;
title: string;
tagline: string;
description: string;
@@ -25,64 +24,83 @@ interface Tier {
const TIERS: Tier[] = [
{
id: "discovery",
badge: "Entrada / Baixo Risco",
level: "01",
title: "Auditoria & Diagnóstico Técnico",
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
id: "build",
badge: "Do zero à produção",
title: "Um sistema para a sua operação",
tagline: "Quando as ferramentas prontas já não atendem.",
description:
"Análise profunda de arquitetura, performance ou segurança com entrega de um plano de ação executável. Ideal para quem precisa de direção antes de contratar execução.",
"Transforme regras de negócio, planilhas e processos dispersos em um sistema próprio, com os fluxos e as integrações que a empresa precisa.",
deliverables: [
"Relatório de gargalos e dívida técnica",
"Mapeamento de riscos e dependências",
"Sugestão de stack e cronograma de mitigação",
"Plano de ação priorizado",
"Definição de escopo e arquitetura",
"Interface, backend e banco de dados",
"Integrações e permissões de acesso",
"Validação, publicação e documentação",
],
pricing: "Valor Fixo",
pricingNote: "Contrato único. Entrega em até 5 dias úteis.",
featured: false,
ctaLabel: "Solicitar diagnóstico",
ctaHref: "#contact",
},
{
id: "cycles",
badge: "Mais Escolhido",
level: "02",
title: "Ciclos de Engenharia",
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
description:
"Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
deliverables: [
"Features em produção a cada ciclo",
"Refatoração iterativa com rastreabilidade",
"Documentação técnica atualizada",
"Capacidade de pivotar prioridades no início de cada sprint",
],
pricing: "Fixo por Ciclo",
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
pricing: "Proposta por projeto",
pricingNote: "Escopo, etapas e investimento definidos antes da execução.",
featured: true,
ctaLabel: "Iniciar ciclo de engenharia",
ctaHref: "#contact",
ctaLabel: "Conversar sobre meu sistema",
ctaHref: "/#contact",
},
{
id: "fractional",
badge: "Premium / High-Ticket",
level: "03",
title: "Fractional Tech Lead",
tagline: "Liderança técnica sênior. Sem o custo de um CTO full-time.",
id: "discovery",
badge: "Antes de investir",
title: "Diagnóstico de um sistema existente",
tagline: "Entenda o que aproveitar e o que precisa mudar.",
description:
"Direção arquitetural, gestão de risco e sustentação de operações críticas. Para empresas que precisam de decisões de alto impacto com responsabilidade contínua.",
"Seu sistema ficou lento, frágil ou difícil de manter? Avalio o código e a operação para orientar a próxima decisão, inclusive em projetos iniciados com IA.",
deliverables: [
"SLA prioritário Zero-Risk",
"Code review e lideranca do time interno",
"Decisoes de infra: Cloud, VPS, CI/CD",
"Consultoria estrategica recorrente",
"Levantamento de riscos e dependências",
"Revisão de acesso aos dados",
"Análise dos gargalos da aplicação",
"Plano de correção por prioridade",
],
pricing: "Retainer Mensal",
pricingNote: "Renovação mensal. Vagas limitadas.",
featured: false,
ctaLabel: "Agendar conversa estratégica",
ctaHref: "#contact",
pricing: "Escopo de análise definido",
pricingNote: "Profundidade e prazo combinados conforme o sistema.",
ctaLabel: "Avaliar meu projeto",
ctaHref: "/#contact",
},
{
id: "evolution",
badge: "Continuidade",
title: "Integração e evolução",
tagline: "Faça o que já existe trabalhar melhor.",
description:
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
deliverables: [
"Integração entre sistemas e equipamentos",
"Novos fluxos e automações",
"Testes das partes afetadas",
"Planejamento de manutenção e operação",
],
pricing: "Etapas acordadas",
pricingNote:
"Prioridades, suporte e responsabilidades definidos em contrato.",
ctaLabel: "Planejar a próxima etapa",
ctaHref: "/#contact",
},
];
const PROCESS = [
{
title: "Entender a operação",
description:
"Quem usa, qual problema precisa resolver e quais dados e ferramentas já existem.",
},
{
title: "Definir a entrega",
description:
"Escopo, arquitetura, riscos, investimento e critérios de aceite antes de desenvolver.",
},
{
title: "Construir e validar",
description:
"Entregas por etapa, demonstrações e testes dos fluxos, das permissões e das integrações.",
},
{
title: "Publicar e dar continuidade",
description:
"Implantação, documentação e condições de manutenção combinadas para a operação.",
},
];
@@ -112,9 +130,6 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<span className="ds-kicker max-md:text-[10px] max-md:leading-[1.6]">
{tier.badge}
</span>
<span className="text-[11px] tracking-[0.08em] text-[var(--meta)]">
{tier.level}
</span>
</div>
<DrawLine delay={0.04} />
@@ -150,7 +165,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
{tier.pricing}
</div>
<div className="text-[12px] leading-[1.7] text-[var(--meta)]">
<div className="font-display text-[12px] leading-[1.7] text-[var(--meta)]">
{tier.pricingNote}
</div>
</div>
@@ -199,23 +214,23 @@ export function WorkModalities() {
return (
<Section
id="modalities"
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."
eyebrow="Soluções"
title="Software feito a partir do seu negócio."
description="Para empresas que precisam de um sistema próprio, de ferramentas conectadas ou de uma avaliação técnica antes de dar o próximo passo."
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">O que você pode contratar</div>
<div className="ds-kicker">Reconhece algum destes cenários?</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.
Planilhas demais. Sistemas isolados. Trabalho repetido.
</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.
A operação cresceu, mas as ferramentas não acompanharam. Ou a
ideia precisa sair do protótipo para atender clientes. O projeto
começa pelo problema, sem exigir que você já saiba qual tecnologia
usar.
</p>
</div>
<MagneticButton
@@ -238,10 +253,10 @@ export function WorkModalities() {
href={service.href}
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-base font-semibold leading-snug text-[var(--ink)]">
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
{service.label}
</div>
<div className="mt-sp-2 text-sm leading-6 text-[var(--ink-80)]">
<div className="mt-sp-2 font-display text-sm leading-6 text-[var(--ink-80)]">
{service.description}
</div>
</Link>
@@ -262,6 +277,28 @@ export function WorkModalities() {
</motion.div>
))}
</motion.div>
<div id="process" className="mt-sp-12 scroll-mt-24">
<h3 className="ds-card-title">Como o projeto acontece</h3>
<p className="ds-copy max-w-[65ch]">
Você acompanha as decisões e valida cada etapa. Prazo e investimento
dependem do escopo, das integrações e dos riscos identificados.
</p>
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
{PROCESS.map((step, index) => (
<li
key={step.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<span className="ds-kicker">
{String(index + 1).padStart(2, "0")}
</span>
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
<p className="ds-copy">{step.description}</p>
</li>
))}
</ol>
</div>
</Section>
);
}
@@ -1,81 +0,0 @@
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",
);
});
});
+29 -102
View File
@@ -1,8 +1,7 @@
"use client";
import { motion, useInView } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useInView } from "motion/react";
import { type CSSProperties, useRef } from "react";
interface TextDiffuseRevealProps {
text: string;
@@ -12,8 +11,12 @@ interface TextDiffuseRevealProps {
amount?: number;
}
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
/**
* Título que entra palavra por palavra. A animação é CSS (.text-reveal em globals.css) com transparência
* e deslocamento: a placa de vídeo compõe sem redesenhar. A versão anterior animava cada letra com
* desfoque e renderizava o React de novo a cada letra, o que pesava justamente durante as transições.
* Movimento reduzido: o CSS mostra o texto direto, sem animação.
*/
export function TextDiffuseReveal({
text,
className = "",
@@ -22,111 +25,35 @@ export function TextDiffuseReveal({
amount = 0.3,
}: Readonly<TextDiffuseRevealProps>) {
const ref = useRef<HTMLSpanElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const isInView = useInView(ref, { once, amount });
const [staggerDelay, setStaggerDelay] = useState(0.03);
const [completedChars, setCompletedChars] = useState(0);
useEffect(() => {
const media = window.matchMedia("(max-width: 768px)");
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
updateStagger();
media.addEventListener("change", updateStagger);
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => {
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,
[text],
);
useEffect(() => {
if (!isInView) {
setCompletedChars(0);
}
}, [isInView]);
const hasActiveWillChange =
!shouldReduceMotion && isInView && completedChars < animatedChars;
let animatedIndex = 0;
let word = 0;
return (
<span
ref={ref}
className={className}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
className={`text-reveal ${className}`.trim()}
data-shown={isInView ? "" : undefined}
style={{ "--d": `${startDelay}s` } as CSSProperties}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{tokens.map(({ key, value: token }) => {
if (token === "\n") {
return <br key={key} />;
}
if (/^\s+$/.test(token)) {
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
}
return (
<span key={key} className="inline-block whitespace-nowrap">
{Array.from(token).map((char) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
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>
);
})}
{text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((token, index) => {
const key = `${index}:${token}`;
if (token === "\n") return <br key={key} />;
if (/^\s+$/.test(token)) return " ";
return (
<span
key={key}
className="text-reveal-word"
style={{ "--i": word++ } as CSSProperties}
>
{token}
</span>
);
})}
</span>
</span>
);
@@ -1,103 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render } 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", () => {
it("renders grain 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);
});
});
+56 -18
View File
@@ -1,12 +1,15 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
import {
resetHomeArrivalState,
useHomeFragmentNavigation,
} from "./useHomeFragmentNavigation";
function HomeNavigation() {
const onLayoutReady = useHomeFragmentNavigation();
return (
<>
<button type="button" onClick={onLayoutReady}>
Establish notebook layout
Establish layout
</button>
<a href="/#about">Sobre</a>
<a href="/#contact">Contato</a>
@@ -23,11 +26,13 @@ function HomeNavigation() {
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
beforeEach(() => {
resetHomeArrivalState();
window.history.replaceState(null, "", "/");
HTMLElement.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
window.history.replaceState(null, "", "/");
if (originalScrollIntoView) {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
@@ -37,20 +42,59 @@ afterEach(() => {
});
describe("home fragment navigation", () => {
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
it("starts an unfragmented visit at the top after the home establishes layout", () => {
let restoredPosition = 3200;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object")
restoredPosition = options.top ?? restoredPosition;
});
render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(restoredPosition).toBe(0);
});
it("leaves a same-document return to the position the browser restores", () => {
const scrollTo = jest
.spyOn(window, "scrollTo")
.mockImplementation(() => {});
const firstVisit = render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
firstVisit.unmount();
expect(window.history.scrollRestoration).not.toBe("manual");
scrollTo.mockClear();
render(<HomeNavigation />);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(scrollTo).not.toHaveBeenCalled();
expect(window.history.scrollRestoration).not.toBe("manual");
});
it("does not reset the visitor's position after intentional scrolling during startup", () => {
let position = 0;
jest
.spyOn(window, "scrollTo")
.mockImplementation((options: ScrollToOptions | number) => {
if (typeof options === "object") position = options.top ?? position;
});
render(<HomeNavigation />);
position = 600;
fireEvent.wheel(window, { deltaY: 600 });
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(position).toBe(600);
});
it("waits for the home 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" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(contact).toHaveFocus();
screen.getByRole("button", { name: "Read biography" }).focus();
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(
screen.getByRole("button", { name: "Read biography" }),
).toHaveFocus();
@@ -62,18 +106,14 @@ describe("home fragment navigation", () => {
const message = screen.getByRole("button", { name: "Send message" });
message.focus();
fireEvent.wheel(window, { deltaY: 200 });
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish 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" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
const link = screen.getByRole("link", { name: "Sobre" });
link.focus();
expect(fireEvent.click(link)).toBe(true);
@@ -95,9 +135,7 @@ describe("home fragment navigation", () => {
window.history.replaceState(null, "", "/#contact");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
fireEvent.click(
screen.getByRole("button", { name: "Establish notebook layout" }),
);
fireEvent.click(screen.getByRole("button", { name: "Establish layout" }));
expect(message).toHaveFocus();
});
});
+28 -37
View File
@@ -1,5 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import type {
PortfolioExperience,
PortfolioProjectView,
@@ -7,73 +8,63 @@ import type {
} from "@/lib/portfolio-data";
import About from "../About";
import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Experience from "../Experience";
import Footer from "../Footer";
import Hero from "../Hero";
import Hero, { type HeroStats } from "../Hero";
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 ExperienceDisclosure from "./ExperienceDisclosure";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
export default function CinematicHome({
projects,
experiences,
skillCategories,
heroStats,
}: {
heroStats?: HeroStats;
projects: PortfolioProjectView[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<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" direction="left" />
</div>
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
<About />
<SectionDivider variant="waveform" />
<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"
direction="right"
/>
</div>
<Experience experiences={experiences} />
<SectionDivider variant="gradient" />
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. */}
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} />
<Projects projects={projects} />
<LighthouseMetrics />
<SectionDivider variant="pulse" />
<WorkModalities />
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText text="VAMOS CONVERSAR" direction="left" />
<EngineeringWithAI />
<LighthouseMetrics />
<div className="bg-[var(--paper)]">
<About />
<ExperienceDisclosure>
<Skills categories={skillCategories} />
<Experience experiences={experiences} />
</ExperienceDisclosure>
</div>
<Contact />
</main>
<Footer />
<div className="relative z-10">
<Footer />
</div>
</div>
);
}
@@ -1,146 +0,0 @@
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 });
});
});
@@ -1,210 +0,0 @@
"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>
);
}
@@ -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>
);
}
+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>
);
}
-404
View File
@@ -1,404 +0,0 @@
"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>
);
}
@@ -1,76 +0,0 @@
"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,44 +0,0 @@
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>
);
}
-193
View File
@@ -1,193 +0,0 @@
"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,327 @@
"use client";
import { type RefObject, useEffect } from "react";
type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
) => 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").
* Truque de todas as transições: durante a última altura de tela entre duas seções, a que sai é empurrada
* para baixo na mesma medida da rolagem (parece parada) e a que entra pode fazer o mesmo. Assim nada
* "rola"; só a transição acontece. Sem pin e sem mudar o layout: links internos e medições seguem iguais.
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (as "travas" de posição duram exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - a seção que saiu fica invisível depois da passagem, para não aparecer atrás de transparências.
* 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). */
export 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 sai fica parada na tela enquanto a próxima entra
const holdPrev = (tl: gsap.core.Timeline, prev: HTMLElement) =>
tl.fromTo(prev, { y: 0 }, { y: vh }, 0);
// a seção que entra também fica parada no topo: a transição acontece "no lugar"
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) => {
holdPrev(tl, prev);
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(
prev,
{ autoAlpha: 1 },
{ autoAlpha: 0, duration: 0.4 },
0,
).fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris abre do centro enquanto a anterior avança contra a câmera
(tl, prev, next) => {
holdPrev(tl, prev);
holdNext(tl, next);
const center = () => `at 50% ${vh() / 2}px`;
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
)
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () =>
`circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next) => {
holdPrev(tl, prev);
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 mais rápido que a rolagem, 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) => {
holdPrev(tl, prev);
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0).fromTo(
next,
{ yPercent: 0, 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) => {
holdPrev(tl, prev);
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) => {
holdPrev(tl, prev);
holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => `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);
const scenes = Array.from(main.children) as HTMLElement[];
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: "relative",
zIndex: index === 0 ? 30 : index + 1,
});
});
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: () => pageTop(next) - vh(),
end: () => pageTop(next),
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);
});
}, main);
// Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na
// tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior;
// depois não aparece atrás de transparências, e o navegador não pinta o que está coberto.
// As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las
// deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura.
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = scenes.map(pageTop);
scenes.forEach((scene, index) => {
const from = index === 0 ? -Infinity : tops[index] - vh();
const to = index === scenes.length - 1 ? Infinity : tops[index + 1];
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
});
};
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", queueSync);
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
const scene = scenes.find((candidate) => candidate.contains(target));
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const top =
!scene || scene === scenes[0]
? 0
: Math.max(
pageTop(scene),
scene === target ? 0 : pageTop(target) - 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", queueSync);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) scene.style.visibility = "";
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}
@@ -16,6 +16,17 @@ function focusFragment(target: HTMLElement) {
target.focus({ preventScroll: true });
}
/**
* A document owns one outstanding homepage arrival. Later mounts are same-document
* history, where the browser restores its own position.
*/
let arrivalSettled = false;
/** Tests replay several documents inside one jsdom document. */
export function resetHomeArrivalState() {
arrivalSettled = false;
}
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
export function useHomeFragmentNavigation() {
const ready = useRef(false);
@@ -26,22 +37,49 @@ export function useHomeFragmentNavigation() {
| 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 restoration = navigation?.type === "back_forward";
const arrival = !restoration && !arrivalSettled;
let pending = restoration ? "" : window.location.hash;
let pendingTop = arrival && !window.location.hash;
if (arrival) window.history.scrollRestoration = "manual";
const resolve = () => {
if (!ready.current || !pending) return;
if (!ready.current) return;
if (pendingTop) {
pendingTop = false;
if (!window.location.hash)
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}
if (!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 padding =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const margin =
Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
window.scrollTo({
top: Math.max(
0,
window.scrollY +
target.getBoundingClientRect().top -
padding -
margin,
),
left: 0,
behavior: "instant",
});
};
const cancelArrival = () => {
pending = "";
pendingTop = false;
window.history.scrollRestoration = "auto";
};
const onKeyDown = (event: KeyboardEvent) => {
if (
@@ -88,11 +126,13 @@ export function useHomeFragmentNavigation() {
return;
const target = fragmentTarget(url.hash);
if (!target) return;
pendingTop = false;
pending = ready.current ? "" : url.hash;
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
focusFragment(target);
};
const onHashChange = () => {
pendingTop = false;
if (pending && pending !== window.location.hash) cancelArrival();
if (!ready.current) return;
const target = fragmentTarget(window.location.hash);
@@ -109,6 +149,7 @@ export function useHomeFragmentNavigation() {
window.addEventListener("pointerdown", cancelArrival, { passive: true });
return () => {
resolveArrival.current = null;
window.history.scrollRestoration = "auto";
document.removeEventListener("click", onClick, true);
window.removeEventListener("hashchange", onHashChange);
window.removeEventListener("wheel", cancelArrival);
@@ -121,5 +162,7 @@ export function useHomeFragmentNavigation() {
return useCallback(() => {
ready.current = true;
resolveArrival.current?.();
arrivalSettled = true;
window.history.scrollRestoration = "auto";
}, []);
}
@@ -1,285 +0,0 @@
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,
);
});
});
-427
View File
@@ -1,427 +0,0 @@
"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>
);
}
@@ -1,43 +0,0 @@
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",
);
});
});
-52
View File
@@ -1,52 +0,0 @@
"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>
);
}
@@ -1,131 +0,0 @@
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);
});
});
-445
View File
@@ -1,445 +0,0 @@
"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>
);
}
-221
View File
@@ -1,221 +0,0 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import NotebookStory, { canUseNotebook3D } from "./NotebookStory";
const mockNotebookStage = jest.fn(({ isVisible }: { isVisible: boolean }) => (
<div data-is-visible={String(isVisible)} data-testid="notebook-stage" />
));
jest.mock("next/dynamic", () => () => {
return function DynamicNotebookStage(props: { isVisible: boolean }) {
return mockNotebookStage(props);
};
});
jest.mock("gsap", () => ({
gsap: {
registerPlugin: jest.fn(),
timeline: jest.fn(() => ({ to: jest.fn().mockReturnThis() })),
context: jest.fn((callback: () => void) => {
callback();
return { revert: jest.fn() };
}),
},
}));
jest.mock("gsap/ScrollTrigger", () => ({
ScrollTrigger: { getAll: jest.fn(() => []) },
}));
const projects = getFeaturedProjects(null);
let intersectionCallback: IntersectionObserverCallback | undefined;
const mockObserve = jest.fn();
const mockDisconnect = jest.fn();
function setDocumentHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", {
configurable: true,
value: hidden,
});
}
function setSectionIntersects(isIntersecting: boolean) {
const entry = { isIntersecting } as IntersectionObserverEntry;
act(() => intersectionCallback?.([entry], {} as IntersectionObserver));
}
function mockMatchMedia({
reduced = false,
coarse = false,
hover = true,
}: {
reduced?: boolean;
coarse?: boolean;
hover?: boolean;
} = {}) {
const matcher = jest.isMockFunction(window.matchMedia)
? window.matchMedia
: jest.spyOn(window, "matchMedia");
matcher.mockImplementation((query: string) => ({
matches:
(query === "(prefers-reduced-motion: reduce)" && reduced) ||
(query === "(pointer: coarse)" && coarse) ||
(query === "(hover: hover)" && hover),
media: query,
onchange: null,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
}
describe("NotebookStory", () => {
beforeEach(() => {
mockMatchMedia();
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1280,
});
setDocumentHidden(false);
intersectionCallback = undefined;
mockObserve.mockReset();
mockDisconnect.mockReset();
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
constructor(callback: IntersectionObserverCallback) {
intersectionCallback = callback;
}
disconnect = mockDisconnect;
observe = mockObserve;
takeRecords = () => [];
unobserve = jest.fn();
}
global.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
});
afterEach(() => {
jest.restoreAllMocks();
});
it("renders the static fallback when reduced motion is enabled", async () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("does not initialize the 3D stage on coarse pointers", async () => {
mockMatchMedia({ coarse: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("initializes the 3D stage only on a desktop hover-capable viewport", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
});
it("stays paused when the tab returns while the section is offscreen", async () => {
render(<NotebookStory projects={projects} />);
const stage = await screen.findByTestId("notebook-stage");
expect(stage).toHaveAttribute("data-is-visible", "false");
setSectionIntersects(true);
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "true"),
);
setDocumentHidden(true);
act(() => document.dispatchEvent(new Event("visibilitychange")));
await waitFor(() =>
expect(stage).toHaveAttribute("data-is-visible", "false"),
);
setSectionIntersects(false);
setDocumentHidden(false);
act(() => document.dispatchEvent(new Event("visibilitychange")));
expect(stage).toHaveAttribute("data-is-visible", "false");
});
it("returns to the static fallback when the viewport loses desktop eligibility", async () => {
render(<NotebookStory projects={projects} />);
await waitFor(() =>
expect(screen.getByTestId("notebook-stage")).toBeInTheDocument(),
);
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 800,
});
window.dispatchEvent(new Event("resize"));
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
});
it("keeps the static fallback when no mapped projects are available", async () => {
mockMatchMedia();
render(<NotebookStory projects={[]} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(
screen.getByLabelText("Projetos em produção no notebook"),
).toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByTestId("notebook-stage")).not.toBeInTheDocument(),
);
});
it("requires desktop hover input without reduced motion", () => {
const matchMedia = (query: string) =>
({
matches: query === "(hover: hover)",
}) as MediaQueryList;
expect(canUseNotebook3D({ innerWidth: 1280, matchMedia })).toBe(true);
expect(canUseNotebook3D({ innerWidth: 1024, matchMedia })).toBe(false);
expect(
canUseNotebook3D({
innerWidth: 1280,
matchMedia: (query) =>
({
matches:
query === "(hover: hover)" ||
query === "(prefers-reduced-motion: reduce)",
}) as MediaQueryList,
}),
).toBe(false);
});
it("passes only mapped projects into the fallback browser", () => {
mockMatchMedia({ reduced: true });
render(<NotebookStory projects={projects} />);
expect(screen.getByRole("tab", { name: /NutriScan/i })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /Twins Lab/i }),
).not.toBeInTheDocument();
});
});
-302
View File
@@ -1,302 +0,0 @@
"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>
);
}
@@ -1,82 +0,0 @@
import {
getFeaturedProjects,
type PortfolioProjectView,
} from "@/lib/portfolio-data";
import { selectNotebookProjects } from "./notebookProjects";
const projectsFixture = getFeaturedProjects(null);
describe("selectNotebookProjects", () => {
it("returns featured projects in notebook order", () => {
const result = selectNotebookProjects(projectsFixture);
expect(result.map((project) => project.id)).toEqual([
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
]);
});
it("omits a project when its screenshot mapping is missing", () => {
const result = selectNotebookProjects([
...projectsFixture,
{ ...projectsFixture[0], id: "unknown-live-project" },
]);
expect(
result.some((project) => project.id === "unknown-live-project"),
).toBe(false);
});
it("omits a project with an unapproved demo URL", () => {
const projectsWithUnsafeUrl = projectsFixture.map((project) =>
project.id === "nutriscan"
? { ...project, demoUrl: "javascript:alert(1)" }
: project,
);
const result = selectNotebookProjects(projectsWithUnsafeUrl);
expect(result.some((project) => project.id === "nutriscan")).toBe(false);
});
it("keeps a project without a demo so its local evidence remains visible", () => {
const projectsWithoutDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoUrl: null,
demoAvailability: "none" as const,
demoCheckedAt: null,
}
: project,
) as unknown as PortfolioProjectView[];
expect(
selectNotebookProjects(projectsWithoutDemo).some(
(project) => project.id === "nutriscan",
),
).toBe(true);
});
it("keeps an unavailable project when its local evidence and URL exist", () => {
const projectsWithUnavailableDemo = projectsFixture.map((project) =>
project.id === "nutriscan"
? {
...project,
demoAvailability: "unavailable" as const,
demoCheckedAt: "2026-09-04T12:00:00.000Z",
}
: project,
);
expect(
selectNotebookProjects(projectsWithUnavailableDemo).find(
(project) => project.id === "nutriscan",
),
).toMatchObject({
demoAvailability: "unavailable",
demoCheckedAt: "2026-09-04T12:00:00.000Z",
});
});
});
-68
View File
@@ -1,68 +0,0 @@
import type { PortfolioProjectView } from "@/lib/portfolio-data";
export type NotebookProject = {
id: string;
title: string;
subtitle: string;
demoUrl: PortfolioProjectView["demoUrl"];
demoAvailability: PortfolioProjectView["demoAvailability"];
demoCheckedAt: PortfolioProjectView["demoCheckedAt"];
technologies: string[];
screenshotSrc: string;
screenshotWidth: number;
screenshotHeight: number;
};
const NOTEBOOK_ORDER = [
"nutriscan",
"sc-plus",
"sev-modaintima",
"as-servicos",
] as const;
const SCREENSHOT_BY_PROJECT_ID: Record<
(typeof NOTEBOOK_ORDER)[number],
string
> = {
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 isApprovedDemoUrl(
value: PortfolioProjectView["demoUrl"],
): value is string {
return typeof value === "string" && /^https?:\/\//.test(value);
}
export function selectNotebookProjects(
projects: readonly PortfolioProjectView[],
): NotebookProject[] {
return NOTEBOOK_ORDER.flatMap((id) => {
const project = projects.find((candidate) => candidate.id === id);
const screenshotSrc = SCREENSHOT_BY_PROJECT_ID[id];
if (
!project ||
(project.demoUrl !== null && !isApprovedDemoUrl(project.demoUrl))
) {
return [];
}
return [
{
id: project.id,
title: project.title,
subtitle: project.subtitle,
demoUrl: project.demoUrl,
demoAvailability: project.demoAvailability,
demoCheckedAt: project.demoCheckedAt,
technologies: [...project.technologies],
screenshotSrc,
screenshotWidth: 1600,
screenshotHeight: 1000,
},
];
});
}
@@ -1,75 +0,0 @@
import { getNotebookFrame, getNotebookSeekProgress } from "./notebookTimeline";
describe("notebook choreography", () => {
it("opens and boots before browsing, then blanks the display before closing", () => {
const frames = Array.from({ length: 1001 }, (_, index) =>
getNotebookFrame(index / 1000, 4),
);
const phases = [...new Set(frames.map((frame) => frame.phase))];
expect(phases).toEqual([
"approach",
"open",
"boot",
"cursor-in",
"browse",
"hold",
"close",
]);
for (const frame of frames) {
if (frame.phase === "open") expect(frame.screenOpacity).toBe(0);
if (frame.cursorVisible) expect(frame.screenOpacity).toBe(1);
if (frame.phase === "close" && frame.lidAngle < 112)
expect(frame.screenOpacity).toBe(0);
}
expect(getNotebookFrame(-1, 4).lidAngle).toBe(0);
expect(getNotebookFrame(2, 4).lidAngle).toBe(0);
});
it("keeps the previous project until cursor arrival, including reverse scroll", () => {
const frames = Array.from({ length: 10001 }, (_, index) =>
getNotebookFrame(index / 10000, 4),
);
for (let selected = 1; selected < 4; selected += 1) {
const arrival = frames.findIndex(
(frame) => frame.activeTabIndex === selected,
);
const before = getNotebookFrame((arrival - 1) / 10000, 4);
const atClick = frames[arrival];
expect(before.activeTabIndex).toBe(selected - 1);
expect(before.cursorClicking).toBe(false);
expect(atClick.cursorClicking).toBe(true);
expect(atClick.cursorX).toBeCloseTo((selected + 0.5) / 4);
const reversed = getNotebookFrame((arrival - 2) / 10000, 4);
expect(reversed.activeTabIndex).toBe(selected - 1);
expect(reversed.cursorX).toBeLessThan(atClick.cursorX);
expect(reversed.cursorX).toBeGreaterThan((selected - 0.5) / 4);
}
});
it("seeks directly into a readable hold for the requested project", () => {
for (const count of [1, 4]) {
for (let index = 0; index < count; index += 1) {
const progress = getNotebookSeekProgress(index, count);
const frame = getNotebookFrame(progress, count);
expect(frame.activeTabIndex).toBe(index);
expect(frame.screenOpacity).toBe(1);
expect(frame.cursorClicking).toBe(false);
expect(getNotebookFrame(progress + 0.03, count).activeTabIndex).toBe(
index,
);
}
}
expect(
getNotebookFrame(getNotebookSeekProgress(99, 4), 4).activeTabIndex,
).toBe(3);
});
it("does not expose a tab or cursor for empty or invalid project counts", () => {
expect(getNotebookFrame(0.6, 0)).toMatchObject({
activeTabIndex: -1,
cursorVisible: false,
cursorClicking: false,
});
expect(getNotebookFrame(0.6, Number.NaN).activeTabIndex).toBe(-1);
});
});
-117
View File
@@ -1,117 +0,0 @@
export type NotebookPhase =
| "approach"
| "open"
| "boot"
| "cursor-in"
| "browse"
| "hold"
| "close";
export type NotebookFrame = {
phase: NotebookPhase;
lidAngle: number;
deviceOpacity: number;
screenOpacity: number;
activeTabIndex: number;
cursorVisible: boolean;
cursorX: number;
cursorY: number;
cursorClicking: boolean;
};
const OPEN_START = 0.06;
const BOOT_START = 0.18;
const CURSOR_START = 0.23;
const BROWSE_START = 0.3;
const BROWSE_END = 0.84;
const CLOSE_START = 0.9;
const CLOSE_LID_START = 0.93;
const OPEN_ANGLE = 112;
const ARRIVAL = 0.18;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function between(value: number, start: number, end: number): number {
return clamp((value - start) / (end - start), 0, 1);
}
export function getNotebookSeekProgress(
index: number,
tabCount: number,
): number {
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
if (!count) return BROWSE_START;
const selected = clamp(
Number.isFinite(index) ? Math.floor(index) : 0,
0,
count - 1,
);
return (
BROWSE_START + ((BROWSE_END - BROWSE_START) * (selected + 0.6)) / count
);
}
/** One reversible clock. Each tab arrives/clicks before its long reading hold. */
export function getNotebookFrame(
progress: number,
tabCount: number,
target?: NotebookFrame,
): NotebookFrame {
const p = clamp(Number.isFinite(progress) ? progress : 0, 0, 1);
const count = Number.isFinite(tabCount)
? Math.max(0, Math.floor(tabCount))
: 0;
const phase: NotebookPhase =
p < OPEN_START
? "approach"
: p < BOOT_START
? "open"
: p < CURSOR_START
? "boot"
: p < BROWSE_START
? "cursor-in"
: p < BROWSE_END
? "browse"
: p < CLOSE_START
? "hold"
: "close";
const slot = between(p, BROWSE_START, BROWSE_END) * count;
const destination = clamp(Math.floor(slot), 0, Math.max(0, count - 1));
const local = slot - destination;
const moving = phase === "browse" && destination > 0 && local < ARRIVAL;
const activeTabIndex = !count ? -1 : moving ? destination - 1 : destination;
const travel = between(local, 0, ARRIVAL);
const cursorTab = moving
? destination - 1 + travel * travel * (3 - 2 * travel)
: destination;
const entry = between(p, CURSOR_START, BROWSE_START);
const frame = target ?? ({} as NotebookFrame);
frame.phase = phase;
frame.lidAngle =
OPEN_ANGLE *
between(p, OPEN_START, BOOT_START) *
(1 - between(p, CLOSE_LID_START, 1));
frame.deviceOpacity = between(p, 0, OPEN_START);
frame.screenOpacity =
between(p, BOOT_START, CURSOR_START) *
(1 - between(p, CLOSE_START, CLOSE_LID_START));
frame.activeTabIndex = activeTabIndex;
frame.cursorVisible = count > 0 && p >= CURSOR_START && p < CLOSE_START;
frame.cursorX = count
? phase === "cursor-in"
? 0.5 + (0.5 / count - 0.5) * entry
: (cursorTab + 0.5) / count
: 0.5;
frame.cursorY = phase === "cursor-in" ? 0.95 - 0.65 * entry : 0.3;
frame.cursorClicking =
count > 0 &&
phase === "browse" &&
(destination === 0
? local < 0.07
: local >= ARRIVAL && local < ARRIVAL + 0.07);
return frame;
}
-24
View File
@@ -1,30 +1,6 @@
import { render, screen, within } from "@testing-library/react";
import { metadata as rootMetadata } from "../layout";
import CurriculoPage from "./page";
jest.mock("next/font/google", () => ({
Geist_Mono: () => ({ variable: "font-geist-mono" }),
Space_Grotesk: () => ({ variable: "font-space-grotesk" }),
}));
jest.mock("next/font/local", () => () => ({
variable: "font-monument-extended",
}));
const authorizedRegion =
"Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
it("uses one factual regional description across home metadata", () => {
const description = rootMetadata.description;
expect(typeof description).toBe("string");
expect(description).toContain(authorizedRegion);
expect(description).not.toMatch(
/Joinville|São Francisco do Sul|Itapoá|Jaraguá do Sul|Curitiba/,
);
expect(rootMetadata.openGraph).toMatchObject({ description });
expect(rootMetadata.twitter).toMatchObject({ description });
});
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
render(CurriculoPage());
+2 -1
View File
@@ -8,11 +8,12 @@ import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/curriculo`;
export const metadata: Metadata = {
title: "Currículo de Evandro Fonseca Junior para Contratação",
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
description:
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
description:
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+568 -711
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title"><title>Evandro Fonseca Junior</title><rect width="64" height="64" fill="#0a0a0a"/><path fill="#ccff00" d="M12 12h40v8H21v8h25v8H21v8h31v8H12z"/><path fill="#f4f2ed" d="M22 24h22v8H22z"/></svg>

Before

Width:  |  Height:  |  Size: 288 B

+26 -4
View File
@@ -10,9 +10,9 @@ import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import "./globals.css";
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
const defaultTitle = "Sistemas sob medida para empresas | Evandro.dev.br";
const seoDescription =
"Evandro Fonseca Junior é engenheiro da computação e desenvolvedor de software com atuação em Next.js, TypeScript, Rust, IoT e integrações. Base em Garuva, atendimento a projetos no Norte de Santa Catarina e atuação remota.";
"Sistemas web, aplicativos e integrações sob medida, do negócio à produção, com Evandro Fonseca Junior. Base em Santa Catarina, atendimento remoto.";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
@@ -77,7 +77,7 @@ export const metadata: Metadata = {
url: "/opengraph-image",
width: 1200,
height: 630,
alt: "Evandro Fonseca Junior | Engenharia de Software, Next.js, Tauri e Rust",
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
},
],
},
@@ -89,7 +89,7 @@ export const metadata: Metadata = {
images: [
{
url: "/opengraph-image",
alt: "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software",
alt: "Evandro.dev.br | Sistemas sob medida para empresas",
},
],
},
@@ -128,6 +128,28 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
lang="pt-BR"
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
>
<head>
{/* Set arrival policy before native restoration and client-side layout changes. */}
<style id="arrival-scroll-policy">{`html { scroll-behavior: auto !important; }`}</style>
<script>{`
addEventListener("load", () => {
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
if (sheet) sheet.disabled = true;
}, { once: true });
if (location.pathname === "/") {
const navigation = performance.getEntriesByType("navigation")[0];
if (navigation?.type !== "back_forward") {
history.scrollRestoration = "manual";
if (navigation?.type === "reload" && location.hash) {
history.replaceState(history.state, "", location.pathname + location.search);
}
addEventListener("pagehide", () => {
history.scrollRestoration = "auto";
}, { once: true });
}
}
`}</script>
</head>
<body>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<a className="skip-link" href="#main-content">
-37
View File
@@ -1,37 +0,0 @@
import { shouldLoadHeroScene } from "./should-load-hero-scene";
describe("shouldLoadHeroScene", () => {
it("keeps WebGL off for reduced motion, coarse pointers, and narrow screens", () => {
expect(
shouldLoadHeroScene({
reducedMotion: true,
coarsePointer: false,
width: 1440,
}),
).toBe(false);
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: true,
width: 1440,
}),
).toBe(false);
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: false,
width: 767,
}),
).toBe(false);
});
it("allows WebGL only on capable desktop screens", () => {
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: false,
width: 1024,
}),
).toBe(true);
});
});
-11
View File
@@ -1,11 +0,0 @@
export function shouldLoadHeroScene({
reducedMotion,
coarsePointer,
width,
}: {
reducedMotion: boolean;
coarsePointer: boolean;
width: number;
}) {
return !reducedMotion && !coarsePointer && width >= 1024;
}
+9 -9
View File
@@ -2,28 +2,28 @@ import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Evandro Fonseca Junior — Engenheiro da Computação e Desenvolvedor de Software",
name: "Evandro.dev.br — Sistemas sob medida para empresas",
short_name: "Evandro.dev",
description:
"Portfólio profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba.",
"Desenvolvimento de sistemas web, aplicativos e integrações sob medida. Engenharia de software com Evandro Fonseca Junior, de Santa Catarina para sua empresa.",
start_url: "/",
display: "standalone",
background_color: "#000000",
theme_color: "#000000",
orientation: "portrait-primary",
categories: ["portfolio", "developer", "engineering", "technology"],
categories: ["business", "productivity", "technology"],
lang: "pt-BR",
dir: "ltr",
icons: [
{
src: "/favicon.ico",
sizes: "any",
type: "image/x-icon",
src: "/brand/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon.svg",
sizes: "any",
type: "image/svg+xml",
src: "/icon.png",
sizes: "512x512",
type: "image/png",
},
],
};
+4 -5
View File
@@ -1,17 +1,16 @@
import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt =
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
export const alt = "Evandro.dev.br — Sistemas sob medida para empresas";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Portfólio de engenharia de software"
title="Evandro Fonseca Junior"
description="Engenheiro da Computação e Desenvolvedor de Software"
eyebrow="Engenharia de software para empresas"
title="Sistemas sob medida."
description="Da ideia à produção. Sistemas web, aplicativos e integrações com Evandro Fonseca Junior."
/>,
OG_SIZE,
);
+16 -1
View File
@@ -1,10 +1,25 @@
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
return match ? Number(match[1]) : undefined;
}
export default async function Home() {
const projects = await loadFeaturedProjects();
return (
<CinematicHome
projects={await loadFeaturedProjects()}
heroStats={{
years: yearsOfExperience(),
projects: projects.length,
demos: projects.filter(
(project) =>
project.demoUrl && project.demoAvailability !== "unavailable",
).length,
}}
projects={projects}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
/>
+1 -9
View File
@@ -1,17 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import PrivacyPage, { metadata } from "./page";
it("publishes factual privacy content and a breadcrumb-only JSON-LD graph", () => {
it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => {
const { container } = render(<PrivacyPage />);
expect(
screen.getByRole("heading", { level: 1, name: /privacidade/i }),
).toBeVisible();
expect(screen.getByText(/evandro\.analytics-consent\.v1/)).toBeVisible();
expect(screen.getAllByText(/WhatsApp/).length).toBeGreaterThan(0);
expect(screen.getByText(/nenhuma tag do Google é carregada/i)).toBeVisible();
expect(screen.getByText(/2026-09-04/)).toBeVisible();
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
+22 -5
View File
@@ -12,6 +12,7 @@ export const metadata: Metadata = {
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
title: `Privacidade e consentimento | ${siteName}`,
description:
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
@@ -107,10 +108,26 @@ export default function PrivacyPage() {
<code>granted</code> ou <code>denied</code>.
</p>
<p className="ds-copy mt-sp-4">
A publicidade permanece negada. Ao conceder analytics, o Google
pode criar cookies <code>_ga*</code>. Ao revogar, novas coletas
são desativadas e os cookies de analytics acessíveis são
expirados.
Cookies de publicidade e personalização permanecem negados. Ao
conceder analytics, o Google pode criar cookies{" "}
<code>_ga*</code>. Ao revogar, o consentimento é atualizado para
negado, o loader é removido e os cookies de analytics acessíveis
são expirados.
</p>
<p className="ds-copy mt-sp-4">
A tag compartilhada conecta Google Analytics e Google Ads. O
modo de consentimento do Google pode enviar sinais técnicos sem
cookies de publicidade após o aceite de analytics, com redação
dos dados de anúncios ativada. Não enviamos eventos de venda nem
o conteúdo do formulário para esses serviços.
</p>
<p className="ds-copy mt-sp-4">
Após o aceite de analytics, medimos o acionamento do WhatsApp
para entender quais serviços e botões geram interesse. Esse
evento registra o serviço, a posição do botão e o caminho da
página, sem nome, e-mail, mensagem ou parâmetros da URL. O
acionamento não confirma envio de mensagem nem contratação. O
contato pelo WhatsApp funciona mesmo sem aceitar analytics.
</p>
</article>
@@ -141,7 +158,7 @@ export default function PrivacyPage() {
<p className="ds-kicker">Vigência</p>
<p className="ds-copy mt-sp-2">
Esta versão está vigente desde{" "}
<time dateTime="2026-09-04">2026-09-04</time>.
<time dateTime="2026-09-13">2026-09-13</time>.
</p>
</div>
</div>
+6 -17
View File
@@ -104,12 +104,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
expect(screen.getByText(detail)).not.toBeVisible();
}
const primaryCtas = screen.getAllByRole("link", {
name: service.ctaLabel,
});
expect(primaryCtas).toHaveLength(2);
const primaryCtas = screen.getAllByRole("link", { name: /WhatsApp/i });
for (const cta of primaryCtas) {
expect(cta).toHaveAttribute("href", "/#contact");
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(service.navLabel);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
}
const expectedCaseLinks = service.relatedCaseIds
@@ -209,18 +210,6 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
it("keeps hub cards and structured items aligned", () => {
const { container } = render(<ServicesIndexPage />);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
});
expect(
within(visibleBreadcrumb)
.getAllByRole("link")
.map((link) => [link.textContent, link.getAttribute("href")]),
).toEqual([
["Início", "/"],
["Serviços", "/servicos"],
]);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
"CollectionPage",
+19 -14
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo";
import {
@@ -44,6 +45,7 @@ export async function generateMetadata({
canonical: url,
},
openGraph: {
locale: "pt_BR",
title: service.seoTitle,
description: service.metaDescription,
url,
@@ -122,14 +124,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
{service.answer}
</p>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href={service.ctaHref}
className="ds-link ds-link-accent"
strength={0.25}
<WhatsAppLink
service={service.navLabel}
placement="service_detail_hero"
className="ds-link ds-link-accent min-h-12"
>
{service.ctaLabel}
</MagneticButton>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/cases"
@@ -139,6 +140,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
Ver casos relacionados
</MagneticButton>
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
preencher um formulário. Atendimento remoto a partir de Garuva,
SC.
</p>
</div>
</div>
</section>
@@ -305,14 +311,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
remotamente.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href={service.ctaHref}
className="ds-link ds-link-accent"
strength={0.25}
<WhatsAppLink
service={service.navLabel}
placement="service_detail_footer"
className="ds-link ds-link-accent min-h-12"
>
{service.ctaLabel}
</MagneticButton>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/servicos"
+264 -79
View File
@@ -1,14 +1,53 @@
import type { Metadata } from "next";
import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { serviceHubLinks, siteName, siteUrl } from "@/lib/site-content";
import {
caseStudies,
serviceHubLinks,
siteName,
siteUrl,
} from "@/lib/site-content";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, Sistemas Web e Aplicativos Mobile e Desktop";
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida";
const pageDescription =
"Desenvolvimento de sites, sistemas web, aplicativos mobile e desktop para Windows e Linux. Integrações, melhorias e consultoria para o seu negócio.";
"Sites, sistemas web e aplicativos sob medida com Evandro, desenvolvedor em Garuva-SC e atendimento remoto no Brasil. Conheça projetos e converse pelo WhatsApp.";
const businessIntents = [
{
title: "Quero um site para meu negócio",
description:
"Apresente seus serviços, produtos e diferenciais em um endereço próprio, com leitura no celular e um caminho claro para o cliente entrar em contato.",
href: "/servicos/criacao-de-sites",
linkLabel: "Conhecer a criação de sites",
service: "Sites profissionais",
ctaLabel: "Conversar sobre meu site",
},
{
title: "Preciso organizar minha operação",
description:
"Um sistema web para acompanhar pedidos, atendimentos ou informações hoje espalhadas em planilhas. Também posso conectar ferramentas e melhorar o software que você já usa.",
href: "/servicos/engenharia-de-software",
linkLabel: "Conhecer o desenvolvimento de sistemas",
service: "Sistemas web",
ctaLabel: "Conversar sobre meu sistema",
},
{
title: "Quero desenvolver um aplicativo",
description:
"Leve um fluxo de trabalho para o celular ou computador. Avaliamos quem vai usar, os recursos necessários e a plataforma antes de definir o desenvolvimento e a distribuição.",
href: "/servicos/engenharia-de-software",
linkLabel: "Entender o escopo de um app mobile",
service: "Aplicativos mobile e desktop",
ctaLabel: "Conversar sobre meu aplicativo",
},
];
const featuredCases = caseStudies.filter((caseStudy) =>
["sc-plus", "nutriscan"].includes(caseStudy.id),
);
export const metadata: Metadata = {
title: pageTitle,
@@ -17,6 +56,7 @@ export const metadata: Metadata = {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
title: pageTitle,
description: pageDescription,
url: pageUrl,
@@ -56,89 +96,234 @@ export default function ServicesIndexPage() {
<main>
<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="/servicos"
>
Serviços
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Serviços de desenvolvimento</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Da presença na internet ao sistema que move seu negócio.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Sites e sistemas web, aplicativos mobile e aplicativos desktop
para Windows e Linux. Também integro ferramentas e melhoro
software que já está em uso. Começamos pelo que você precisa
resolver, não pela tecnologia.
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-3 text-sm">
<li>
<Link
className="inline-flex min-h-11 items-center underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
href="/"
>
Início
</Link>
</li>
<li className="flex items-center gap-sp-3">
<span aria-hidden="true">/</span>
<span aria-current="page">Serviços</span>
</li>
</ol>
</nav>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Sites, sistemas e aplicativos sob medida.
</h1>
<p className="ds-copy max-w-[62ch]">
Apresente seu negócio, organize a operação ou conecte ferramentas
que hoje dão trabalho. Desenvolvo a solução a partir do que você
precisa resolver, não de uma lista de tecnologias.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className="ds-link ds-link-accent min-h-12"
service="Sites, sistemas e aplicativos"
placement="service_hub_hero"
>
Falar com Evandro no WhatsApp
</WhatsAppLink>
<a
href="#projetos"
className="ds-link ds-link-muted min-h-12 focus-visible:outline-2 focus-visible:outline-offset-4"
>
Ver projetos realizados
</a>
</div>
<p className="ds-copy max-w-[62ch]">
Sou Evandro, desenvolvedor em Garuva, Santa Catarina. Atendimento
remoto para todo o Brasil, com conversa direta comigo.
</p>
</div>
</section>
<section className="ds-section" aria-labelledby="necessidade-title">
<div className="ds-section-shell grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-sp-16">
<div>
<h2 id="necessidade-title" className="ds-section-title">
O que você precisa fazer?
</h2>
<p className="ds-copy">
Não precisa chegar com um projeto técnico pronto. Começamos pela
sua rotina e pelo que precisa mudar.
</p>
</div>
<div className="divide-y divide-[var(--border)]">
{businessIntents.map((intent) => (
<article
key={intent.service}
className="py-sp-6 first:pt-0 last:pb-0"
>
<h3 className="ds-card-title">{intent.title}</h3>
<p className="ds-copy">{intent.description}</p>
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
<WhatsAppLink
className="ds-link ds-link-accent min-h-11"
service={intent.service}
placement="service_hub_intent"
>
{intent.ctaLabel}
</WhatsAppLink>
<Link
href={intent.href}
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
{intent.linkLabel}
</Link>
</div>
</article>
))}
</div>
</div>
</section>
<section
id="projetos"
className="ds-section"
aria-labelledby="projetos-title"
>
<div className="ds-section-shell">
<h2 id="projetos-title" className="ds-section-title">
Veja o trabalho antes de conversar.
</h2>
<p className="ds-copy max-w-[64ch]">
Dois projetos com demonstrações publicadas. Os cases mostram o que
foi implementado e as tecnologias usadas, sem promessas de resultado
comercial.
</p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
{featuredCases.map((caseStudy) => (
<article
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
<p className="mb-sp-3 text-sm text-[var(--meta)]">
Demonstração publicada
</p>
<h3 className="ds-card-title">{caseStudy.title}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<Link
href={`/cases/${caseStudy.id}`}
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
Ver o case {caseStudy.title}
</Link>
</article>
))}
</div>
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<div className="max-w-[72ch]">
<h2 className="ds-card-title !mt-0">O que você precisa fazer?</h2>
<p className="ds-copy">
Conheça as possibilidades abaixo. Cada serviço explica para quem
faz sentido, exemplos de uso, escopo e etapas de trabalho. Os
detalhes técnicos ficam disponíveis para aprofundar a conversa.
</p>
</div>
<div className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service, index) => (
<article
key={service.href}
className="grid gap-sp-4 py-sp-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-sp-8"
>
<div className="min-w-0">
<p className="ds-kicker" aria-hidden="true">
{String(index + 1).padStart(2, "0")}
</p>
<h3 className="ds-card-title">
<Link
href={service.href}
className="underline decoration-[var(--slash)] decoration-2 underline-offset-4 hover:decoration-[var(--ink)]"
>
{service.label}
</Link>
</h3>
</div>
<p className="ds-copy !mt-0">{service.description}</p>
</article>
))}
</div>
<div className="mt-sp-8 max-w-[72ch]">
<h2 className="ds-card-title">
Ainda não sabe qual caminho seguir?
<div className="ds-section-shell grid gap-sp-10 lg:grid-cols-2 lg:gap-sp-16">
<div>
<h2 className="ds-section-title">
Como saímos da conversa para a entrega.
</h2>
<p className="ds-copy">
Conte como funciona sua rotina e o que gostaria de mudar.
Definimos juntos a primeira entrega, as plataformas atendidas e os
limites do projeto antes de fechar a proposta.
</p>
<Link className="ds-link ds-link-accent mt-sp-5" href="/#contact">
Conversar sobre minha ideia
</Link>
<ol className="ds-list space-y-sp-4 list-decimal">
<li>
<strong>Entender o problema.</strong> Conversamos sobre o
negócio, quem vai usar e o que já existe.
</li>
<li>
<strong>Combinar o escopo.</strong> Definimos prioridades,
plataformas, responsabilidades, orçamento e prazo na proposta.
</li>
<li>
<strong>Construir e revisar.</strong> Você acompanha as etapas.
Validamos a entrega e combinamos publicação, suporte e evolução.
</li>
</ol>
</div>
<div>
<h2 className="ds-card-title">Antes de contratar</h2>
<dl className="mt-sp-5 space-y-sp-6 text-sm">
<div>
<dt className="font-semibold">
Quanto custa e quanto tempo leva?
</dt>
<dd className="ds-copy !mt-sp-2">
Depende das funcionalidades, conteúdo e integrações. O valor e
o prazo são definidos depois de entender os requisitos, antes
de iniciar o desenvolvimento.
</dd>
</div>
<div>
<dt className="font-semibold">
Preciso estar perto de Garuva?
</dt>
<dd className="ds-copy !mt-sp-2">
Não. O atendimento é remoto, com conversas, demonstrações e
revisões on-line para projetos em todo o Brasil.
</dd>
</div>
<div>
<dt className="font-semibold">
E se eu já tiver um site ou sistema?
</dt>
<dd className="ds-copy !mt-sp-2">
Avalio a base atual para propor melhorias, correções ou
integrações. Recomeçar do zero não é uma condição para
trabalhar comigo.
</dd>
</div>
</dl>
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="servicos-title">
<div className="ds-section-shell">
<h2 id="servicos-title" className="ds-section-title">
Quer aprofundar o escopo técnico?
</h2>
<p className="ds-copy max-w-[64ch]">
Consulte as áreas de atuação, incluindo desktop, integrações,
consultoria e IoT. Cada página explica possibilidades e limites.
</p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => (
<li
key={service.href}
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8"
>
<Link
href={service.href}
className="inline-flex min-h-11 items-center font-semibold underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
>
{service.label}
</Link>
<p className="ds-copy !mt-0">{service.description}</p>
</li>
))}
</ul>
</div>
</section>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<h2 className="ds-section-title max-w-[24ch]">
Conte o que você quer resolver.
</h2>
<p className="ds-copy max-w-[62ch]">
Pode ser uma ideia nova, uma rotina manual ou um sistema que precisa
melhorar. Me conte pelo WhatsApp; não precisa preencher um
formulário para começar.
</p>
<WhatsAppLink
className="ds-link ds-link-accent mt-sp-6 min-h-12"
service="Sites, sistemas e aplicativos"
placement="service_hub_footer"
>
Conversar com Evandro no WhatsApp
</WhatsAppLink>
</div>
</section>
</main>
+6
View File
@@ -0,0 +1,6 @@
import { ViewTransition } from "react";
/** O template remonta a cada navegação; o conteúdo leva o nome "page" e anima com page-out/page-in (globals.css). */
export default function Template({ children }: { children: React.ReactNode }) {
return <ViewTransition name="page">{children}</ViewTransition>;
}
+98
View File
@@ -0,0 +1,98 @@
# Prompts de logo para os projetos do portfólio
Um prompt por projeto listado no site (`public/portfolio-data.json`). Cada um parte do que o projeto realmente é e das cores que ele já usa nas capturas de tela do repositório (`public/assets/prints/`). Quando não existe captura pública (Twins Lab, Reddit Posts Explorer, MinGW GCC Installer), a paleta abaixo é **proposta**, não extraída.
## Antes de gerar
- **Marcas de clientes.** SEV Moda Íntima, SC Plus e AS Serviços têm identidade própria (a SEV e a AS Serviços já têm logo). Os prompts abaixo geram um **selo de portfólio** (miniatura do projeto nos cards e no favicon), não uma nova marca para o cliente. Se o cliente autorizar, use o logo oficial dele.
- **Texto em imagem.** Geradores erram letras. Todos os prompts pedem marca sem palavras (só monograma quando indicado). O nome do projeto entra depois, como texto no site.
- **Formato.** Gere 1:1, em 4 variações por prompt, escolha uma e vetorize (SVG). Exporte `512 px`, `192 px` e `32 px` para testar legibilidade.
- **Onde salvar.** `public/assets/projects/<id>.svg` (e `.png`), com o `id` do projeto: `sev-modaintima`, `nutriscan`, `twins-lab`, `reddit-explorer`, `sc-plus`, `gcc-installer`, `as-servicos`.
## Bloco de estilo comum (cole no início de cada prompt)
```
Minimal flat vector logo mark, geometric, strong silhouette, 2 to 3 flat colors, no gradients, no shadows, no 3D, no bevel, centered on a square 1:1 canvas with 12% padding, solid background, must stay recognizable at 32x32 pixels, clean even stroke weight, no text.
```
## Prompt negativo comum
```
text, words, letters (except the monogram when requested), photo, realistic, 3D render, drop shadow, glow, bevel, gradient mesh, watermark, mockup, busy details, thin hairlines, multiple logos, border frame, QR code
```
---
## 1. SEV Moda Íntima (`sev-modaintima`)
Loja virtual de moda íntima com vitrine cinematográfica e pedido pelo WhatsApp. Identidade observada: vinho/vermelho profundo, creme, serifa elegante de alto contraste.
```
[bloco de estilo comum]
Logo mark for a premium lingerie e-commerce brand. A monogram of the letters "S" and "V" in an elegant high-contrast serif, the two letters interlocked inside a soft arch that echoes the curve of lace trim. Palette: deep wine red #9B1C31 and warm cream #F6EFE6 on a #1A0F12 near-black background. Refined, feminine, confident, not cartoonish.
```
Variação clara: mesmo desenho em vinho sobre fundo creme.
## 2. NutriScan (`nutriscan`)
Consulta de produtos por nome ou código de barras, com cache JSON e Open Food Facts. Identidade observada: fundo creme, verde-floresta, serifa com itálico.
```
[bloco de estilo comum]
Logo mark for a food-data lookup app. A single leaf whose central vein is replaced by a short row of vertical barcode bars, framed by four small corner brackets like a scanner viewfinder. Palette: forest green #2E7D4F on warm cream #F7F3EA, dark ink #1F1B16 for the bars only. Friendly, trustworthy, calm, editorial.
```
## 3. Twins Lab (`twins-lab`)
Protótipo de gêmeo digital industrial: simulador de reator e transmissão de eventos pelo servidor (SSE) a cada dois segundos. Paleta **proposta**.
```
[bloco de estilo comum]
Logo mark for an industrial digital-twin prototype. Two identical cylindrical reactor vessels side by side, the right one drawn as a clean outline (the digital twin) and the left one solid, joined by a short pulse line (a small heartbeat zigzag) between them. Palette: graphite #0F1720 background, solid vessel in off-white #E8EEF2, twin outline and pulse in electric cyan #22D3EE. Technical, precise, calm.
```
## 4. Reddit Posts Explorer (`reddit-explorer`)
Coleta posts do r/artificial, grava no MongoDB e filtra por período, upvotes ou comentários. Paleta **proposta**; evite o mascote e o laranja exato do Reddit (marca registrada).
```
[bloco de estilo comum]
Logo mark for a tool that explores and filters social-media posts about artificial intelligence. Three overlapping rounded speech bubbles arranged like a stack, with a small filter funnel notch cut into the front bubble. Palette: deep indigo #2B2A6E and off-white #F4F2ED, one warm accent #FF7A45 on the funnel only. Do not use any alien or mascot character and do not imitate the Reddit logo. Smart, clear, neutral.
```
## 5. SC Plus (`sc-plus`)
Site institucional de educação, ciência e tecnologia em Garuva-SC (identidade observada: fundo escuro, verde vivo, sans geométrica).
```
[bloco de estilo comum]
Logo mark for a community education, science and technology organization focused on sustainability. A plus sign whose upper arm grows into a small sprout with two leaves, and whose center is a single round circuit node. Palette: vivid green #22C55E and white #FFFFFF on a near-black #0A0A0A background. Optimistic, modern, community-oriented.
```
## 6. MinGW GCC Installer (`gcc-installer`)
Instalador gráfico de MinGW-w64/GCC para Windows: lista builds, baixa, instala em `C:\mingw64` e ajusta o PATH. Paleta **proposta**; sem logotipo do Windows.
```
[bloco de estilo comum]
Logo mark for a developer-tool installer. An open package box with a downward arrow entering it, and a terminal prompt ">_" cut out of the front face of the box. Palette: slate #1E293B background, box in sky blue #38BDF8, arrow and prompt in white #FFFFFF. Do not use the Windows logo or any four-pane window shape. Practical, engineered, no-nonsense.
```
## 7. AS Serviços (`as-servicos`)
Site institucional em Next.js com cena 3D gerada em código (casa wireframe, instalação elétrica e placa-mãe; três disciplinas). Identidade observada: azul forte, camadas de prancha em roxo, azul e cobre.
```
[bloco de estilo comum]
Logo mark for a multidisciplinary engineering firm (building, electrical, computing). Three isometric layers stacked like architectural drawing sheets, drawn only with thin even outlines and small round node dots at the corners; the stack reads as a simple geometric peak. Palette: strong blue #0B5FD8 for the top layer, violet #7C4DFF for the middle layer, copper #B26A2B for the bottom layer, on white #FFFFFF. Blueprint feel, clean, technical, no letters.
```
---
## Checklist depois de gerar
- [ ] Conferir em 32 px e em fundo claro e escuro.
- [ ] Vetorizar e limpar nós (SVG).
- [ ] Salvar em `public/assets/projects/<id>.svg` e `.png`.
- [ ] Para marcas de clientes, confirmar autorização de uso antes de publicar.
+2
View File
@@ -4,6 +4,8 @@
Este é um runbook declarativo para um operador autorizado. Ele descreve evidências que deverão ser coletadas em contas e painéis externos; não registra execução, acesso, configuração, medição ou resultado externo. Search Console, Bing Webmaster Tools, Cloudflare, CrUX e RUM são próximas ações externas, não evidência do repositório.
Medições executadas e o auditor repetível estão em `docs/seo-geo-validation.md`.
## Regras de operação
- Executar cada ação somente com autorização e na propriedade correta.
+75
View File
@@ -0,0 +1,75 @@
# SEO, AEO e GEO: medição antes e depois (2026-10-05)
Este documento registra o que foi **medido**, com ferramenta e comando, e o que ainda **depende de conta externa**. Ele complementa `docs/seo-geo-operations.md` (runbook de Search Console, Bing, Cloudflare e CrUX, ainda não executado) e respeita as exclusões dele: sem `llms.txt`, sem `FAQPage`/`ProfessionalService`/`LocalBusiness`, sem páginas por cidade, sem `keywords`, sem datas ou métricas inventadas.
## Como repetir
```bash
# 1) auditoria (HTML sem JavaScript, como um robô vê); funciona em produção ou localhost
node scripts/seo-audit.mjs --base https://evandro.dev.br --out audit-depois.json
# 2) comparar duas execuções (antes.json vs depois.json); sai com código 2 se algo piorou
node scripts/seo-compare.mjs audit-antes.json audit-depois.json
```
A auditoria lê o `sitemap.xml` e confere, página a página: status, compressão, TTFB, `lang`, `title` e `description` (tamanho e unicidade), canônica própria, indexabilidade, um `h1` e hierarquia, `alt` das imagens, links internos e externos, Open Graph e Twitter Card (e se a imagem social abre), JSON-LD (válido, referências `@id` resolvem, sem os tipos excluídos pela política), resposta direta cedo no HTML, acesso de 8 robôs de busca e de IA (Googlebot, bingbot, OAI-SearchBot, GPTBot, ChatGPT-User, Claude-SearchBot, ClaudeBot, PerplexityBot), e-mail visível para robôs, links do GitHub e links internos quebrados. Cada verificação é passa/falha com a evidência; não existe "nota" inventada.
## Resultado: `main` contra a branch (build de produção local, 17 URLs)
| Grupo | `main` | branch |
|---|---:|---:|
| técnico | 57/57 | 57/57 |
| on-page | 204/221 | **213/221** |
| social (Open Graph e Twitter) | 35/51 | **51/51** |
| AEO/GEO | 54/60 | **56/60** |
| dados estruturados | 84/84 | 84/84 |
27 verificações corrigidas e 0 pioradas. O que mudou: `og:locale` em todas as rotas (só a home tinha), títulos e descrições mais curtos, e conteúdo textual novo em 2 cases.
Lighthouse (categorias SEO, acessibilidade e boas práticas, mobile): 100 / 97 / 100 nas duas versões. **O Lighthouse não distingue esse tipo de melhoria**, por isso a auditoria acima é a medida principal.
Validador independente: `structured-data-testing-tool` (npm), preset de redes sociais. Facebook/Open Graph 100% e Twitter 71–85%; o aviso restante é `twitter:site` ausente (opcional). A ferramenta **não** interpreta JSON-LD dentro de `@graph` (reporta "WebSite não encontrado" por limitação dela, não do site); para o JSON-LD, use os dois validadores manuais abaixo.
## O que foi alterado no código
- `openGraph.locale: "pt_BR"` nas 6 rotas que redefinem `openGraph`.
- Titles: casos passam de "Case de Engenharia de Software" para "Case de Engenharia"; `/curriculo`, Tauri e Engenharia de Software ficam mais curtos. Descrição da home de 193 para 146 caracteres e a de AS Serviços de 184 para 136.
- `Person` (JSON-LD) ganha `jobTitle` e `knowsAbout`, ambos já visíveis no site.
- Reddit Posts Explorer e MinGW GCC Installer ganham resposta, desafio, implementação, limites e fontes fixadas em commit (repositórios públicos); os textos anteriores diziam o que o código não sustenta (SSG, "rate limiting" por cache, busca em tempo real, download assíncrono, monitoramento).
- SC Plus: removida a alegação "otimizada para Core Web Vitals / LCP < 2,5 s". Medição local (Lighthouse mobile): LCP 3,7 s, desempenho 89.
- Hero e páginas: conteúdo visível já no HTML (LCP da home no Lighthouse local: 4,0 s para 3,5 s).
## Achados que o código não resolve (ações suas)
Ordenados pelo efeito provável na descoberta:
1. **Cloudflare ofusca o e-mail para robôs.** Confirmado em produção: o HTML servido ao Googlebot tem `/cdn-cgi/l/email-protection#...` e o texto "[email protected]", sem nenhum `mailto:`. Buscadores e respostas de IA não veem o contato. Segundo a [documentação da Cloudflare](https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/), desative em *Security > Settings > Email Address Obfuscation* (ou envolva um endereço com `<!--email_off-->`). A verificação `email-visivel-para-robos` da auditoria passa a ficar verde depois.
2. **Resultados de busca desatualizados.** Uma busca por "evandro.dev.br" ainda mostra o título e a descrição antigos ("Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica"). Peça nova indexação no Search Console (Inspeção de URL > Solicitar indexação) e envie a URL no Bing Webmaster Tools.
3. **Dois repositórios com "Ver código" quebrado.** `evandrodevbr/twins-lab` e `evandrodevbr/scplus` respondem 404 para visitantes (são privados), e o Twins Lab traz o selo "Código público". Torne os repositórios públicos ou remova o link e o selo. A auditoria acusa isso (`links-github-acessiveis`).
4. **Posicionamento e URLs diferentes entre perfis.** O site se apresenta como "sistemas sob medida para empresas"; o perfil do GitHub diz "Backend Software Engineer | Node.js, NestJS, TypeScript, Python & PostgreSQL", com empresa "Apolo Energia", e o README do perfil se declara aberto a vagas de backend (os "cinco anos" coincidem com o site). Respostas de IA misturam essas descrições. Escolha a frase central e repita-a nos perfis. O LinkedIn também aparece com duas URLs no site (`/in/evandro-fonseca-junior-0969aa1b0/` na hero e no JSON-LD, `/in/evandrodevbr/` no rodapé): use a mesma.
5. **Homônimos.** Há outras pessoas com nome parecido (por exemplo "Evandro Junior" no LinkedIn e "Evandro Fonseca Junior" em outros sites). `sameAs` e a repetição do mesmo texto de apresentação nos perfis ajudam a desambiguar.
6. **SC Plus.** O site no ar se apresenta como "SCPLUS — Educação, Ciência e Tecnologia em Garuva-SC", e o portfólio diz "SC Plus Aquicultura". Confirme o nome correto.
## Ferramentas que precisam da sua conta (não executadas)
| Ferramenta | Para quê | O que observar |
|---|---|---|
| [Google Search Console](https://search.google.com/search-console) | cobertura, canônica escolhida, consultas, cliques | as 17 URLs do sitemap; a canônica que o Google escolheu |
| [Bing Webmaster Tools](https://www.bing.com/webmasters) | indexação e relatório de IA (quando disponível) | AI Performance, se a conta tiver |
| [Rich Results Test](https://search.google.com/test/rich-results) | JSON-LD como o Google lê | `Person`, `WebSite`, `BreadcrumbList` sem erros |
| [Schema Markup Validator](https://validator.schema.org/) | JSON-LD contra schema.org | sem erros em `@graph` |
| [CrUX](https://developer.chrome.com/docs/crux) / PageSpeed Insights | desempenho de campo (LCP, INP, CLS) | só aparece com tráfego suficiente; sem dado de campo, não substitua por nota de laboratório |
## Fontes conferidas
- Google, [AI features and your website](https://developers.google.com/search/docs/appearance/ai-features): não há marcação nem arquivo especial para aparecer em AI Overviews/AI Mode; vale o SEO padrão (conteúdo em texto, links internos, robots.txt liberando, dados estruturados coerentes com o texto).
- Google, [FAQPage](https://developers.google.com/search/docs/appearance/structured-data/faqpage): resultado enriquecido só para sites governamentais e de saúde conhecidos. Por isso `FAQPage` continua excluído.
- OpenAI, [crawlers](https://developers.openai.com/api/docs/bots): `OAI-SearchBot` precisa estar liberado para aparecer nas buscas do ChatGPT; `GPTBot` é de treinamento; `ChatGPT-User` é iniciado pelo usuário. O `robots.txt` do site já libera todos (`User-agent: *`, `Allow: /`) e a auditoria confirma HTTP 200 para os 8 robôs.
- Anthropic, [crawlers](https://privacy.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler): `ClaudeBot` (treinamento), `Claude-User` e `Claude-SearchBot` (busca), cada um controlável no `robots.txt`.
- `llms.txt`: o Google informou que nenhum sistema de IA o usa hoje e que não planeja suportá-lo (declarações de John Mueller e Gary Illyes reportadas pela imprensa especializada). Continua excluído.
## Limites desta medição
- A auditoria vê o HTML entregue sem JavaScript; ela não prevê posição em buscas nem citações em respostas de IA. Esses resultados só aparecem com dados de plataforma (Search Console, Bing, relatórios de IA) ao longo de semanas.
- A busca usada para ver como o site aparece hoje é uma única consulta pontual; não é série histórica.
- Limiares como 30–65 caracteres no título e 70–165 na descrição são orientações de exibição, não regras de ranqueamento.
Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+76
View File
@@ -0,0 +1,76 @@
export const GA_MEASUREMENT_ID = "G-QTT9QT0T3S";
export const CONSENT_STORAGE_KEY = "evandro.analytics-consent.v1";
export const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
export type AnalyticsConsent = "granted" | "denied";
export type Gtag = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: IArguments[];
"ga-disable-G-QTT9QT0T3S"?: boolean;
gtag?: Gtag;
}
}
export function readConsent(): AnalyticsConsent | null {
try {
const value = localStorage.getItem(CONSENT_STORAGE_KEY);
return value === "granted" || value === "denied" ? value : null;
} catch {
return null;
}
}
function analyticsPagePath(): string {
const path = window.location.pathname.replace(/\/$/, "") || "/";
switch (path) {
case "/":
case "/servicos":
case "/cases":
case "/curriculo":
case "/privacidade":
return path;
default:
// Route templates keep arbitrary slugs and personal data out of metrics.
if (path.startsWith("/servicos/")) return "/servicos/[slug]";
if (path.startsWith("/cases/")) return "/cases/[slug]";
return "/other";
}
}
/** Measures an attempted handoff, not an opened app, delivered message or lead.
* Callers must supply static labels, never form values or destination URLs.
*/
export function trackWhatsAppOpen({
service,
placement,
}: {
service: string;
placement: string;
}): void {
if (
process.env.NODE_ENV !== "production" ||
typeof window === "undefined" ||
readConsent() !== "granted" ||
window[GA_DISABLE_KEY] === true ||
typeof window.gtag !== "function"
) {
return;
}
try {
const pagePath = analyticsPagePath();
window.gtag("event", "whatsapp_open", {
send_to: GA_MEASUREMENT_ID,
service,
placement,
page_path: pagePath,
// Override gtag's URL defaults so query/hash/referrer data cannot leak.
page_location: `https://evandro.dev.br${pagePath}`,
page_referrer: "",
});
} catch {
// Analytics must never interrupt navigation. Dropped events are not replayed.
}
}
+16
View File
@@ -1,3 +1,10 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const brandMark = `data:image/png;base64,${readFileSync(
join(process.cwd(), "public/brand/mark-dark.png"),
).toString("base64")}`;
export const OG_SIZE = { width: 1200, height: 630 } as const;
export const OG_CONTENT_TYPE = "image/png";
@@ -16,6 +23,7 @@ export function OgCard({
width: "100%",
height: "100%",
display: "flex",
position: "relative",
flexDirection: "column",
justifyContent: "space-between",
background: "#050505",
@@ -24,9 +32,17 @@ export function OgCard({
fontFamily: "Arial, sans-serif",
}}
>
<img
src={brandMark}
alt="Evandro.dev.br"
width={80}
height={80}
style={{ position: "absolute", top: 48, right: 72 }}
/>
<div
style={{
display: "flex",
paddingRight: 112,
color: "#ccff00",
fontSize: 24,
letterSpacing: "0.16em",
+21
View File
@@ -86,6 +86,27 @@ describe("factual portfolio records", () => {
/produç|clientes?|leads?|ranking|performance|resultado comercial|anime\.js|shadcn/i,
);
});
it("keeps Reddit Posts Explorer and MinGW GCC Installer within what their public repositories support", () => {
const reddit = serializeProject("reddit-explorer");
const gcc = serializeProject("gcc-installer");
expect(reddit).toContain("MongoDB");
expect(reddit).not.toMatch(
/SSG|rate limit|cache|tempo real|real-time|busca em|otimiza|responsiv/i,
);
expect(gcc).toContain("mingw64");
expect(gcc).not.toMatch(
/assíncron|monitoramento|drastic|Watchdog|dependências|intuitiv/i,
);
});
it("does not claim measured performance for SC Plus without a stored measurement", () => {
// Lighthouse mobile local: LCP 3,7 s em 2026-10-05; a alegação anterior (LCP < 2,5 s) não se sustentava
expect(serializeProject("sc-plus")).not.toMatch(
/LCP|Core Web Vitals|otimizad|velocidade|rápid|Performance/i,
);
});
it("does not publish the unavailable Twins Lab demo", () => {
expect(
portfolio.projects.find((item) => item.id === "twins-lab")?.demoUrl,
+12 -1
View File
@@ -37,7 +37,17 @@ it("keeps the root graph limited to verified website and person fields", () => {
["@id", "@type", "inLanguage", "name", "publisher", "url"].sort(),
);
expect(Object.keys(person).sort()).toEqual(
["@id", "@type", "homeLocation", "image", "name", "sameAs", "url"].sort(),
[
"@id",
"@type",
"homeLocation",
"image",
"jobTitle",
"knowsAbout",
"name",
"sameAs",
"url",
].sort(),
);
expect(website).toMatchObject({
"@type": "WebSite",
@@ -49,6 +59,7 @@ it("keeps the root graph limited to verified website and person fields", () => {
"@type": "Person",
"@id": SEO_IDS.person,
image: `${siteUrl}/edev.jpg`,
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" },
sameAs: [
"https://github.com/evandrodevbr",
+12
View File
@@ -24,6 +24,18 @@ export function buildPersonJsonLd() {
name: "Evandro Fonseca Junior",
url: siteUrl,
image: `${siteUrl}/edev.jpg`,
// cargo e áreas vêm de public/portfolio-data.json e estão visíveis em /curriculo e na seção de skills
jobTitle: "Engenheiro da Computação e Desenvolvedor de Software",
knowsAbout: [
"Next.js",
"React",
"TypeScript",
"Node.js",
"Python",
"IoT",
"Computação em nuvem",
"Inteligência artificial",
],
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
+28 -2
View File
@@ -1,6 +1,12 @@
import { caseStudies, getRelatedServices, servicePages } from "./site-content";
const detailedIds = ["as-servicos", "nutriscan", "twins-lab"] as const;
const detailedIds = [
"as-servicos",
"gcc-installer",
"nutriscan",
"reddit-explorer",
"twins-lab",
] as const;
const immutableGitHubBlobUrl =
/^https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[0-9a-f]{40}\/.+$/;
@@ -11,7 +17,7 @@ function getDetail(id: (typeof detailedIds)[number]) {
return detail;
}
it("limits detailed content to the three evidenced cases", () => {
it("limits detailed content to the evidenced cases (public repositories pinned to a commit)", () => {
expect(
caseStudies
.filter(({ detail }) => detail !== undefined)
@@ -64,6 +70,15 @@ it("requires sources for factual statements and resolves every source ID", () =>
expect(getDetail("nutriscan").boundaries[0]?.sourceIds).toContain(
"nutri-readme",
);
// reddit-explorer e gcc-installer: o 1º limite vem da documentação do repositório; o 2º é o escopo editorial (sem fonte)
for (const id of ["reddit-explorer", "gcc-installer"] as const) {
const { boundaries } = getDetail(id);
expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0);
expect(
boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0),
).toBe(true);
}
});
it("derives the exact related service set from the existing relationship", () => {
@@ -96,3 +111,14 @@ it("does not publish the unavailable Twins Lab demo", () => {
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
).toBeUndefined();
});
it("keeps AS Serviços as a project (code only) and states the author's participation", () => {
const asServicos = caseStudies.find(({ id }) => id === "as-servicos");
expect(asServicos?.demoUrl).toBeNull();
expect(asServicos?.githubUrl).toBe(
"https://github.com/evandrodevbr/as-servicos",
);
expect(asServicos?.role).toMatch(/primeira landing page/i);
expect(asServicos?.role).toMatch(/animações 3D/i);
expect(asServicos?.role).toMatch(/painel para controle de projetos/i);
});
+136 -22
View File
@@ -2,13 +2,13 @@ export const siteUrl = "https://evandro.dev.br";
export const siteName = "Evandro.dev.br";
export const intentCtas = {
hiring: {
label: "Avaliar perfil para contratação",
href: "/curriculo",
project: {
label: "Conversar sobre meu projeto",
href: "/#contact",
},
consulting: {
label: "Solicitar consultoria técnica",
href: "/#contact",
label: "Conhecer as soluções",
href: "/servicos",
},
} as const;
@@ -75,6 +75,8 @@ export interface CaseStudyContent {
stack: string[];
demoUrl?: string | null;
githubUrl?: string | null;
/** Papel do autor quando o projeto não é só dele (texto editorial, sem fonte pública). */
role?: string;
detail?: CaseStudyDetail;
}
@@ -144,7 +146,7 @@ export const servicePages: ServicePageContent[] = [
slug: "engenharia-de-software",
navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Engenharia de software",
seoTitle: "Desenvolvimento de Sistemas Web e Aplicativos Mobile",
seoTitle: "Sistemas Web e Aplicativos Mobile sob Medida",
metaDescription:
"Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.",
headline: "Sistemas e apps para o seu trabalho.",
@@ -266,7 +268,7 @@ export const servicePages: ServicePageContent[] = [
slug: "tauri-rust",
navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Aplicativos desktop",
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
seoTitle: "Aplicativos Desktop com Tauri e Rust",
metaDescription:
"Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.",
headline: "Aplicativos para Windows e Linux.",
@@ -618,22 +620,83 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "reddit-explorer",
title: "Reddit Posts Explorer",
subtitle: "Aplicação orientada a dados com API, cache e busca",
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
summary:
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
"Aplicação Astro SSR que coleta os posts em alta do r/artificial, grava no MongoDB sem duplicar e filtra por período, upvotes ou comentários.",
impact:
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
"Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
githubUrl: "https://github.com/evandrodevbr/reddit-search",
detail: {
answer: {
text: "Aplicação Astro em modo SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
sourceIds: ["rd-readme", "rd-service", "rd-astro"],
},
challenge: {
text: "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.",
sourceIds: ["rd-service", "rd-filter"],
},
implementation: [
{
text: "O serviço consulta r/artificial/hot.json com User-Agent identificado e limite de 15 segundos, e grava os posts em lote com upsert pelo id; rodar a coleta de novo atualiza os existentes.",
sourceIds: ["rd-service"],
},
{
text: "O endpoint de filtro valida as datas e o campo de ordenação (ups ou num_comments) e responde 400 quando faltam parâmetros ou eles são inválidos.",
sourceIds: ["rd-filter"],
},
{
text: "O Astro roda com output server e o adapter Node em modo standalone.",
sourceIds: ["rd-astro", "rd-package"],
},
],
boundaries: [
{
text: "O subreddit é fixo, e o projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
sourceIds: ["rd-readme"],
},
{
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, volume de dados ou resultado comercial.",
sourceIds: [],
},
],
sources: [
{
id: "rd-readme",
label: "README do projeto",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/README.md",
},
{
id: "rd-service",
label: "Serviço de coleta e consulta",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/lib/services/redditService.ts",
},
{
id: "rd-filter",
label: "Endpoint de filtro",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/pages/api/posts/filter.ts",
},
{
id: "rd-astro",
label: "Configuração do Astro",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/astro.config.mjs",
},
{
id: "rd-package",
label: "Manifesto do projeto",
url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/package.json",
},
],
},
},
{
id: "sc-plus",
title: "SC Plus Aquicultura",
subtitle: "Site institucional em Next.js com foco em performance e SEO",
subtitle: "Landing page institucional em Next.js 15",
summary:
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
"Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
impact:
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
"Dar à instituição uma página própria, com base técnica para ser encontrada em buscadores.",
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
demoUrl: "https://scplus.evandro.dev.br",
githubUrl: "https://github.com/evandrodevbr/scplus",
@@ -641,29 +704,80 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "gcc-installer",
title: "MinGW GCC Installer",
subtitle:
"Ferramenta desktop para automação de ambiente de desenvolvimento",
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
summary:
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
"Aplicativo Python com interface tkinter que lista as builds do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e adiciona o GCC ao PATH do usuário.",
impact:
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
"Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
stack: ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
detail: {
answer: {
text: "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
sourceIds: ["gcc-readme", "gcc-main"],
},
challenge: {
text: "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.",
sourceIds: ["gcc-readme"],
},
implementation: [
{
text: "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as builds com ucrt e thread model posix são destacadas na tabela.",
sourceIds: ["gcc-main"],
},
{
text: "A instalação extrai o pacote com o 7-Zip (caminho padrão fixo) em uma pasta temporária, move o resultado para C:\\mingw64 e renomeia mingw32-make.exe para make.exe.",
sourceIds: ["gcc-main"],
},
{
text: "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE; fora do Windows o programa encerra com um aviso.",
sourceIds: ["gcc-main"],
},
],
boundaries: [
{
text: "Funciona só no Windows, o caminho do 7-Zip é fixo e o fluxo da interface não foi exercitado de ponta a ponta no Windows nesta revisão; os 23 testes cobrem a lógica independente de plataforma, e não há CI.",
sourceIds: ["gcc-readme", "gcc-tests"],
},
{
text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, número de usuários ou resultado comercial.",
sourceIds: [],
},
],
sources: [
{
id: "gcc-readme",
label: "README do projeto",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/README.md",
},
{
id: "gcc-main",
label: "Aplicativo (main.py)",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/main.py",
},
{
id: "gcc-tests",
label: "Testes do núcleo",
url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/tests/test_core.py",
},
],
},
},
{
id: "as-servicos",
title: "AS Serviços",
subtitle: "Site institucional com cena 3D procedural",
summary:
"Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
impact:
"Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.",
stack: ["Next.js 16", "Three.js", "TypeScript"],
demoUrl: "https://asservicos.com",
demoUrl: null,
githubUrl: "https://github.com/evandrodevbr/as-servicos",
role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.",
detail: {
answer: {
text: "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
text: "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
sourceIds: ["as-readme", "as-package", "as-engine"],
},
challenge: {
+4 -5
View File
@@ -2,10 +2,9 @@ export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
export const NAVIGATION_ITEMS = [
{ href: "/#home", label: "Home" },
{ href: "/cases", label: "Casos" },
{ href: "/servicos", label: "Serviços" },
{ href: "/curriculo", label: "Currículo" },
{ href: "/#modalities", label: "Soluções" },
{ href: "/cases", label: "Projetos" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/#contact", label: "Contato" },
{ href: "/#experience", label: "Experiência" },
] as const;
+10
View File
@@ -13,6 +13,16 @@ const nextConfig: NextConfig = {
root: process.cwd(),
},
async redirects() {
return [
{
source: "/servicos/sob-medida",
destination: "/servicos/engenharia-de-software",
permanent: true,
},
];
},
async headers(): Promise<
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
> {
+8 -4
View File
@@ -11,14 +11,18 @@
"@tailwindcss/oxide",
"sharp",
"unrs-resolver"
]
],
"overrides": {
"brace-expansion@<1.1.21": "1.1.21",
"brace-expansion@>=2.0.0 <2.1.7": "2.1.7"
}
},
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "biome check",
"typecheck": "tsc --noEmit",
"typecheck": "next typegen && tsc --noEmit",
"format": "biome format --write",
"test": "jest",
"test:watch": "jest --watch",
@@ -28,11 +32,11 @@
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.5.0",
"clsx": "2.1.1",
"cobe": "0.6.4",
"gsap": "3.13.0",
"lenis": "^1.3.26",
"lucide-react": "0.542.0",
"motion": "12.23.12",
"next": "16.3.0",
"next": "16.3.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwind-merge": "3.3.1",
+78 -68
View File
@@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
brace-expansion@<1.1.21: 1.1.21
brace-expansion@>=2.0.0 <2.1.7: 2.1.7
importers:
.:
@@ -17,12 +21,12 @@ importers:
clsx:
specifier: 2.1.1
version: 2.1.1
cobe:
specifier: 0.6.4
version: 0.6.4
gsap:
specifier: 3.13.0
version: 3.13.0
lenis:
specifier: ^1.3.26
version: 1.3.26([email protected])
lucide-react:
specifier: 0.542.0
version: 0.542.0([email protected])
@@ -30,8 +34,8 @@ importers:
specifier: 12.23.12
version: 12.23.12([email protected]([email protected]))([email protected])
next:
specifier: 16.3.0
version: 16.3.0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
specifier: 16.3.8
version: 16.3.8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.1.0
version: 19.1.0
@@ -706,53 +710,53 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@next/[email protected].0':
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
'@next/[email protected].8':
resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==}
'@next/[email protected].0':
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
'@next/[email protected].8':
resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/[email protected].0':
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
'@next/[email protected].8':
resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/[email protected].0':
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
'@next/[email protected].8':
resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/[email protected].0':
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
'@next/[email protected].8':
resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/[email protected].0':
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
'@next/[email protected].8':
resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/[email protected].0':
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
'@next/[email protected].8':
resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/[email protected].0':
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
'@next/[email protected].8':
resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/[email protected].0':
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
'@next/[email protected].8':
resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -810,8 +814,8 @@ packages:
'@sinonjs/[email protected]':
resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==}
'@swc/[email protected].15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/[email protected].23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==}
@@ -1254,11 +1258,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
[email protected]8:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
[email protected].21:
resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==}
[email protected].4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
[email protected].7:
resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
[email protected]:
resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
@@ -1333,9 +1337,6 @@ packages:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
[email protected]:
resolution: {integrity: sha512-huuGFnDoXLy/tsCZYYa/H35BBRs9cxsS0XKJ3BXjRp699cQKuoEVrvKlAQMx0DKXG7+VUv4jsHVrS7yPbkLSkQ==}
[email protected]:
resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==}
@@ -1892,6 +1893,20 @@ packages:
engines: {node: '>=6'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
[email protected]:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -2083,8 +2098,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
[email protected].0:
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
[email protected].8:
resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -2171,9 +2186,6 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
[email protected]:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
[email protected]:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3425,30 +3437,30 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@next/[email protected].0': {}
'@next/[email protected].8': {}
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@next/[email protected].0':
'@next/[email protected].8':
optional: true
'@pkgjs/[email protected]':
@@ -3519,7 +3531,7 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@swc/[email protected].15':
'@swc/[email protected].23':
dependencies:
tslib: 2.8.1
@@ -3942,12 +3954,12 @@ snapshots:
dependencies:
require-from-string: 2.0.2
[email protected]8:
[email protected].21:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
[email protected].4:
[email protected].7:
dependencies:
balanced-match: 1.0.2
@@ -4011,10 +4023,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
phenomenon: 1.6.0
[email protected]: {}
[email protected]:
@@ -4775,6 +4783,10 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
react: 19.1.0
[email protected]: {}
[email protected]:
@@ -4879,11 +4891,11 @@ snapshots:
[email protected]:
dependencies:
brace-expansion: 1.1.18
brace-expansion: 1.1.21
[email protected]:
dependencies:
brace-expansion: 2.1.4
brace-expansion: 2.1.7
[email protected]: {}
@@ -4917,10 +4929,10 @@ snapshots:
[email protected]: {}
[email protected].0(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
[email protected].8(@babel/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies:
'@next/env': 16.3.0
'@swc/helpers': 0.5.15
'@next/env': 16.3.8
'@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.21
caniuse-lite: 1.0.30001810
postcss: 8.5.23
@@ -4928,14 +4940,14 @@ snapshots:
react-dom: 19.1.0([email protected])
styled-jsx: 5.1.6(@babel/[email protected])([email protected])
optionalDependencies:
'@next/swc-darwin-arm64': 16.3.0
'@next/swc-darwin-x64': 16.3.0
'@next/swc-linux-arm64-gnu': 16.3.0
'@next/swc-linux-arm64-musl': 16.3.0
'@next/swc-linux-x64-gnu': 16.3.0
'@next/swc-linux-x64-musl': 16.3.0
'@next/swc-win32-arm64-msvc': 16.3.0
'@next/swc-win32-x64-msvc': 16.3.0
'@next/swc-darwin-arm64': 16.3.8
'@next/swc-darwin-x64': 16.3.8
'@next/swc-linux-arm64-gnu': 16.3.8
'@next/swc-linux-arm64-musl': 16.3.8
'@next/swc-linux-x64-gnu': 16.3.8
'@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.8
sharp: 0.35.4(@types/[email protected])
transitivePeerDependencies:
- '@babel/core'
@@ -5000,8 +5012,6 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="210 220 830 800"><path fill="#f4f3ef" d="M245 757H321V681H399V606H476V757H399V878H523V757H654V913H594V982H245Z"/><g fill="#d5fa00"><path d="M511 512H667V688H511Z"/><path d="M667 358H821V512H667Z"/><path d="M844 255H947V358H844Z"/><path d="M906 432H1007V533H906Z"/></g></svg>

After

Width:  |  Height:  |  Size: 323 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="210 220 830 800"><path fill="#151d1c" d="M245 757H321V681H399V606H476V757H399V878H523V757H654V913H594V982H245Z"/><g fill="#d5fa00"><path d="M511 512H667V688H511Z"/><path d="M667 358H821V512H667Z"/><path d="M844 255H947V358H844Z"/><path d="M906 432H1007V533H906Z"/></g></svg>

After

Width:  |  Height:  |  Size: 323 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+30 -43
View File
@@ -152,31 +152,29 @@
{
"id": "reddit-explorer",
"title": "Reddit Posts Explorer",
"subtitle": "Análise de Conteúdo IA",
"description": "Aplicação Astro.js com arquitetura SSG otimizada para consumo de Reddit API. Resolve problema de rate limiting através de cache estratégico em MongoDB com Mongoose. Sistema de filtragem por data e ordenação, API REST própria e busca em tempo real com otimização de queries.",
"longDescription": "Aplicação web que consome a API do Reddit para buscar, filtrar e analisar posts relacionados à Inteligência Artificial, oferecendo interface intuitiva para descoberta de conteúdo relevante.",
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.",
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.",
"technologies": [
"Astro.js",
"Astro",
"MongoDB",
"Mongoose",
"TypeScript",
"TailwindCSS",
"Reddit API"
],
"features": [
"Integração com Reddit API",
"Sistema de cache com MongoDB",
"Filtragem por data e ordenação",
"Interface responsiva",
"API REST própria",
"Busca em tempo real"
"Coleta dos posts em alta do r/artificial",
"Upsert por id no MongoDB",
"Filtro por período",
"Ordenação por upvotes ou comentários",
"API própria com validação de parâmetros"
],
"category": "api",
"level": "pleno",
"status": "Concluído",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
"badges": ["API Integration", "MongoDB", "Real-time"],
"badges": ["API Integration", "MongoDB", "Código público"],
"featured": true,
"metrics": {
"complexity": 3,
@@ -186,9 +184,9 @@
{
"id": "sc-plus",
"title": "SC Plus Aquicultura",
"subtitle": "Landing Page Institucional",
"description": "Landing page Next.js 15 com React 19 e Tailwind CSS 4. Arquitetura otimizada para Core Web Vitals com LCP < 2.5s através de code splitting e server-side rendering. SEO técnico implementado com meta tags dinâmicas e structured data.",
"longDescription": "Site institucional responsivo desenvolvido com as mais recentes tecnologias web, otimizado para performance e SEO, apresentando os serviços e valores da empresa de aquicultura sustentável.",
"subtitle": "Landing page institucional em Next.js 15",
"description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
"technologies": [
"Next.js 15",
"React 19",
@@ -196,18 +194,16 @@
"TypeScript"
],
"features": [
"Design moderno e responsivo",
"Performance otimizada",
"SEO otimizado",
"Interface intuitiva",
"Carregamento rápido"
"Landing page institucional",
"Metadados e URL canônica",
"Dados estruturados"
],
"category": "web",
"level": "pleno",
"status": "Concluído",
"demoUrl": "https://scplus.evandro.dev.br",
"githubUrl": "https://github.com/evandrodevbr/scplus",
"badges": ["Responsive", "SEO", "Modern Stack"],
"badges": ["Landing page", "Next.js 15"],
"featured": true,
"metrics": {
"complexity": 2,
@@ -217,32 +213,23 @@
{
"id": "gcc-installer",
"title": "MinGW GCC Installer",
"subtitle": "Ferramenta de Desenvolvimento",
"description": "Aplicação desktop Python com Tkinter para automação de setup de ambiente GCC no Windows. Resolve problema de configuração manual complexa através de download assíncrono com threading, verificação de compatibilidade via Win32 API e integração automática com PATH do sistema. Utiliza 7-Zip para extração e Watchdog para monitoramento.",
"longDescription": "Ferramenta automatizada que simplifica drasticamente o processo de configuração de ambiente de desenvolvimento GCC no Windows, incluindo verificação de compatibilidade, download automático e integração com o sistema.",
"technologies": [
"Python",
"Tkinter",
"Requests",
"Watchdog",
"Win32 API",
"7-Zip",
"Threading"
],
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.",
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Funciona só no Windows e o caminho do 7-Zip é fixo.",
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
"features": [
"Interface gráfica intuitiva",
"Download assíncrono com progresso",
"Verificação de compatibilidade",
"Integração automática com PATH",
"Monitoramento em tempo real",
"Instalação automática de dependências"
"Lista as builds publicadas do MinGW-w64",
"Destaca builds ucrt e posix",
"Baixa e instala em C:\\mingw64",
"Adiciona o toolchain ao PATH do usuário",
"Testes do núcleo (23)"
],
"category": "desktop",
"level": "pleno",
"status": "Concluído",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
"badges": ["Desktop", "Automation", "System Integration"],
"badges": ["Desktop", "Windows", "Código público"],
"featured": true,
"metrics": {
"complexity": 4,
@@ -253,7 +240,7 @@
"id": "as-servicos",
"title": "AS Serviços",
"subtitle": "Site Institucional com Cena 3D Procedural",
"description": "Site institucional em Next.js 16 que representa quatro frentes de engenharia com uma cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.",
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.",
"technologies": ["Next.js 16", "TypeScript", "Three.js"],
"features": [
@@ -264,13 +251,13 @@
"category": "web",
"level": "senior",
"status": "Código público",
"demoUrl": "https://asservicos.com",
"demoUrl": null,
"githubUrl": "https://github.com/evandrodevbr/as-servicos",
"badges": ["Three.js", "WebGL", "Código público"],
"featured": true,
"metrics": {
"complexity": 5,
"teamSize": "Individual"
"teamSize": "Em equipe"
}
}
],
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more