feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s

Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
This commit is contained in:
evandro committed 2026-11-10 03:48:25 -03:00
1 parent 4671837c44
commit c7d09d63d4
165 files changed
+7940 -3790

No files matched your search

+85 -45
View File
@@ -36,49 +36,76 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
});
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
expect(contactLink).toHaveAttribute("target", "_blank");
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(
contactLink.getAttribute("href") ?? "",
).searchParams.get("text");
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
// o botão primário é o único com o fundo limão; o secundário é contorno
expect(contactLink).toHaveAttribute("href", "/#contact-form");
expect(contactLink).not.toHaveAttribute("target");
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
expect(contactLink).toHaveClass("cv-fill");
expect(contactLink.className).not.toMatch(/hover:bg-/);
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).toHaveClass("cv-outline-dark");
expect(projects).not.toHaveAttribute("target");
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
expect(whatsApp.className).not.toContain("bg-[var(--slash)]");
expect(whatsApp.querySelector("svg")).toHaveAttribute(
"aria-hidden",
"true",
);
// a ordem do Tab: os dois botões e depois o link
expect(
contactLink.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
projects.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
);
});
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
render(<Hero />);
const heading = screen.getByRole("heading", { level: 1 });
const highlighted = Array.from(
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
)
.filter((word) => word.className.includes("text-[var(--slash)]"))
.map((word) => word.textContent);
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
heading.querySelectorAll<HTMLElement>(".hero-word"),
).filter((word) => word.className.includes("text-[var(--slash)]"));
expect(highlighted.map((word) => word.textContent)).toEqual([
"sob",
"medida",
]);
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
const marks = heading.querySelectorAll(".hero-mark");
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveClass("whitespace-nowrap");
expect(marks[0].textContent).toBe("sob medida");
for (const word of highlighted) expect(marks[0]).toContainElement(word);
});
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
@@ -106,11 +133,22 @@ describe("Hero", () => {
expect(
screen.getByText(
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
// o primeiro passo, logo abaixo dos botões
const step = screen.getByText(
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
);
expect(step.className).toContain("text-[15px]");
expect(
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
screen
.getByRole("link", { name: "Chamar no WhatsApp" })
.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
// o botão flutuante do WhatsApp observa este id: some enquanto o link do hero está à vista
expect(
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
});
@@ -136,27 +174,15 @@ describe("Hero", () => {
}
});
it("renders the work column with lazy posters that stay out of small screens", () => {
render(<Hero clients={clients} />);
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
const { container } = render(<Hero clients={clients} />);
const work = screen.getByTestId("hero-work");
expect(work).toHaveClass("hidden", "lg:block");
const images = work.querySelectorAll("img");
expect(images).toHaveLength(2);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
}
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
const original = (image: Element) =>
new URL(
image.getAttribute("src") ?? "",
"http://localhost",
).searchParams.get("url");
expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(images[0]).toHaveAccessibleName(
"Site da Lavação 047 aberto no computador",
);
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
const piece = container.querySelector(".hero-piece");
expect(piece).not.toBeNull();
expect(piece).toHaveAttribute("aria-hidden", "true");
const image = piece?.querySelector("img");
expect(image?.getAttribute("src")).toContain("hero-poster");
expect(image).toHaveAttribute("alt", "");
});
it("shows a wide card to the SEV case below the buttons on small screens", () => {
@@ -173,13 +199,27 @@ describe("Hero", () => {
});
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
render(<Hero />);
const { container } = render(<Hero />);
const label = screen.getByText(
/Evandro Fonseca Junior · Engenharia de software/,
const label = container.querySelector(".hero-kicker") as HTMLElement;
expect(label.textContent).toBe(
"Evandro Fonseca Junior · Engenharia de software",
);
expect(label.className).toContain("font-display");
expect(label.className).not.toContain("font-mono");
// sem caixa alta nem espaçamento largo
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
});
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
render(<Hero />);
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
"max-lg:hidden",
);
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
"hidden",
);
});
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {