Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:26:39 -03:00
Evandro Fonseca Junior 229280d519 fix(projects): reveal individual cards on small screens 2026-09-13 13:44:04 -03:00
Evandro Fonseca Junior 673f3c1843 fix(ci): generate route types before typechecking a fresh checkout 2026-09-13 04:37:45 -03:00
Evandro Fonseca Junior b181216a35 fix(nav): hand scroll restoration back to the browser after arrival 2026-09-13 04:35:00 -03:00
Evandro Fonseca Junior 9aff4d1035 feat: rebuild landing experience, content and analytics 2026-09-13 04:30:58 -03:00
Evandro Fonseca Junior d24ee12299 feat(brand): apply logos, icons and social sharing cards 2026-09-13 04:19:02 -03:00
49 changed files with 3538 additions and 14 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+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");
});
+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>
);
}
+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>
);
}
+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>
);
}
@@ -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",
);
});
});
@@ -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>
);
}
@@ -0,0 +1,378 @@
"use client";
import { type RefObject, useEffect } from "react";
type Transition = (
tl: gsap.core.Timeline,
prev: HTMLElement,
next: HTMLElement,
touch: boolean,
) => void;
/*
* Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição
* da rolagem (rolar para cima volta o "vídeo").
*
* Quem segura as seções paradas durante uma passagem:
* - a que SAI fica com position: sticky (topo = altura da tela − altura da seção): ela para quando o fim
* dela chega ao fim da tela. Quem mantém isso é o compositor do navegador, na mesma batida da rolagem;
* - a que ENTRA só fica parada no topo no desktop (deslocamento pelo GSAP). Com a roda do mouse, o Lenis
* rola e desloca no mesmo quadro. No toque, a rolagem nativa corre em outra thread e o deslocamento
* chegava um quadro atrasado: em rolagens rápidas a seção tremia. Por isso, no toque, ela sobe com o
* dedo e a passagem usa só efeitos que não disputam a posição (recorte, transparência, giro, escala).
*
* Regras que mantêm tudo estável:
* - cada tween termina até o tempo 1 da timeline (a trava de posição dura exatamente 1);
* - nenhum ancestral de um <canvas> recebe scale (o R3F mede o canvas com getBoundingClientRect);
* - cada cena só fica visível da passagem em que entra até a passagem em que sai (com folga), para não
* aparecer atrás de transparências e para o navegador não pintar o que está coberto.
* Os valores são funções para serem recalculados quando a janela muda de tamanho (invalidateOnRefresh).
*/
const vh = () => window.innerHeight;
// origem no meio da parte visível da seção que sai (a última altura de tela dela)
const visibleCenter = (el: HTMLElement) => () =>
`50% ${el.offsetHeight - vh() / 2}px`;
/** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */
function pageTop(el: HTMLElement) {
let top = 0;
for (
let node: HTMLElement | null = el;
node;
node = node.offsetParent as HTMLElement | null
)
top += node.offsetTop;
return top;
}
// a seção que entra fica parada no topo (só desktop, ver o comentário acima)
const holdNext = (tl: gsap.core.Timeline, next: HTMLElement) =>
tl.fromTo(next, { y: () => -vh() }, { y: 0 }, 0);
// uma linguagem diferente por passagem, na ordem das seções da home
const TRANSITIONS: Transition[] = [
// hero → projetos: o mergulho termina no escuro, o hero some e os projetos abrem como tela de cinema
(tl, prev, next, touch) => {
tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0);
if (touch) {
tl.fromTo(
next,
{
opacity: 0.3,
clipPath: "inset(0% 5% 0% 5% round 28px 28px 0px 0px)",
},
{ opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px 0px 0px 0px)" },
0,
);
return;
}
holdNext(tl, next);
const band = (open: number) => () =>
`inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`;
tl.fromTo(
next,
{ opacity: 0, clipPath: band(0.36) },
{ opacity: 1, clipPath: band(1), duration: 0.65 },
0.35,
);
},
// projetos → modalidades: uma íris se abre enquanto a anterior avança contra a câmera
(tl, prev, next, touch) => {
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 1.18, opacity: 0.2 },
0,
);
// no toque a seção sobe com o dedo: a íris nasce do topo dela, como uma bolha subindo
const center = () => (touch ? "at 50% 0px" : `at 50% ${vh() / 2}px`);
const radius = () =>
touch
? Math.hypot(window.innerWidth / 2, vh())
: Math.hypot(window.innerWidth / 2, vh() / 2);
if (!touch) holdNext(tl, next);
// power1.in: o círculo cresce devagar no começo, sem tomar a tela antes do fim da passagem
tl.fromTo(
next,
{ clipPath: () => `circle(0px ${center()})` },
{
clipPath: () => `circle(${radius()}px ${center()})`,
ease: "power1.in",
},
0,
);
},
// modalidades → IA: corte lateral, uma tela empurra a outra
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ xPercent: 0, opacity: 1 },
{ xPercent: -35, opacity: 0.3 },
0,
).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0);
},
// IA → qualidade: a seção sobe como um cartão sobre a anterior, que escurece.
// Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o
// desenho do diagrama a cada quadro dobravam o custo de pintura desta passagem. O diagrama se desenha
// uma vez, em CSS, quando a passagem começa (classe is-drawing, ver onEnter).
(tl, prev, next, touch) => {
tl.fromTo(prev, { opacity: 1 }, { opacity: 0.2 }, 0);
// no desktop ela sobe um pouco mais rápido que a rolagem; no toque, só com o dedo
if (!touch)
tl.fromTo(
next,
{ y: () => vh() * 0.35 },
{ y: 0, ease: "power2.out" },
0,
);
},
// qualidade → sobre: a página entra girando pela esquerda, como uma folha virando (só transform)
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ opacity: 1, xPercent: 0 },
{ opacity: 0.25, xPercent: 20 },
0,
).fromTo(
next,
{ xPercent: -100, rotation: -7, transformOrigin: "0% 0%" },
{ xPercent: 0, rotation: 0, ease: "power2.out" },
0,
);
},
// sobre → contato: o contato cresce do centro, como um zoom de câmera
(tl, prev, next, touch) => {
if (!touch) holdNext(tl, next);
tl.fromTo(
prev,
{ scale: 1, opacity: 1, transformOrigin: visibleCenter(prev) },
{ scale: 0.92, opacity: 0.2 },
0,
)
.fromTo(
next,
{
scale: touch ? 0.8 : 0.55,
clipPath: "inset(0% round 56px)",
transformOrigin: () => (touch ? "50% 0%" : `50% ${vh() / 2}px`),
},
{ scale: 1, clipPath: "inset(0% round 0px)", ease: "power2.out" },
0,
)
.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2 }, 0);
},
];
const easeInOutCubic = (t: number) =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export function useCinematicScroll(mainRef: RefObject<HTMLElement | null>) {
useEffect(() => {
const main = mainRef.current;
if (!main) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let cancelled = false;
let cleanup = () => {};
(async () => {
const [{ gsap }, { ScrollTrigger }, { default: Lenis }] =
await Promise.all([
import("gsap"),
import("gsap/ScrollTrigger"),
import("lenis"),
]);
if (cancelled) return;
gsap.registerPlugin(ScrollTrigger);
const lenis = new Lenis({ lerp: 0.075, wheelMultiplier: 0.85 });
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// toque: rolagem nativa em outra thread, sem trava de posição pelo JavaScript
const touch = window.matchMedia(
"(hover: none) and (pointer: coarse)",
).matches;
const scenes = Array.from(main.children) as HTMLElement[];
// Topo de cada cena somando as alturas das anteriores (as cenas são contíguas): com sticky, o
// offsetTop de uma cena presa passa a incluir o deslocamento dela.
const sceneTops = () => {
let top = pageTop(main);
return scenes.map((scene) => {
const current = top;
top += scene.offsetHeight;
return current;
});
};
const stick = () => {
for (const scene of scenes)
scene.style.top = `${Math.min(0, vh() - scene.offsetHeight)}px`;
};
const context = gsap.context(() => {
scenes.forEach((scene, index) => {
// quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte
gsap.set(scene, {
position: "sticky",
zIndex: index === 0 ? 30 : index + 1,
});
});
stick();
TRANSITIONS.forEach((transition, index) => {
const prev = scenes[index];
const next = scenes[index + 1];
if (!prev || !next) return;
const tl = gsap.timeline({
defaults: { ease: "none", duration: 1 },
scrollTrigger: {
trigger: next,
start: () => sceneTops()[index + 1] - vh(),
end: () => sceneTops()[index + 1],
scrub: true,
invalidateOnRefresh: true,
// passagem concluída: o recorte já cobre a tela inteira; sem ele a seção pinta normalmente
onLeave: () => gsap.set(next, { clipPath: "none" }),
// diagrama de qualidade: desenha uma vez quando a seção começa a entrar
onEnter: () => next.classList.add("is-drawing"),
},
});
transition(tl, prev, next, touch);
});
}, main);
// Visibilidade pela posição da rolagem: cada cena só existe na tela da passagem em que entra até a
// passagem em que sai. Folga para o quadro de atraso entre a rolagem nativa e este cálculo: um quarto
// de tela depois da saída e, no toque, meia tela antes da entrada (lá a cena sobe com o dedo; no
// desktop ela já nasce presa no topo e só pode aparecer quando a passagem começa).
let frame = 0;
const syncVisibility = () => {
frame = 0;
const y = window.scrollY;
const tops = sceneTops();
scenes.forEach((scene, index) => {
const from =
index === 0 ? -Infinity : tops[index] - vh() * (touch ? 1.5 : 1);
const to =
index === scenes.length - 1
? Infinity
: tops[index + 1] + vh() * 0.25;
const visibility = y >= from && y <= to ? "" : "hidden";
if (scene.style.visibility !== visibility)
scene.style.visibility = visibility;
});
};
const queueSync = () => {
if (!frame) frame = requestAnimationFrame(syncVisibility);
};
const onRefresh = () => {
stick();
queueSync();
};
syncVisibility();
ScrollTrigger.addEventListener("refresh", onRefresh);
window.addEventListener("scroll", queueSync, { passive: true });
// a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
const resizeObserver = new ResizeObserver(() => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150);
});
resizeObserver.observe(main);
// botões e links internos: em vez do salto, a página "avança o vídeo" até o destino
const onClick = (event: MouseEvent) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (
!(link instanceof HTMLAnchorElement) ||
(link.target && link.target !== "_self")
)
return;
const url = new URL(link.href);
if (
url.origin !== location.origin ||
url.pathname !== location.pathname ||
!url.hash
)
return;
let target: HTMLElement | null = null;
try {
target = document.getElementById(
decodeURIComponent(url.hash.slice(1)),
);
} catch {}
if (!target) return;
event.preventDefault();
// nunca para no meio de uma passagem: no mínimo onde termina a entrada da cena que contém o alvo;
// alvos internos descontam o cabeçalho fixo (posição relativa à cena, que não muda se ela estiver presa)
const index = scenes.findIndex((candidate) =>
candidate.contains(target),
);
const offset =
Number.parseFloat(
getComputedStyle(document.documentElement).scrollPaddingTop,
) || 0;
const sceneTop = index < 0 ? 0 : sceneTops()[index];
const top =
index <= 0
? 0
: Math.max(
sceneTop,
scenes[index] === target
? 0
: sceneTop +
pageTop(target) -
pageTop(scenes[index]) -
offset,
);
if (location.hash !== url.hash) history.pushState(null, "", url.hash);
lenis.scrollTo(top, {
duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000),
easing: easeInOutCubic,
// o foco vai depois: antes de a cena entrar ela está invisível e não aceita foco
onComplete: () => {
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
target.focus({ preventScroll: true });
},
});
};
document.addEventListener("click", onClick, true);
cleanup = () => {
document.removeEventListener("click", onClick, true);
ScrollTrigger.removeEventListener("refresh", onRefresh);
window.removeEventListener("scroll", queueSync);
cancelAnimationFrame(frame);
clearTimeout(refreshTimer);
resizeObserver.disconnect();
context.revert();
for (const scene of scenes) {
scene.style.visibility = "";
scene.style.top = "";
}
gsap.ticker.remove(tick);
lenis.destroy();
};
})();
return () => {
cancelled = true;
cleanup();
};
}, [mainRef]);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+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>;
}
+21 -14
View File
@@ -1,33 +1,40 @@
{
"checkedAt": "2026-07-11T05:31:16.931Z",
"checkedAt": "2026-08-31T19:15:15.134Z",
"projects": {
"sev-modaintima": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"checkedAt": "2026-08-31T19:15:15.134Z",
"finalUrl": "https://sevmodaintima.com.br/",
"latencyMs": 248
"latencyMs": 2768
},
"nutriscan": {
"status": null,
"contentType": null,
"checkedAt": "2026-07-11T05:31:16.931Z",
"latencyMs": 55,
"error": "fetch failed"
"status": 200,
"contentType": "text/html; charset=utf-8",
"checkedAt": "2026-08-31T19:15:15.134Z",
"finalUrl": "https://nutriscan.evandro.dev.br/",
"latencyMs": 2538
},
"twins-lab": {
"status": 404,
"contentType": "text/plain; charset=utf-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"checkedAt": "2026-08-31T19:15:15.134Z",
"finalUrl": "https://twins.evandro.dev.br/",
"latencyMs": 75
"latencyMs": 2541
},
"sc-plus": {
"status": 502,
"contentType": "text/html; charset=UTF-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"status": 200,
"contentType": "text/html; charset=utf-8",
"checkedAt": "2026-08-31T19:15:15.134Z",
"finalUrl": "https://scplus.evandro.dev.br/",
"latencyMs": 79
"latencyMs": 2576
},
"as-servicos": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"checkedAt": "2026-08-31T19:15:15.134Z",
"finalUrl": "https://asservicos.com/",
"latencyMs": 2592
}
}
}
+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.
+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.
+826
View File
@@ -0,0 +1,826 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@theme {
/* Literal values only: var() indirection here stops Tailwind v4 from generating *-sp-* utilities. */
--spacing-sp-1: 4px;
--spacing-sp-2: 8px;
--spacing-sp-3: 12px;
--spacing-sp-4: 16px;
--spacing-sp-5: 20px;
--spacing-sp-6: 24px;
--spacing-sp-8: 32px;
--spacing-sp-10: 40px;
--spacing-sp-12: 48px;
--spacing-sp-16: 64px;
--spacing-sp-20: 80px;
--spacing-sp-24: 96px;
}
:root {
--ink: #0a0a0a;
--ink-80: rgba(10, 10, 10, 0.8);
--ink-40: rgba(10, 10, 10, 0.4);
--ink-12: rgba(10, 10, 10, 0.12);
--ink-06: rgba(10, 10, 10, 0.06);
--paper: #f4f2ed;
--paper-dark: #eceae4;
--white: #ffffff;
--slash: #ccff00;
--slash-dim: rgba(204, 255, 0, 0.16);
--meta: #4a4a4a;
--border: rgba(10, 10, 10, 0.1);
--border-light: rgba(10, 10, 10, 0.06);
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sp-12: 48px;
--sp-16: 64px;
--sp-20: 80px;
--sp-24: 96px;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 16px;
--r-full: 999px;
--font-display: var(--font-space-grotesk), sans-serif;
--font-hero: var(--font-monument-extended), sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
}
/* Layered so Tailwind utilities (later layer) can override the zero-reset. */
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
html,
body {
width: 100%;
max-width: 100%;
overflow-x: hidden;
overscroll-behavior-x: none;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 68px;
}
html [id] {
scroll-margin-top: 68px;
}
.site-header-nav,
.site-header-cv,
.site-header-version {
display: none;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: flex;
}
.site-header-menu-toggle {
width: 32px;
height: 32px;
flex: 0 0 32px;
}
@media (min-width: 1025px) {
.site-header-nav {
display: flex;
}
.site-header-cv,
.site-header-version {
display: inline-flex;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: none;
}
}
.skip-link {
position: fixed;
top: 8px;
left: 16px;
z-index: 10000;
transform: translateY(-160%);
border: 1px solid var(--slash);
background: var(--ink);
color: var(--white);
padding: 10px 14px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
transition: transform 0.2s var(--ease-out);
}
.skip-link:focus {
transform: translateY(0);
outline: 2px solid var(--slash);
outline-offset: 3px;
}
:where(a, button, input, select, textarea):focus-visible {
outline: 2px solid var(--slash);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
.film-grain {
display: none;
}
}
body {
position: relative;
background: var(--paper);
color: var(--ink);
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.68;
}
img,
video,
canvas,
svg {
max-width: 100%;
}
@supports (overflow: clip) {
html,
body {
overflow-x: clip;
}
}
a {
color: inherit;
}
button,
input,
select,
textarea {
font: inherit;
}
::selection {
background: var(--slash);
color: var(--ink);
}
.bg-grid {
background-image:
linear-gradient(rgba(10, 10, 10, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(10, 10, 10, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
}
.bg-grid-light {
background-image:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 48px 48px;
}
.ds-main {
/* no top padding needed */
}
.ds-section {
padding: clamp(72px, 9vw, 112px) clamp(24px, 6vw, 64px);
border-bottom: 1px solid var(--border);
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
.ds-section:nth-of-type(even):not(.ds-section-dark) {
background: var(--paper-dark);
}
.ds-section-dark {
background: var(--ink);
border-bottom-color: rgba(255, 255, 255, 0.06);
color: var(--white);
}
.ds-section-shell,
.ds-loading,
.ds-error {
width: min(1240px, 100%);
margin: 0 auto;
}
.ds-section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--sp-6);
margin-bottom: var(--sp-10);
}
.ds-section-meta {
max-width: 760px;
}
.ds-eyebrow,
.ds-kicker {
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--meta);
}
.ds-eyebrow {
margin-bottom: var(--sp-2);
}
.ds-section-title {
font-family: var(--font-display);
font-size: clamp(24px, 4vw, 44px);
line-height: 1.02;
font-weight: 800;
letter-spacing: -0.035em;
}
.ds-section-description {
max-width: 58ch;
color: rgba(10, 10, 10, 0.74);
font-size: 14px;
line-height: 1.82;
}
.ds-grid-2,
.ds-grid-4 {
display: grid;
gap: var(--sp-6);
}
.ds-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ds-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ds-card,
.ds-panel {
border: 1px solid var(--border);
min-width: 0;
}
.ds-card {
background: rgba(255, 255, 255, 0.92);
padding: clamp(20px, 3vw, 28px);
display: flex;
flex-direction: column;
}
.ds-panel {
background: rgba(10, 10, 10, 0.03);
padding: var(--sp-5);
display: flex;
flex-direction: column;
}
.ds-card-title {
font-family: var(--font-display);
font-size: clamp(16px, 2.4vw, 30px);
line-height: 1.08;
letter-spacing: -0.025em;
margin-top: var(--sp-4);
}
.ds-copy {
margin-top: var(--sp-4);
color: var(--ink-80);
font-size: 14px;
line-height: 1.8;
flex-grow: 1;
}
.ds-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
list-style: none;
margin-top: auto;
padding-top: var(--sp-6);
}
.ds-tag {
padding: 8px 12px;
border: 1px solid rgba(10, 10, 10, 0.12);
background: rgba(10, 10, 10, 0.03);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: none;
font-weight: 500;
/* pílula inteira numa linha: a quebra acontece entre tags (.ds-tags wrap), nunca dentro dela */
white-space: nowrap;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.ds-tag:hover {
border-color: rgba(10, 10, 10, 0.3);
box-shadow: 0 2px 12px rgba(10, 10, 10, 0.1);
}
.ds-section-dark .ds-tag:hover {
border-color: rgba(204, 255, 0, 0.3);
box-shadow: 0 2px 16px rgba(204, 255, 0, 0.1);
}
.ds-metric {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
color: rgba(10, 10, 10, 0.65);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.ds-link {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
text-decoration: none;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
transition:
transform 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
}
.ds-link:hover {
transform: translateY(-1px);
}
.ds-link-accent {
color: var(--ink);
background: var(--slash);
padding: 12px 18px;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
background 0.25s ease;
}
.ds-link-accent:hover {
background: #ffffff;
box-shadow:
0 0 20px rgba(204, 255, 0, 0.45),
0 0 48px rgba(204, 255, 0, 0.18);
}
.ds-section-dark .ds-link-accent:hover {
box-shadow:
0 0 24px rgba(204, 255, 0, 0.55),
0 0 60px rgba(204, 255, 0, 0.22);
}
.ds-link-muted {
padding: 12px 18px;
border: 1px solid var(--border);
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.ds-link-muted:hover {
border-color: var(--ink-40);
box-shadow: 0 4px 20px rgba(10, 10, 10, 0.12);
}
.ds-section-dark .ds-link-muted:hover {
border-color: rgba(204, 255, 0, 0.35);
box-shadow: 0 4px 20px rgba(204, 255, 0, 0.08);
}
.ds-list {
margin-top: var(--sp-5);
padding-left: 18px;
font-size: 14px;
line-height: 1.85;
flex-grow: 1;
}
.ds-list li::marker {
color: var(--slash);
}
.ds-loading,
.ds-error {
padding: var(--sp-10) clamp(24px, 6vw, 64px);
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.ds-section-dark .ds-eyebrow,
.ds-section-dark .ds-kicker,
.ds-section-dark .ds-metric {
color: rgba(255, 255, 255, 0.65);
}
.ds-section-dark .ds-section-description {
color: rgba(255, 255, 255, 0.75);
}
.ds-section-dark .ds-card,
.ds-section-dark .ds-panel {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.ds-section-dark .ds-copy,
.ds-section-dark .ds-list {
color: rgba(255, 255, 255, 0.74);
}
.ds-section-dark .ds-tag {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
}
.ds-section-dark .ds-link-muted {
border-color: rgba(255, 255, 255, 0.12);
}
.ds-draw-line {
display: block;
width: 100%;
height: 1px;
margin-top: var(--sp-4);
background: linear-gradient(
90deg,
rgba(10, 10, 10, 0.38) 0%,
rgba(10, 10, 10, 0.06) 100%
);
}
.ds-section-dark .ds-draw-line {
background: linear-gradient(
90deg,
rgba(204, 255, 0, 0.5) 0%,
rgba(204, 255, 0, 0.08) 100%
);
}
@keyframes ds-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes marquee-reverse {
0% {
transform: translateX(-50%);
}
100% {
transform: translateX(0);
}
}
.animate-marquee {
animation: marquee 40s linear infinite;
}
.animate-marquee-reverse {
animation: marquee-reverse 45s linear infinite;
}
.ds-skeleton-card {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.ds-skeleton {
position: relative;
overflow: hidden;
border-radius: 4px;
background: #111;
}
.ds-skeleton::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, #111 0%, #1f1f1f 50%, #111 100%);
background-size: 220% 100%;
animation: ds-shimmer 1.4s linear infinite;
}
.ds-skeleton-kicker {
height: 10px;
width: 30%;
}
.ds-skeleton-title {
height: 26px;
width: 72%;
}
.ds-skeleton-line {
height: 12px;
width: 100%;
}
.ds-skeleton-line-short {
width: 62%;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
animation-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
animation-iteration-count: 1 !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component transition styles. */
transition-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override smooth scrolling. */
scroll-behavior: auto !important;
}
}
@media (max-width: 1024px) {
.site-header-nav,
.site-header-cv,
.site-header-version {
display: none;
}
.site-header-menu-toggle,
.site-header-mobile-panel {
display: flex;
}
.ds-section-header {
flex-direction: column;
gap: var(--sp-4);
}
.ds-grid-2,
.ds-grid-4 {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.ds-section {
padding: 72px 20px;
}
.ds-section-title {
font-size: clamp(24px, 8vw, 34px);
}
.ds-card-title {
font-size: clamp(16px, 5vw, 24px);
}
.ds-section-description,
.ds-copy,
.ds-list {
font-size: 13px;
line-height: 1.75;
}
.ds-tags {
gap: 8px;
padding-top: var(--sp-5);
}
.ds-tag {
font-size: 10px;
padding: 7px 10px;
}
}
/* ═══════════════════════════════════════════
CINEMATIC STYLES - Awwwards Level
═══════════════════════════════════════════ */
/* Cinematic text gradient - hero heading */
.cinematic-text-gradient {
color: #f4f2ed;
}
/* Solid surfaces avoid expensive backdrop blur during first paint. */
.cinematic-glass {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 10, 10, 0.78);
transition:
border-color 0.25s ease,
background 0.25s ease;
}
.cinematic-glass:hover {
border-color: rgba(204, 255, 0, 0.35);
background: rgba(10, 10, 10, 0.9);
}
/* Primary CTA - cinematic glow button */
.cinematic-cta-primary {
position: relative;
background: var(--slash);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-cta-primary::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
transform: translateX(-100%);
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-cta-primary:hover {
background: #ffffff;
box-shadow:
0 0 30px rgba(204, 255, 0, 0.4),
0 0 60px rgba(204, 255, 0, 0.15);
}
.cinematic-cta-primary:hover::before {
transform: translateX(100%);
}
/* Secondary CTA */
.cinematic-cta-secondary {
position: relative;
border: 1px solid rgba(244, 242, 237, 0.2);
background: rgba(10, 10, 10, 0.4);
backdrop-filter: blur(10px);
overflow: hidden;
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-cta-secondary::before {
content: "";
position: absolute;
inset: -1px;
background: linear-gradient(
135deg,
transparent 30%,
rgba(204, 255, 0, 0.3) 50%,
transparent 70%
);
background-size: 200% 200%;
opacity: 0;
transition: opacity 0.4s;
}
.cinematic-cta-secondary:hover {
border-color: var(--slash);
color: var(--slash);
box-shadow: 0 0 20px rgba(204, 255, 0, 0.1);
}
.cinematic-cta-secondary:hover::before {
opacity: 1;
animation: borderGlow 2s linear infinite;
}
@keyframes borderGlow {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 200%;
}
}
/* Social links */
.cinematic-social-link {
border: 1px solid rgba(244, 242, 237, 0.12);
background: rgba(10, 10, 10, 0.3);
color: rgba(244, 242, 237, 0.86);
backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-social-link:hover {
border-color: var(--slash);
background: rgba(204, 255, 0, 0.08);
color: var(--slash);
box-shadow: 0 0 20px rgba(204, 255, 0, 0.15);
}
/* Aside card */
.cinematic-aside-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(
180deg,
rgba(10, 10, 10, 0.6) 0%,
rgba(10, 10, 10, 0.3) 100%
);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.cinematic-aside-card:hover {
border-color: rgba(204, 255, 0, 0.12);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.4),
0 0 1px rgba(204, 255, 0, 0.2);
}
/* Section divider animations */
@keyframes shimmerDivider {
0% {
transform: translateX(-100%);
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes pulseDot {
0%,
100% {
transform: scale(1);
opacity: 0.3;
}
50% {
transform: scale(2);
opacity: 1;
}
}
/* Cinematic card hover effect */
.ds-card {
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.ds-card:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(10, 10, 10, 0.12);
}
.ds-section-dark .ds-card:hover {
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.4),
0 0 1px rgba(204, 255, 0, 0.1);
}
/* Cinematic section reveal transforms */
.cinematic-reveal {
will-change: transform, opacity;
}
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.
}
}
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

+8
View File
@@ -0,0 +1,8 @@
// O App Router usa a versão canary do React, que exporta <ViewTransition>; os tipos estáveis (@types/react 19.1)
// só trazem o nome provisório unstable_ViewTransition. Remover quando os tipos acompanharem.
import type { ExoticComponent, ViewTransitionProps } from "react";
import type {} from "react/experimental";
declare module "react" {
export const ViewTransition: ExoticComponent<ViewTransitionProps>;
}
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env node
/**
* Auditoria repetível de SEO / AEO / GEO, sem dependências (Node >= 20).
*
* node scripts/seo-audit.mjs --base http://localhost:3000 --out audit-antes.json
* node scripts/seo-compare.mjs audit-antes.json audit-depois.json
*
* Lê o sitemap, baixa cada URL como um robô (HTML sem JavaScript) e confere o que buscadores e sistemas de
* resposta/IA realmente enxergam. Não inventa nota: cada verificação é passa/falha com a evidência.
* O que depende de conta (Search Console, Bing, CrUX, citações em plataformas de IA) está em docs/seo-geo-operations.md.
*/
import { writeFileSync } from "node:fs";
const args = Object.fromEntries(
process.argv
.slice(2)
.map((a, i, all) => (a.startsWith("--") ? [a.slice(2), all[i + 1]] : null))
.filter(Boolean),
);
const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, "");
const SITE = "https://evandro.dev.br"; // origem canônica declarada no código
const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)";
const AI_BOTS = [
[
"Googlebot",
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
],
[
"bingbot",
"Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
],
[
"OAI-SearchBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot",
],
[
"GPTBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
],
[
"ChatGPT-User",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)",
],
[
"Claude-SearchBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; [email protected])",
],
[
"ClaudeBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; [email protected])",
],
[
"PerplexityBot",
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
],
];
const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção
const get = async (url, ua = UA, init = {}) => {
const t0 = performance.now();
const res = await fetch(url, {
headers: { "user-agent": ua, "accept-encoding": "gzip, br" },
redirect: "manual",
...init,
});
const body = init.method === "HEAD" ? "" : await res.text();
return { res, body, ms: Math.round(performance.now() - t0) };
};
const attrs = (tag) => {
const out = {};
for (const m of tag.matchAll(
/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
))
out[m[1].toLowerCase()] = m[2] ?? m[3] ?? "";
return out;
};
const decode = (s) =>
s
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#x27;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.trim();
const strip = (html) =>
decode(
html
.replace(/<(script|style|noscript|template|svg)[\s\S]*?<\/\1>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " "),
);
const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length;
function parsePage(html) {
const metas = [...html.matchAll(/<meta\b[^>]*>/gi)].map((m) => attrs(m[0]));
const meta = (key, val = "name") =>
metas.find((m) => m[val]?.toLowerCase() === key)?.content;
const links = [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => attrs(m[0]));
const headings = [
...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi),
].map((m) => ({ level: +m[1], text: strip(m[2]) }));
const imgs = [...html.matchAll(/<img\b[^>]*>/gi)].map((m) => attrs(m[0]));
const anchors = [...html.matchAll(/<a\b[^>]*>/gi)].map((m) => attrs(m[0]));
const ld = [
...html.matchAll(
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
),
].map((m) => m[1]);
const main = html.match(/<main\b[\s\S]*?<\/main>/i)?.[0] ?? html;
const firstH1 = main.search(/<h1\b/i);
const afterH1 = firstH1 >= 0 ? main.slice(firstH1) : main;
const firstP = [...afterH1.matchAll(/<p\b[^>]*>([\s\S]*?)<\/p>/gi)]
.map((m) => strip(m[1]))
.find((t) => words(t) >= 8);
return {
title: decode(html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""),
description: meta("description"),
robots: meta("robots"),
viewport: meta("viewport"),
lang: html.match(/<html[^>]*\blang=["']([^"']+)/i)?.[1],
canonical: links.find((l) => l.rel === "canonical")?.href,
alternates: links
.filter((l) => l.rel === "alternate" && l.hreflang)
.map((l) => l.hreflang),
og: Object.fromEntries(
metas
.filter((m) => m.property?.startsWith("og:"))
.map((m) => [m.property, m.content]),
),
tw: Object.fromEntries(
metas
.filter((m) => m.name?.startsWith("twitter:"))
.map((m) => [m.name, m.content]),
),
headings,
imgs,
anchors,
ld,
wordCount: words(strip(main)),
firstAnswerWords: firstP ? words(firstP) : 0,
};
}
function jsonLdFacts(blocks) {
const nodes = [];
const errors = [];
for (const raw of blocks) {
try {
const data = JSON.parse(raw);
const list = data["@graph"] ?? (Array.isArray(data) ? data : [data]);
nodes.push(...list);
} catch (e) {
errors.push(String(e.message).slice(0, 80));
}
}
const ids = new Set(nodes.map((n) => n["@id"]).filter(Boolean));
const types = nodes.flatMap((n) => [].concat(n["@type"] ?? []));
const dangling = [];
const walk = (o) => {
if (Array.isArray(o)) return o.forEach(walk);
if (o && typeof o === "object") {
const keys = Object.keys(o);
if (
keys.length === 1 &&
keys[0] === "@id" &&
!ids.has(o["@id"]) &&
!String(o["@id"]).startsWith(SITE)
)
dangling.push(o["@id"]);
Object.values(o).forEach(walk);
}
};
walk(nodes);
return { nodes, ids, types, errors, dangling };
}
const checks = []; // {scope, id, group, ok, detail}
const add = (scope, group, id, ok, detail = "") =>
checks.push({ scope, group, id, ok: Boolean(ok), detail });
async function auditUrl(url, siteTitles, siteDescs) {
const target = local(url);
const { res, body, ms } = await get(target);
const scope = url.replace(SITE, "") || "/";
add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`);
if (res.status !== 200) return null;
add(
scope,
"técnico",
"html-comprimido",
/br|gzip/.test(res.headers.get("content-encoding") ?? "") ||
BASE.startsWith("http://localhost"),
res.headers.get("content-encoding") ?? "sem compressão",
);
add(
scope,
"técnico",
"ttfb-menor-800ms",
ms < 800,
`${ms} ms (HTML completo)`,
);
const p = parsePage(body);
add(
scope,
"on-page",
"lang-pt-BR",
p.lang?.toLowerCase() === "pt-br",
p.lang ?? "ausente",
);
add(scope, "on-page", "viewport", Boolean(p.viewport));
add(
scope,
"on-page",
"title-30-65",
p.title.length >= 30 && p.title.length <= 65,
`${p.title.length} car.: ${p.title}`,
);
add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title);
siteTitles.add(p.title);
add(
scope,
"on-page",
"description-70-165",
!!p.description &&
p.description.length >= 70 &&
p.description.length <= 165,
`${p.description?.length ?? 0} car.`,
);
add(
scope,
"on-page",
"description-unica",
!!p.description && !siteDescs.has(p.description),
);
if (p.description) siteDescs.add(p.description);
add(
scope,
"on-page",
"canonical-propria",
p.canonical === url || p.canonical === `${url}/`,
p.canonical ?? "ausente",
);
add(
scope,
"on-page",
"indexavel",
!/noindex/i.test(p.robots ?? "") &&
!/noindex/i.test(res.headers.get("x-robots-tag") ?? ""),
);
const h1 = p.headings.filter((h) => h.level === 1);
add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`);
let skipped = false;
p.headings.reduce((prev, h) => {
if (h.level - prev > 1) skipped = true;
return h.level;
}, 0);
add(scope, "on-page", "hierarquia-de-titulos", !skipped);
add(
scope,
"on-page",
"imagens-com-alt",
p.imgs.every((i) => i.alt !== undefined),
`${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`,
);
const internal = p.anchors.filter(
(a) =>
a.href &&
(a.href.startsWith("/") || a.href.startsWith(SITE)) &&
!a.href.startsWith("//"),
);
add(
scope,
"on-page",
"links-internos-5+",
new Set(internal.map((a) => a.href.split("#")[0])).size >= 5,
`${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`,
);
const external = p.anchors.filter((a) => a.target === "_blank");
// noreferrer também implica noopener
add(
scope,
"on-page",
"externos-com-noopener",
external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")),
`${external.length} links _blank; sem rel: ${external
.filter((a) => !/noopener|noreferrer/.test(a.rel ?? ""))
.map((a) => a.href)
.slice(0, 2)
.join(" ")}`,
);
const og = p.og,
tw = p.tw;
const missingOg = [
"og:title",
"og:description",
"og:url",
"og:image",
"og:type",
"og:site_name",
"og:locale",
].filter((k) => !og[k]);
add(
scope,
"social",
"og-completo",
missingOg.length === 0,
missingOg.length ? `faltam ${missingOg.join(",")}` : "",
);
add(
scope,
"social",
"twitter-card",
tw["twitter:card"] === "summary_large_image" &&
!!tw["twitter:title"] &&
!!tw["twitter:image"],
);
if (og["og:image"]) {
const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(
() => null,
);
add(
scope,
"social",
"og-image-acessivel",
img?.res.status === 200 &&
/^image\//.test(img.res.headers.get("content-type") ?? ""),
`${img?.res.status} ${img?.res.headers.get("content-type")}`,
);
}
const ld = jsonLdFacts(p.ld);
add(
scope,
"dados-estruturados",
"json-ld-valido",
p.ld.length > 0 && ld.errors.length === 0,
ld.errors.join("; ") || `${p.ld.length} blocos`,
);
add(
scope,
"dados-estruturados",
"referencias-resolvem",
ld.dangling.length === 0,
ld.dangling.join(", "),
);
add(
scope,
"dados-estruturados",
"sem-faqpage-nem-localbusiness",
!ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)),
ld.types.join(","),
);
if (scope !== "/")
add(
scope,
"dados-estruturados",
"breadcrumb",
ld.types.includes("BreadcrumbList"),
);
add(
scope,
"dados-estruturados",
"person-com-sameas",
!ld.types.includes("Person") ||
ld.nodes.some(
(n) =>
n["@type"] === "Person" &&
Array.isArray(n.sameAs) &&
n.sameAs.length >= 2,
),
);
add(
scope,
"aeo-geo",
"conteudo-no-html-sem-js",
p.wordCount >= 250,
`${p.wordCount} palavras no <main> do HTML`,
);
add(
scope,
"aeo-geo",
"resposta-direta-cedo",
p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70,
`${p.firstAnswerWords} palavras no 1º parágrafo após o H1`,
);
const names = new Set(
ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name),
);
add(
scope,
"aeo-geo",
"entidade-autor-consistente",
names.size <= 1 && (scope === "/" ? names.size === 1 : true),
[...names].join(","),
);
return { scope, p, ld };
}
async function main() {
const sm = await get(`${BASE}/sitemap.xml`);
const urls = [...sm.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
add(
"site",
"técnico",
"sitemap-200",
sm.res.status === 200 && urls.length > 0,
`${urls.length} URLs`,
);
const robots = await get(`${BASE}/robots.txt`);
add(
"site",
"técnico",
"robots-com-sitemap",
/sitemap:\s*https?:\/\//i.test(robots.body),
);
add(
"site",
"técnico",
"robots-nao-bloqueia-tudo",
!/disallow:\s*\/\s*$/im.test(robots.body),
);
add("site", "técnico", "http-para-https-ou-local", true);
for (const [name, ua] of AI_BOTS) {
const r = await get(`${BASE}/`, ua);
add(
"site",
"aeo-geo",
`acesso-${name}`,
r.res.status === 200,
`HTTP ${r.res.status}`,
);
}
// A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato
const botHome = await get(`${BASE}/`, AI_BOTS[0][1]);
add(
"site",
"aeo-geo",
"email-visivel-para-robos",
!/email-protection|\[email(&#160;|\s)protected\]/.test(botHome.body),
/email-protection/.test(botHome.body)
? "HTML servido a robôs tem e-mail ofuscado"
: "",
);
const llms = await get(`${BASE}/llms.txt`);
// informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado
add(
"site",
"aeo-geo",
"llms-txt-ausente-por-politica",
llms.res.status === 404,
`HTTP ${llms.res.status}`,
);
const titles = new Set();
const descs = new Set();
const pages = [];
for (const url of urls) pages.push(await auditUrl(url, titles, descs));
// links internos quebrados (amostra: todos os destinos únicos das páginas auditadas)
const targets = new Set();
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors) {
const h = a.href?.split("#")[0];
if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h);
}
let broken = 0;
const brokenList = [];
for (const t of targets) {
const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(
() => null,
);
if (!r || r.res.status >= 400) {
broken++;
brokenList.push(`${t} (${r?.res.status ?? "erro"})`);
}
}
add(
"site",
"técnico",
"sem-links-internos-quebrados",
broken === 0,
`${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`,
);
const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, "")));
const orphanCandidates = [...inSitemap].filter((u) => {
const path = u.replace(SITE, "") || "/";
return (
path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path)
);
});
add(
"site",
"técnico",
"sem-paginas-orfas-no-sitemap",
orphanCandidates.length === 0,
orphanCandidates.join(", "),
);
// links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado)
const ghLinks = new Set();
for (const pg of pages.filter(Boolean))
for (const a of pg.p.anchors)
if (
/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")
)
ghLinks.add(a.href.replace(/\/$/, ""));
const ghBroken = [];
for (const url of ghLinks) {
const r = await get(url, UA, { method: "HEAD" }).catch(() => null);
if (!r || r.res.status === 404)
ghBroken.push(url.replace("https://github.com/evandrodevbr/", ""));
}
add(
"site",
"técnico",
"links-github-acessiveis",
ghBroken.length === 0,
ghBroken.length
? `404 para visitantes: ${ghBroken.join(", ")}`
: `${ghLinks.size} repositórios`,
);
const groups = {};
for (const c of checks) {
groups[c.group] ??= { ok: 0, total: 0 };
const g = groups[c.group];
g.total++;
if (c.ok) g.ok++;
}
const out = {
base: BASE,
at: new Date().toISOString(),
urls: urls.length,
groups,
checks,
};
if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2));
console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`);
for (const [g, v] of Object.entries(groups))
console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`);
const failing = checks.filter((c) => !c.ok);
console.log(`\nFalhas (${failing.length}):`);
const byId = {};
for (const f of failing) {
byId[f.id] ??= [];
byId[f.id].push(f);
}
for (const [id, list] of Object.entries(byId))
console.log(
` - ${id} (${list.length}): ${list
.slice(0, 4)
.map(
(f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`,
)
.join(" | ")}`,
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
/** Compara duas execuções de scripts/seo-audit.mjs: node scripts/seo-compare.mjs antes.json depois.json */
import { readFileSync } from "node:fs";
const [beforePath, afterPath] = process.argv.slice(2);
if (!beforePath || !afterPath) {
console.error("uso: node scripts/seo-compare.mjs antes.json depois.json");
process.exit(1);
}
const before = JSON.parse(readFileSync(beforePath, "utf8"));
const after = JSON.parse(readFileSync(afterPath, "utf8"));
const key = (c) => `${c.scope}::${c.id}`;
const prev = new Map(before.checks.map((c) => [key(c), c]));
const next = new Map(after.checks.map((c) => [key(c), c]));
console.log(
`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`,
);
console.log("grupo antes depois");
for (const g of new Set([
...Object.keys(before.groups),
...Object.keys(after.groups),
])) {
const a = before.groups[g] ?? { ok: 0, total: 0 };
const b = after.groups[g] ?? { ok: 0, total: 0 };
console.log(
`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`,
);
}
const fixed = [];
const regressed = [];
for (const [k, c] of next) {
const p = prev.get(k);
if (p && !p.ok && c.ok) fixed.push(c);
if (p?.ok && !c.ok) regressed.push(c);
if (!p && !c.ok)
regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` });
}
console.log(`\nCorrigidos (${fixed.length}):`);
for (const c of fixed) console.log(` + ${c.scope} ${c.id}`);
console.log(`\nPiorou (${regressed.length}):`);
for (const c of regressed)
console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`);
process.exit(regressed.length ? 2 : 0);