Compare commits
No files matched your search
@@ -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 -->
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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");
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 668 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -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.
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -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>;
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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( |\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);
|
||||
});
|
||||
@@ -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);
|
||||