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

@@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
/**
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
*/
@@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => {
for (const item of capabilities) {
expect(screen.getByText(item)).toBeVisible();
}
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(0);
});
@@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content";
/**
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
*/
export function CaseCapabilitiesSection({
caseStudy,
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
name: "Quer um projeto parecido com este?",
}),
).toBeVisible();
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(cta).toHaveAttribute("target", "_blank");
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
expect(messageOf(cta)).toContain(
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
// o rótulo do botão é o mesmo do case de cliente: uma lista só
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
+3 -3
View File
@@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices";
/**
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
* (e no botão do cabeçalho): `similarProjectSubject`.
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
* `similarProjectSubject`.
*/
const contactCopy = {
client: { title: "Quer um projeto parecido com este?" },
@@ -47,7 +47,7 @@ export function CaseContactSection({
placement="case_detail"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
+27 -2
View File
@@ -1,15 +1,23 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
* e o pôster como elemento compartilhado da troca de página.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
@@ -22,6 +30,23 @@ afterEach(() => {
});
describe("CaseCover", () => {
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
const transition = image.parentElement;
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(new Set(names).size).toBe(names.length);
});
it("path: no desktop capture -> renders nothing", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCover {...props} />);
+12 -8
View File
@@ -1,9 +1,11 @@
import Image from "next/image";
import { ViewTransition } from "react";
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
@@ -54,14 +56,16 @@ export function CaseCover({
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
<ViewTransition {...coverTransitionProps(id)}>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</ViewTransition>
</div>
</BrowserFrame>
);
+2 -2
View File
@@ -70,9 +70,9 @@ export function CaseHero({
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
{caseStudy.title}
{isClient ? "Case de cliente" : "Projeto de laboratório"}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
+1 -1
View File
@@ -43,7 +43,7 @@ export function CaseMediaSection({
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
+12 -3
View File
@@ -27,9 +27,18 @@ describe("CasePlayer", () => {
name: `Reproduzir o vídeo do site ${title} no computador`,
});
expect(button).toHaveAttribute("type", "button");
// só botão de contato é limão: o de play é escuro, com contorno branco
expect(button.innerHTML).not.toContain("--slash");
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
const circle = button.querySelector("[aria-hidden='true']");
const restClasses = (circle?.className ?? "")
.split(" ")
.filter((name) => !name.startsWith("group-"));
expect(restClasses.join(" ")).not.toContain("--slash");
expect(circle).toHaveClass(
"group-hover:scale-108",
"group-hover:bg-[var(--slash)]",
"group-focus-visible:scale-108",
"group-focus-visible:bg-[var(--slash)]",
);
});
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
+2 -1
View File
@@ -78,9 +78,10 @@ export function CasePlayer({
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] motion-reduce:transition-none sm:size-16"
>
<Play className="ml-1 size-6" fill="currentColor" />
</span>
+10
View File
@@ -0,0 +1,10 @@
/**
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
* CaseCover lê as capturas só no servidor.
*/
export function coverTransitionProps(id: string) {
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
}