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

+29 -2
View File
@@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit
expect(none.container).toBeEmptyDOMElement();
});
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11");
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
}
});
@@ -107,3 +107,30 @@ it("keeps the years number in the display font (the mono font is not downloaded
expect(screen.getByText("5").className).not.toContain("font-mono");
});
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
render(
<ClientStrip
clients={[
{
id: "sev-modaintima",
name: "SEV Moda Íntima",
sector: "loja virtual",
},
{ id: "lavacao-047", name: "Lavação 047" },
]}
/>,
);
const sector = screen.getByText("loja virtual");
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
expect(link).not.toContainElement(sector);
expect(link.parentElement).toContainElement(sector);
expect(link.parentElement).toHaveClass("flex-col");
// o nome continua levando ao case
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
const plain = screen.getByRole("link", { name: "Lavação 047" });
expect(plain.parentElement?.childElementCount).toBe(1);
});
+28 -11
View File
@@ -1,11 +1,19 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
export type ClientLink = { id: string; name: string };
export type ClientLink = {
id: string;
name: string;
/** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */
sector?: string;
};
/**
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
* De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso,
* só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a
* altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham
* pela linha dos nomes (`sm:items-start`).
*
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
@@ -30,12 +38,19 @@ export default function ClientStrip({
...clients.map((client) => ({
key: client.id,
node: (
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</HoverPrefetchLink>
<span className="flex flex-col">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center self-start font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] sm:min-h-6"
>
{client.name}
</HoverPrefetchLink>
{client.sector && (
<span className="text-[13px] leading-[18px] max-sm:hidden">
{client.sector}
</span>
)}
</span>
),
})),
...(years
@@ -57,14 +72,16 @@ export default function ClientStrip({
return (
<div
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] sm:items-start ${className}`.trim()}
>
{clients.length > 0 && <p>Já entregue para:</p>}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
{/* de 640 px em diante a lista fica ao lado do rótulo enquanto sobram 24rem, e quebra por dentro; menos que isso,
ela desce para baixo do rótulo e usa a largura toda */}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1 sm:min-w-[min(24rem,100%)] sm:flex-1 sm:basis-0 sm:items-start sm:gap-y-sp-2">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
className={`flex items-center pr-4 sm:items-start${index > 0 ? " -ml-4" : ""}`}
>
{index > 0 && (
<span
+15 -1
View File
@@ -24,7 +24,7 @@ afterEach(() => {
});
describe("Contact: opções do tipo de projeto", () => {
it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
it("lists the five offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option");
@@ -32,10 +32,12 @@ describe("Contact: opções do tipo de projeto", () => {
"Ainda preciso definir",
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
@@ -61,7 +63,9 @@ describe("Contact: tipo escolhido pela oferta", () => {
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
@@ -156,7 +160,9 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])(
"after the offer %s the form says 'Você escolheu: <name>'",
(kind, name) => {
@@ -201,6 +207,14 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
expect(directMessage()).toContain(
"Quero conversar sobre a revisão de um sistema que já existe.",
);
choose("whatsapp");
expect(directMessage()).toContain(
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
);
choose("fiscal");
expect(directMessage()).toContain(
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
);
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
await user.selectOptions(
subjectSelect(),
+7 -7
View File
@@ -265,11 +265,11 @@ export default function Contact() {
service={directSubject}
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-[15px] text-white/65">
@@ -277,7 +277,7 @@ export default function Contact() {
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
aria-hidden="true"
/>
</WhatsAppLink>
@@ -310,7 +310,7 @@ export default function Contact() {
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
@@ -412,7 +412,7 @@ export default function Contact() {
</Field>
<button
type="submit"
className="cv-btn flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
className="cv-btn cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
@@ -444,9 +444,9 @@ export default function Contact() {
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Conversar no WhatsApp
Chamar no WhatsApp
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
+1 -1
View File
@@ -42,7 +42,7 @@ export function Faq({
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
className="ds-faq-item group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useSyncExternalStore } from "react";
import { textLink } from "./button-styles";
import { isFiscalNoticeActive } from "./fiscal-notice";
import HoverPrefetchLink from "./HoverPrefetchLink";
/** A data não muda enquanto a página está aberta: nada a observar. */
const subscribe = () => () => {};
/**
* Faixa datada da nota fiscal, acima dos cartões da home. Quem a coloca na página (WorkModalities) só a renderiza dentro
* do prazo; aqui o navegador confere a data de novo, porque a home é gerada no build: depois do prazo ela some sem um
* build novo. Na hidratação vale o valor do servidor (visível), e só depois a data do navegador: sem aviso de hidratação.
*/
export function FiscalNotice({ href }: Readonly<{ href: string }>) {
const active = useSyncExternalStore(
subscribe,
() => isFiscalNoticeActive(new Date()),
() => true,
);
if (!active) return null;
return (
<div className="mb-sp-6 flex flex-col gap-sp-1 border-y border-[var(--border)] py-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
Seu sistema emite nota fiscal?
</strong>{" "}
Os optantes do Simples Nacional entram na reforma tributária em 1º de
janeiro de 2027.
</p>
<HoverPrefetchLink
href={href}
className={`${textLink} shrink-0 self-start text-[var(--ink-80)]`}
>
Ver o que muda
</HoverPrefetchLink>
</div>
);
}
+10 -5
View File
@@ -49,7 +49,7 @@ class FakeObserver {
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
resetNavigation();
@@ -221,7 +221,7 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
it("path: the bar marks <html> while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
@@ -237,12 +237,16 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// o botão flutuante usa a variável publicada pela barra para subir
// a barra publica a altura (o menu do celular a usa) e marca o <html>; o botão flutuante some por CSS com a marca
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
);
await user.click(refuse);
@@ -250,6 +254,7 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
@@ -35,7 +35,7 @@ class FakeObserver {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
const messageOf = (link: HTMLElement | null) =>
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
+5 -16
View File
@@ -52,7 +52,7 @@ function setVisibility(hero: boolean, contact: boolean) {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
global.IntersectionObserver =
@@ -112,25 +112,14 @@ it("shows right away on a page that has neither the hero button nor the contact
expect(floatingLink()).toBeInTheDocument();
});
it("lifts itself by the height of the open cookie bar", () => {
render(<Page />);
setVisibility(false, false);
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
});
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
it("hides by CSS while the cookie bar is open (the data-cookie-bar mark on <html>), instead of sitting on top of it", () => {
render(<Page />);
setVisibility(false, false);
const className = floatingLink()?.parentElement?.className ?? "";
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
expect(className).toContain("transition-transform");
expect(className).toContain("motion-reduce:transition-none");
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
expect(className).not.toContain("transition-[bottom]");
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
// não sobe mais pela altura da barra: some enquanto ela está aberta
expect(className).not.toContain("--cookie-bar-height");
expect(className).toContain(
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
);
+7 -7
View File
@@ -6,16 +6,16 @@ import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
/** Link "Chamar no WhatsApp" do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact";
/**
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
*/
export default function FloatingWhatsApp({
contexts,
@@ -61,15 +61,15 @@ export default function FloatingWhatsApp({
return (
<aside
aria-label="Contato rápido"
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
className="ds-enter-pop fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] md:hidden [[data-cookie-bar=open]_&]:hidden"
>
<WhatsAppLink
placement="floating"
service={subject}
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25d366] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[#54e58b] motion-reduce:transition-none"
className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--whatsapp)] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[var(--whatsapp-hover)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Conversar no WhatsApp</span>
<span className="sr-only">Chamar no WhatsApp</span>
</WhatsAppLink>
</aside>
);
+10 -8
View File
@@ -39,20 +39,22 @@ describe("Footer contact and navigation", () => {
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("offers WhatsApp in the contact column, in a new tab", () => {
it("offers WhatsApp in the contact column, in a new tab, named like every control that opens it", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
const whatsApp = within(contact).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");
});
it("lists the six services from the services hub as same-tab links", () => {
it("lists the eight services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
expect(serviceHubLinks).toHaveLength(6);
expect(serviceHubLinks).toHaveLength(8);
const services = screen.getByRole("navigation", { name: "Serviços" });
const links = within(services).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
@@ -64,7 +66,7 @@ describe("Footer contact and navigation", () => {
for (const link of links) expect(link).not.toHaveAttribute("target");
});
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
it("links Cases, the /ia page by its menu label, Privacidade and a discreet Currículo, with no PDF download", () => {
render(<Footer />);
const more = screen.getByRole("navigation", { name: "Mais" });
@@ -74,7 +76,7 @@ describe("Footer contact and navigation", () => {
);
expect(
within(more).getByRole("link", {
name: "Revisão de sistema que já existe",
name: "Revisão de app com IA",
}),
).toHaveAttribute("href", "/ia");
expect(
@@ -145,11 +147,11 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
it("names /ia like the menu and the page, and no old 'app feito com IA' name", () => {
render(<Footer />);
expect(
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
screen.getByRole("link", { name: "Revisão de app com IA" }),
).toHaveAttribute("href", "/ia");
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
});
+4 -4
View File
@@ -5,9 +5,9 @@ import {
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import { offerLabels } from "@/lib/offer-labels";
import {
CONTACT_EMAIL,
IA_PAGE_LABEL,
INSTAGRAM_URL,
LINKEDIN_URL,
type ServiceLink,
@@ -18,7 +18,7 @@ import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const headingClassName =
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
"font-mono text-[13px] font-medium tracking-[0.02em] text-[var(--meta)]";
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
const linkClassName =
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
@@ -48,7 +48,7 @@ export default function Footer({
placement="footer"
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
>
WhatsApp
Chamar no WhatsApp
</WhatsAppLink>
</li>
<li>
@@ -118,7 +118,7 @@ export default function Footer({
</li>
<li>
<HoverPrefetchLink href="/ia" className={linkClassName}>
{offerLabels.revisao.name}
{IA_PAGE_LABEL}
</HoverPrefetchLink>
</li>
<li>
+1 -1
View File
@@ -60,7 +60,7 @@ describe("GoogleAnalytics consent", () => {
).toBeVisible();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Usamos métricas de navegação do Google só se você permitir\./,
/Métricas de navegação do Google, só se você permitir\./,
);
expect(
screen.queryByTestId("google-analytics-script"),
+8 -6
View File
@@ -231,16 +231,18 @@ function GoogleAnalyticsConsent() {
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
className="fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] sm:px-sp-6"
className="ds-enter fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] [--enter-y:16px] sm:px-sp-6"
>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
>
Usamos métricas de navegação do Google só se você permitir.{" "}
Métricas de navegação do Google, só se você permitir.{" "}
{/* o preenchimento vertical dá ao link 24 px de alvo de toque sem engordar a linha */}
<a
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
className="whitespace-nowrap py-1 underline underline-offset-2 hover:text-white hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
@@ -257,14 +259,14 @@ function GoogleAnalyticsConsent() {
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar
+20 -8
View File
@@ -69,7 +69,13 @@ describe("Header", () => {
within(navigation)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
).toEqual([
"Cases",
"Serviços",
"Como trabalho",
"Sobre",
"Revisão de app com IA",
]);
// "Projetos" saiu: duplicava "Cases"
expect(
within(navigation).queryByRole("link", { name: "Projetos" }),
@@ -179,11 +185,15 @@ describe("Header", () => {
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
const links = within(mobileNavigation).getAllByRole("link");
for (const link of links.slice(0, 5)) {
expect(link).toHaveClass("text-[22px]", "min-h-14");
// sob o ponteiro e no foco, a barra que cresce sob o texto (globals.css)
expect(link).toHaveClass("text-[22px]", "min-h-14", "cv-bar");
}
const contact = links[5];
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
expect(contact.parentElement).toHaveClass("mt-auto");
// o botão verde ganha o bloco cor de papel, não só uma troca de cor
expect(contact).toHaveClass("cv-fill");
expect(contact.className).not.toMatch(/hover:bg-/);
});
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
@@ -245,9 +255,13 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
// "Falar sobre meu projeto" é só do formulário: o botão que abre o WhatsApp tem outro nome
expect(
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(sentence);
},
@@ -258,7 +272,7 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
@@ -274,7 +288,7 @@ describe("Header", () => {
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const buttons = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) {
@@ -295,9 +309,7 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
expect(
whatsAppHref(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
),
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
).toContain("Quero conversar sobre um projeto.");
});
});
+10 -7
View File
@@ -15,6 +15,8 @@ import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
const WHATSAPP_LABEL = "Chamar no WhatsApp";
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
const INERT_IDS = ["main-content", "site-footer"];
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
@@ -55,7 +57,8 @@ function trapTab(event: KeyboardEvent, header: HTMLElement) {
/**
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
* já com o assunto da página, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
* ("Falar sobre meu projeto").
*/
function ContactCta({
className,
@@ -78,7 +81,7 @@ function ContactCta({
placement={placement}
onClick={onNavigate}
>
{CTA_LABEL}
{WHATSAPP_LABEL}
</WhatsAppLink>
);
}
@@ -218,7 +221,7 @@ export function Header({
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
className="cv-bar inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline [--bar-x:var(--sp-3)] hover:text-white"
>
{item.label}
</HoverPrefetchLink>
@@ -230,7 +233,7 @@ export function Header({
<div className="flex shrink-0 items-center">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
className="cv-fill inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
@@ -262,7 +265,7 @@ export function Header({
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
<div className="ds-enter absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] [--enter-y:-8px] lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-1 flex-col">
<nav
id={MENU_ID}
@@ -274,7 +277,7 @@ export function Header({
<li key={item.href} className="border-b border-white/10">
<HoverPrefetchLink
href={item.href}
className="flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline transition-colors hover:text-white"
className="cv-bar flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline [--bar-y:100%] hover:text-white"
onClick={closeMobileMenu}
>
{item.label}
@@ -284,7 +287,7 @@ export function Header({
</ul>
<div className="mt-auto pt-sp-8">
<ContactCta
className="flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
className="cv-fill flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
+38 -17
View File
@@ -3,7 +3,7 @@ import Hero from "./Hero";
/**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
* links sociais (externo x mailto, com rastreio) e faixa de clientes.
*/
jest.mock("./cinematic/HeroField", () => ({
@@ -107,25 +107,46 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
expect(screen.getByTestId("hero-work")).toHaveTextContent(
"lavacao.example.com",
);
});
it("path: workUrl omitted -> the browser bar shows no address", () => {
render(<Hero />);
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
});
it("path: the main button carries the id that hides the floating WhatsApp", () => {
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).not.toHaveAttribute("id");
});
});
describe("Hero: contato", () => {
it("path: 'Chamar no WhatsApp' -> a whatsapp_open from the hero, with the home subject", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
placement: "hero",
service: "um site ou sistema para a minha empresa",
}),
);
});
it("path: 'Falar sobre meu projeto' -> the form, with no WhatsApp event", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: /Falar sobre meu projeto/ });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(link).toHaveAttribute("href", "/#contact-form");
expect(window.gtag).not.toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.anything(),
);
});
});
+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)", () => {
+121 -45
View File
@@ -1,16 +1,20 @@
"use client";
import Image from "next/image";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import { textLinkDark } from "./button-styles";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { CONTACT_FORM_HASH } from "./contact-subject";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
import HeroWork from "./HeroWork";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
const SOCIAL_LINKS = [
{
@@ -33,28 +37,58 @@ export type HeroStats = {
years?: number;
};
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
const ACCENT = new Set([5, 6, 7]);
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
const ACCENT = new Set([3, 4]);
type TitleWord = { word: string; key: string; index: number };
/**
* Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. As palavras
* do destaque seguidas viram um trecho só: elas nunca se separam numa quebra de linha, e o bloco do marcador as cobre
* de uma vez.
*/
const TITLE =
"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."
.split(" ")
.map((word, index) => ({
word,
// a mesma palavra se repete ("e"), então a posição entra na chave
key: `${word}-${index}`,
accent: ACCENT.has(index),
}));
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
(runs, word, index) => {
const accent = ACCENT.has(index);
// a mesma palavra se repete ("e"), então a posição entra na chave
const entry = { word, key: `${word}-${index}`, index };
const last = runs.at(-1);
if (accent && last?.accent) last.words.push(entry);
else runs.push({ key: entry.key, accent, words: [entry] });
return runs;
},
[],
);
function TitleWords({
words,
accent,
}: {
words: TitleWord[];
accent: boolean;
}) {
return words.map(({ word, key, index }, position) => (
<Fragment key={key}>
{position > 0 && " "}
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>
</Fragment>
));
}
export default function Hero({
stats,
clients,
workUrl,
}: {
stats?: HeroStats;
clients?: ClientLink[];
/** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
workUrl?: HeroWorkUrl;
}) {
return (
<section
@@ -63,8 +97,9 @@ export default function Hero({
>
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<HeroField />
<div className="hero-scrim" aria-hidden="true" />
{/* a cena 3D fica acima do véu (o mesmo brilho do pôster) e abaixo das legendas e do texto */}
<HeroField />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
@@ -81,38 +116,48 @@ export default function Hero({
</ol>
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)]">
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
{/* Abaixo de 1024 px o rótulo divide a linha com a peça: fica só a profissão, e o nome volta na tela larga. */}
<p className="hero-in hero-kicker mb-sp-4 flex items-center gap-sp-3 font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
/>
<span>
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
Engenharia de software
</span>
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
<h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
{TITLE.map(({ word, key, accent }, index) => (
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
<h1 className="hero-title hero-col max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em]">
{TITLE.map(({ key, accent, words }) => (
<Fragment key={key}>
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>{" "}
{accent ? (
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
<span
className="hero-mark whitespace-nowrap"
style={{ "--i": words[0].index } as CSSProperties}
>
<TitleWords words={words} accent />
</span>
) : (
<TitleWords words={words} accent={false} />
)}{" "}
</Fragment>
))}
</h1>
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
<p className="hero-in hero-col mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.4s]">
Do site institucional ao sistema que a sua equipe usa todo dia. Você
fala com quem desenvolve.
</p>
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
<div className="hero-in hero-col hero-actions mt-sp-5 flex flex-col gap-sp-3 [--d:.48s] sm:flex-row sm:flex-wrap sm:items-center">
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
<HoverPrefetchLink
href={`/${CONTACT_FORM_HASH}`}
className="cv-fill group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline sm:px-sp-6 sm:text-[16px]"
>
Falar sobre meu projeto
<svg
@@ -125,28 +170,59 @@ export default function Hero({
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</WhatsAppLink>
</HoverPrefetchLink>
<HoverPrefetchLink
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
className="cv-outline-dark inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline sm:text-[16px]"
>
Ver projetos entregues
</HoverPrefetchLink>
{/* Abre o WhatsApp com o assunto da home; enquanto está à vista, o botão flutuante (que observa este id) some. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className={`${textLinkDark} gap-sp-2 self-start sm:self-auto`}
>
<WhatsAppMark className="h-4 w-4 shrink-0" />
Chamar no WhatsApp
</WhatsAppLink>
</div>
{/* O primeiro passo, dito antes de a pessoa clicar: tira a dúvida de "e depois, o que acontece?". */}
<p className="hero-in hero-col mt-sp-3 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] [--d:.54s]">
Começa com uma conversa para entender o seu caso. A proposta vem por
escrito.
</p>
<ClientStrip
clients={clients}
years={stats?.years}
className="hero-in mt-sp-3 [--d:.75s]"
className="hero-in hero-col mt-sp-3 [--d:.6s]"
/>
<HeroWork url={workUrl} />
<HeroWork />
{/* A marca em blocos: pôster renderizado no Blender, decorativo. Carrega junto com a página (eager, sem prioridade
alta). O título segue como o maior elemento medido (LCP) no celular e em tela larga: a imagem entra com
fade a partir de opacidade zero e some por `visibility` quando a cena assume (globals.css). */}
<div className="hero-piece hero-in [--d:.3s]" aria-hidden="true">
<Image
src="/3d/hero-poster.webp"
alt=""
width={1200}
height={1200}
sizes="(min-width: 1024px) min(56vw, 816px), 30vw"
loading="eager"
className="block h-auto w-full"
/>
</div>
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo; no
computador ficam numa linha só, para o palco fixo caber em 1024x768 */}
<div className="flex-wrap items-center gap-x-sp-8 max-lg:hidden lg:flex">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
@@ -185,7 +261,7 @@ export default function Hero({
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-display text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
+9 -53
View File
@@ -1,9 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import HeroWork from "./HeroWork";
/**
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
* O pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
* LCP), no cartão largo e clicável da SEV para as telas abaixo de 1024 px. Em tela larga a coluna é da peça 3D.
*/
const original = (image: Element) =>
@@ -12,23 +12,9 @@ const original = (image: Element) =>
);
describe("HeroWork: pôsteres no tamanho certo", () => {
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
it("serves the SEV poster through the image optimizer, with a `sizes` that matches how it shows", () => {
render(<HeroWork />);
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
expect(desktop).toHaveLength(2);
expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 500px, 36vw",
);
expect(desktop[1]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 136px, 10vw",
);
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
const small = screen
@@ -55,23 +41,11 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
render(<HeroWork />);
const [browser, phone] = within(
screen.getByTestId("hero-work"),
).getAllByRole("img");
expect(browser).toHaveAttribute("width", "1440");
expect(browser).toHaveAttribute("height", "900");
expect(phone).toHaveAttribute("width", "390");
expect(phone).toHaveAttribute("height", "844");
});
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
const { container } = render(<HeroWork />);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(3);
expect(images).toHaveLength(1);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("fetchpriority", "low");
@@ -82,13 +56,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
});
describe("HeroWork: cartão do celular", () => {
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
render(<HeroWork />);
const column = screen.getByTestId("hero-work");
expect(column).toHaveClass("hidden", "lg:block");
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
const { container } = render(<HeroWork />);
const card = screen.getByTestId("hero-work-mobile");
expect(container.firstChild).toBe(card);
expect(container.childElementCount).toBe(1);
expect(card).toHaveClass("lg:hidden", "w-full");
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
expect(card.querySelector(".ds-phone")).toBeNull();
@@ -119,21 +92,4 @@ describe("HeroWork: cartão do celular", () => {
expect(screen.getAllByRole("link")).toHaveLength(1);
});
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
render(<HeroWork />);
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
expect(
within(screen.getByTestId("hero-work")).queryByRole("link"),
).toBeNull();
});
it("passes the address to the browser bar of the desktop frame", () => {
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
expect(
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
).toBeInTheDocument();
});
});
+44 -93
View File
@@ -1,16 +1,8 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "lavacao-047";
const DESKTOP_PROJECT_TITLE = "Lavação 047";
const MOBILE_PROJECT = "as-servicos";
/** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
/** O cartão mostra o site da SEV e leva ao case. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
@@ -28,95 +20,54 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
/**
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
* Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
* fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
* Trabalho real do hero, só abaixo de 1024 px: um cartão largo e clicável (a largura da coluna, cerca de 350 px num
* celular) com o pôster do site da SEV, abaixo dos botões, que leva ao case da SEV; antes era uma moldura de celular
* de 65 por 160 px, pequena demais para ver o que era. Em telas largas a coluna da direita é da peça 3D, e o trabalho
* real começa na seção Projetos.
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
*/
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
export default function HeroWork() {
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return (
<>
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
<HoverPrefetchLink
data-testid="hero-work-mobile"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<BrowserFrame url={url} className="w-[88%]">
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
<Image
src={desktop.poster}
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
width={desktop.width}
height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw"
loading="lazy"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
>
<Image
src={phone.poster}
alt="Site da AS Serviços aberto no celular"
width={phone.width}
height={phone.height}
sizes="(min-width: 1400px) 136px, 10vw"
loading="lazy"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
</div>
<HoverPrefetchLink
data-testid="hero-work-mobile"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
</>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { Logo } from "./Logo";
describe("Logo", () => {
it("shows the text cutout with the same alt text and a decorative inline mark", () => {
const { container } = render(<Logo />);
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-dark.webp");
expect(text).toHaveAttribute("width", "1600");
expect(text).toHaveAttribute("height", "330");
const mark = container.querySelector("svg");
expect(mark).toHaveAttribute("aria-hidden", "true");
expect(mark?.querySelectorAll(".logo-bloco")).toHaveLength(4);
expect(screen.getAllByRole("img")).toHaveLength(1);
});
it("keeps the caller's className and loading", () => {
const { container } = render(
<Logo theme="light" loading="eager" className="w-40 sm:w-44" />,
);
expect(container.firstElementChild).toHaveClass("w-40", "sm:w-44");
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-light.webp");
expect(text).toHaveAttribute("loading", "eager");
expect(container.querySelector("svg path")).toHaveAttribute(
"fill",
"#00090b",
);
});
});
+46 -9
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import "./logo.css";
interface LogoProps {
theme?: "dark" | "light";
@@ -6,20 +7,56 @@ interface LogoProps {
loading?: "eager" | "lazy";
}
/*
* O logo em duas partes sobre a caixa do wordmark-<tema>.webp (1600x330): o símbolo em SVG em linha, para os blocos
* lima poderem se mexer (logo.css), e o texto, que é o mesmo .webp com o símbolo apagado (wordmark-text-<tema>.webp,
* do mesmo tamanho, para passar pelo mesmo redimensionamento e dar a mesma altura de antes). As coordenadas do SVG
* são pixels do .webp original, medidas nas bordas das formas; cor e opacidade também vêm dele.
*/
const SHAPE_FILL = { dark: "#f4f3ee", light: "#00090b" } as const;
// em ordem de encaixe, de baixo para cima
const BLOCKS = [
{ x: 130.66, y: 110.25, width: 76.47, height: 86.05 },
{ x: 204.39, y: 43.9, width: 67.72, height: 69.39 },
{ x: 287.27, y: 3.28, width: 39.58, height: 40.82 },
{ x: 312.91, y: 79.72, width: 39.39, height: 39.76 },
];
export function Logo({
theme = "dark",
className = "w-48",
loading = "lazy",
}: Readonly<LogoProps>) {
return (
<Image
src={`/brand/wordmark-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className={`block h-auto max-w-full ${className}`}
/>
<span className={`relative block max-w-full ${className}`}>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 360 330"
preserveAspectRatio="none"
className="absolute top-0 left-0 h-full w-[22.5%] overflow-visible"
>
<path
fill={SHAPE_FILL[theme]}
fillOpacity={0.933}
d="M10.17 223.44H40.23V188.63H74.87V153.98H111.2V222.33H74.93V277.73H139.93V217.4H200.14V291.36H171.59V325.32H10.17Z"
/>
<g fill="#ccfa00" opacity={0.98}>
{BLOCKS.map((block) => (
<rect key={block.x} className="logo-bloco" {...block} />
))}
</g>
</svg>
<Image
src={`/brand/wordmark-text-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className="block h-auto w-full"
/>
</span>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { act, render, waitFor } from "@testing-library/react";
import { OfferVideo } from "./OfferVideo";
/**
* O vídeo da peça 3D toca quando aparece (o IntersectionObserver do jest.setup avisa logo depois de observar) e fica
* parado no pôster com movimento reduzido ou "Economizar dados".
*/
const realMatchMedia = window.matchMedia;
let play: jest.SpyInstance;
let pause: jest.SpyInstance;
beforeEach(() => {
play = jest
.spyOn(HTMLMediaElement.prototype, "play")
.mockResolvedValue(undefined);
pause = jest
.spyOn(HTMLMediaElement.prototype, "pause")
.mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.matchMedia = realMatchMedia;
Reflect.deleteProperty(navigator, "connection");
});
it("path: in view -> plays, muted and in a loop", async () => {
const { container } = render(<OfferVideo name="whatsapp" />);
await waitFor(() => expect(play).toHaveBeenCalled());
const video = container.querySelector("video") as HTMLVideoElement;
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
});
it("path: reduced motion -> never plays, stays on the poster", () => {
jest.useFakeTimers();
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
const { container } = render(<OfferVideo name="sistema" />);
// o aviso de "à vista" do IntersectionObserver chega num setTimeout
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
expect(container.querySelector("video")).toHaveAttribute(
"poster",
"/3d/peca-sistema.webp",
);
});
it("path: save data -> never plays", () => {
jest.useFakeTimers();
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
render(<OfferVideo name="sistema" />);
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef } from "react";
import { useInView } from "@/app/hooks/useInView";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useSaveData } from "./showcase/useShowcaseMedia";
/**
* Peça 3D em loop no cartão da oferta (`public/3d/peca-<nome>.{webm,mp4}`, 640x400, com o pôster `.webp`). É decorativa:
* o nome do cartão e a lista dizem o que é. Nada baixa antes de tocar (`preload="none"`); toca só enquanto está à vista e
* fica no pôster com movimento reduzido ou "Economizar dados".
*/
export function OfferVideo({ name }: Readonly<{ name: string }>) {
const ref = useRef<HTMLVideoElement>(null);
const inView = useInView(ref, { amount: 0.3, once: false });
const reducedMotion = useReducedMotion();
const saveData = useSaveData();
const play = inView && !reducedMotion && !saveData;
useEffect(() => {
const video = ref.current;
if (!video) return;
if (!play) {
video.pause();
return;
}
// o navegador pode recusar a reprodução (economia de energia, aba em segundo plano): o vídeo fica no pôster
video.play()?.catch(() => {});
}, [play]);
const src = `/3d/peca-${name}`;
return (
<video
ref={ref}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
muted
loop
playsInline
preload="none"
poster={`${src}.webp`}
width={640}
height={400}
// contain, não cover: o fundo do vídeo é o mesmo do quadro, e a peça nunca é cortada num quadro mais largo
className="block h-full w-full object-contain"
>
<source src={`${src}.webm`} type="video/webm" />
<source src={`${src}.mp4`} type="video/mp4" />
</video>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Image from "next/image";
import { OfferVideo } from "./OfferVideo";
/**
* Peça de blocos na coluna da direita do topo de uma página interna (a grade é `heroWithMediaGrid`), só de 1024 px em
* diante. Decorativa: fora da leitura. A peça não mede nada: ocupa a altura que o texto ao lado já deu à linha (e um
* pouco da folga de cima e de baixo), então não empurra o texto nem aumenta o topo.
* `image`: imagem de fundo transparente (`/3d/estado-*.webp`, 1200x900). `loop`: o loop `public/3d/peca-<nome>`
* (640x400, fundo #0a0a0a, que some no topo escuro), pelo OfferVideo.
*/
export function PagePiece(
props: Readonly<{ image: string } | { loop: string }>,
) {
return (
<div aria-hidden="true" className="relative hidden self-stretch lg:block">
{/* object-contain: a peça inteira à vista; o loop chega com object-cover do OfferVideo e não pode perder as bordas */}
<div className="absolute inset-x-0 -top-sp-8 -bottom-sp-6 [&_video]:object-contain">
{"image" in props ? (
<Image
src={props.image}
alt=""
fill
sizes="(min-width: 1024px) 40vw, 1px"
className="object-contain"
/>
) : (
<OfferVideo name={props.loop} />
)}
</div>
</div>
);
}
+1 -1
View File
@@ -178,7 +178,7 @@ describe("Section", () => {
"ds-section-description",
"ds-reveal",
);
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-type");
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
const side = screen.getByText("lateral").parentElement;
expect(side?.tagName).toBe("DIV");
+3 -1
View File
@@ -43,7 +43,9 @@ describe("Section", () => {
</Section>,
);
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
const eyebrow = screen.getByText("Sobre");
expect(eyebrow).toHaveClass("ds-eyebrow", "ds-type");
expect(eyebrow.style.getPropertyValue("--n")).toBe("5");
expect(container.querySelector(".ds-section-description")).toBeNull();
expect(
container.querySelector(".ds-section-header > .ds-reveal"),
+9 -1
View File
@@ -45,7 +45,15 @@ export const Section = ({
>
<div className="ds-section-meta">
{eyebrow && (
<p className="ds-eyebrow ds-reveal" style={stagger(0.02)}>
<p
className="ds-eyebrow ds-type"
style={
{
...stagger(0.02),
"--n": eyebrow.length,
} as CSSProperties
}
>
{eyebrow}
</p>
)}
+199 -69
View File
@@ -5,9 +5,10 @@ import { WorkModalities } from "./WorkModalities";
/**
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
* vez só depois dos cartões), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as quatro ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo"
* uma vez só depois dos cartões), a faixa da nota fiscal acima deles, o estilo único de cartão, o texto de pelo menos
* 15 px e a lista compacta de etapas.
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
*/
@@ -33,6 +34,18 @@ jest.mock("./Section", () => ({
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
// o jsdom não toca vídeo: os cartões do sistema e do atendimento chamam play() ao aparecer
beforeEach(() => {
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
const HOME_OFFERS = serviceOffers.filter(({ id }) => id !== "fiscal");
const original = (image: Element) =>
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
"url",
@@ -43,20 +56,20 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
expect(reveals).toHaveLength(3);
expect(reveals).toHaveLength(4);
expect(
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
).toEqual(["20px", "20px", "20px"]);
).toEqual(["20px", "20px", "20px", "20px"]);
expect(
reveals.map((element) =>
element.style.getPropertyValue("--reveal-duration"),
),
).toEqual(["0.75s", "0.75s", "0.75s"]);
).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]);
expect(
reveals.map((element) =>
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
),
).toEqual([0.08, 0.16, 0.24]);
).toEqual([0.08, 0.16, 0.24, 0.32]);
// o estado escondido vive no CSS: sem opacidade nem transformação inline
for (const element of reveals) {
expect(element.style.opacity).toBe("");
@@ -68,7 +81,7 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(3);
expect(cards).toHaveLength(4);
for (const card of cards) {
expect(card).not.toHaveAttribute("tabindex");
expect(card.className).not.toContain("outline-none");
@@ -87,7 +100,7 @@ describe("WorkModalities: movimento", () => {
});
});
describe("WorkModalities: as três ofertas", () => {
describe("WorkModalities: as quatro ofertas", () => {
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
const { container } = render(<WorkModalities />);
@@ -98,7 +111,7 @@ describe("WorkModalities: as três ofertas", () => {
}),
).toBeInTheDocument();
const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(3);
expect(articles).toHaveLength(4);
const titles = articles.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
@@ -106,10 +119,24 @@ describe("WorkModalities: as três ofertas", () => {
expect(titles).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Revisão de sistema que já existe",
]);
// a fonte é uma só, a mesma de /servicos
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
// a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão
expect(titles).toEqual(HOME_OFFERS.map(({ name }) => name));
expect(
screen.getByText(
"Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado.",
),
).toBeInTheDocument();
});
it("path: two columns from 1024 px up and one below it", () => {
const { container } = render(<WorkModalities />);
const grid = container.querySelector("article")?.closest(".grid");
expect(grid).toHaveClass("grid-cols-1", "lg:grid-cols-2");
expect(grid?.className).not.toMatch(/grid-cols-3/);
});
it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
@@ -124,10 +151,10 @@ describe("WorkModalities: as três ofertas", () => {
).toEqual(serviceOffers[0].deliverables);
});
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row); the other two have an illustration of their own", () => {
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for half the row); the system, the WhatsApp and the review cards loop a video", () => {
const { container } = render(<WorkModalities />);
const [site, system, review] = Array.from(
const [site, system, whatsapp, review] = Array.from(
container.querySelectorAll("article"),
);
const siteImage = within(site).getByRole("img");
@@ -137,55 +164,114 @@ describe("WorkModalities: as três ofertas", () => {
);
expect(siteImage).toHaveAttribute(
"sizes",
"(min-width: 1024px) 33vw, 100vw",
"(min-width: 1024px) 50vw, 100vw",
);
expect(siteImage).toHaveAttribute("loading", "lazy");
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
expect(system.querySelector("img")).toBeNull();
expect(review.querySelector("img")).toBeNull();
for (const card of [system, whatsapp, review]) {
expect(card.querySelector("img")).toBeNull();
}
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
expect(site.querySelector("video")).toBeNull();
expect(review.querySelector("video")).not.toBeNull();
});
it("path: the system and the review cards have no black box with an icon: each has an illustration in the same box as the site photo", () => {
it("path: the system, the WhatsApp and the review videos are decorative 3D pieces: muted loop, no preload, the poster first, webm then mp4", () => {
render(<WorkModalities />);
for (const id of ["sistema", "whatsapp", "revisao"]) {
const art = screen.getByTestId(`offer-art-${id}`);
const video = art.querySelector("video") as HTMLVideoElement;
expect(video).not.toBeNull();
expect(video).toHaveAttribute("aria-hidden", "true");
expect(video).toHaveAttribute("tabindex", "-1");
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
expect(video).toHaveAttribute("playsinline");
expect(video).toHaveAttribute("preload", "none");
expect(video).toHaveAttribute("poster", `/3d/peca-${id}.webp`);
expect(
Array.from(video.querySelectorAll("source")).map((source) => [
source.getAttribute("src"),
source.getAttribute("type"),
]),
).toEqual([
[`/3d/peca-${id}.webm`, "video/webm"],
[`/3d/peca-${id}.mp4`, "video/mp4"],
]);
}
// a planilha em SVG saiu do cartão do sistema
expect(
screen.getByTestId("offer-art-sistema").querySelector("svg"),
).toBeNull();
});
it("path: the WhatsApp video carries a decorative app badge in the top left corner: the WhatsApp mark in its green and the word, light on dark", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-whatsapp");
expect(art.querySelector("video")?.parentElement).toHaveClass("relative");
const badge = within(art).getByText("WhatsApp");
expect(badge).toHaveAttribute("aria-hidden", "true");
expect(badge).toHaveClass("absolute", "top-2", "left-2", "text-white");
expect(badge.className).toMatch(/bg-black\/\d+/);
expect(badge.className).not.toMatch(/rounded/);
const mark = badge.querySelector("svg");
expect(mark).not.toBeNull();
expect(mark?.getAttribute("class")).toContain("text-[var(--whatsapp)]");
// só o vídeo do atendimento leva o selo
for (const id of ["sistema", "revisao"]) {
expect(
within(screen.getByTestId(`offer-art-${id}`)).queryByText("WhatsApp"),
).toBeNull();
}
});
it("path: the report excerpt lies over the right edge of the review video, vertically centered, in a wide box, and below the frame in a narrow one, 20% larger (18 px)", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-revisao");
expect(art).toHaveClass("@container", "relative");
const excerpt = within(art).getByRole("img");
// depois do quadro do vídeo, nunca dentro dele: na caixa estreita fica embaixo, sem cobrir a janela
expect(
excerpt.previousElementSibling?.querySelector("video"),
).not.toBeNull();
expect(excerpt).not.toHaveClass("absolute");
expect(excerpt).toHaveClass(
"@[29rem]:absolute",
"@[29rem]:inset-y-0",
"@[29rem]:right-0",
"@[29rem]:items-end",
"@[29rem]:justify-center",
);
for (const item of excerpt.querySelectorAll("li")) {
expect(item).toHaveClass("text-[18px]");
}
expect(within(excerpt).getByText("Exemplo ilustrativo")).toHaveClass(
"text-[18px]",
);
});
it("path: no black box with an icon: the photo and the videos sit in the same frame, in the same box", () => {
const { container } = render(<WorkModalities />);
expect(screen.queryByTestId("offer-icon")).toBeNull();
const boxes = ["site", "sistema", "revisao"].map((id) =>
const boxes = HOME_OFFERS.map(({ id }) =>
screen.getByTestId(`offer-art-${id}`),
);
// as três caixas têm as mesmas classes, então o mesmo tamanho
// as quatro caixas e os quatro quadros têm as mesmas classes, então o mesmo tamanho
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
for (const box of boxes) {
expect(box.className).toContain("aspect-[16/10]");
expect(box.className).toContain("md:aspect-[21/9]");
expect(box.className).toContain("lg:aspect-[16/10]");
expect(box.firstElementChild).not.toBeNull();
const frames = boxes.map((box) => box.firstElementChild as HTMLElement);
expect(new Set(frames.map((frame) => frame.className)).size).toBe(1);
for (const frame of frames) {
expect(frame.className).toContain("aspect-[16/10]");
expect(frame.className).toContain("md:aspect-[21/9]");
expect(frame.className).toContain("lg:aspect-[16/10]");
expect(frame.querySelector("img, video")).not.toBeNull();
}
expect(container.querySelectorAll("article")).toHaveLength(3);
expect(container.querySelectorAll("article")).toHaveLength(4);
});
it("path: the system illustration is a sheet -> arrow -> dashboard scheme in SVG, with three indicators, no text and no numbers", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-sistema");
const scheme = within(art).getByRole("img");
expect(scheme.tagName.toLowerCase()).toBe("svg");
expect(scheme).toHaveAccessibleName(
"Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras",
);
expect(scheme.getAttribute("viewBox")).toBe("0 0 240 150");
expect(scheme.querySelector('[data-art="planilha"]')).not.toBeNull();
expect(scheme.querySelector('[data-art="seta"]')).not.toBeNull();
const panel = scheme.querySelector('[data-art="painel"]') as Element;
expect(panel).not.toBeNull();
// três indicadores genéricos: um quadro com um traço de rótulo e um bloco de valor cada, sem dígito algum
expect(panel.querySelectorAll("g").length).toBe(3);
expect(scheme.querySelector("text")).toBeNull();
expect(art.textContent).toBe("");
// nada de cor de destaque: o limão é só do contato
expect(art.innerHTML).not.toMatch(/ccff00|var\(--slash\)/i);
});
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
render(<WorkModalities />);
@@ -206,26 +292,21 @@ describe("WorkModalities: as três ofertas", () => {
for (const severity of ["Alta", "Média"]) {
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
}
// o texto da ilustração também tem 15 px, como o resto da seção
// o texto do recorte tem 18 px embaixo do vídeo e 15 px ao lado da janela; nunca abaixo dos 15 px do resto da seção
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
});
it("path: the deliverables of the system and of the review come from serviceOffers, the same lists of /servicos", () => {
it("path: the deliverables of the system, the WhatsApp and the review come from serviceOffers, the same lists of /servicos", () => {
const { container } = render(<WorkModalities />);
const [, system, review] = Array.from(
container.querySelectorAll("article"),
);
expect(
within(system)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[1].deliverables);
expect(
within(review)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[2].deliverables);
const articles = Array.from(container.querySelectorAll("article"));
HOME_OFFERS.forEach((offer, index) => {
expect(
within(articles[index])
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(offer.deliverables);
});
});
it("path: every card says what you receive, with three or four items, once", () => {
@@ -241,7 +322,7 @@ describe("WorkModalities: as três ofertas", () => {
}
});
it("path: the first step shows once, below the three cards: a first conversation and, after it, the scope and the proposal in writing", () => {
it("path: the first step shows once, below the cards: a first conversation and, after it, the scope and the proposal in writing", () => {
const { container } = render(<WorkModalities />);
for (const card of container.querySelectorAll("article")) {
@@ -262,13 +343,14 @@ describe("WorkModalities: as três ofertas", () => {
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
});
it("path: each button opens the contact form card with the project type already chosen, all three with the same filled style", () => {
it("path: each button opens the contact form card with the project type already chosen, all four with the same filled style", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
const buttons = [
["Começar pelo site", "/?tipo=site#contact-form"],
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
["Começar pelo atendimento", "/?tipo=whatsapp#contact-form"],
["Começar pela revisão", "/?tipo=revisao#contact-form"],
] as const;
buttons.forEach(([name, href], index) => {
@@ -292,9 +374,19 @@ describe("WorkModalities: as três ofertas", () => {
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
within(articles[2]).getByRole("link", {
name: "Ver como funciona o atendimento",
}),
).toHaveAttribute("href", "/servicos/atendimento-whatsapp-ia");
expect(
within(articles[3]).getByRole("link", {
name: "Ver como funciona a revisão",
}),
).toHaveAttribute("href", "/ia");
expect(
within(articles[2]).getByText(
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
),
).toBeInTheDocument();
});
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
@@ -306,19 +398,19 @@ describe("WorkModalities: as três ofertas", () => {
name: "Ver todos os serviços",
});
expect(services).toHaveAttribute("href", "/servicos");
expect(container.querySelectorAll("article")).toHaveLength(3);
expect(container.querySelectorAll("article")).toHaveLength(4);
// o link fica fora dos cartões, depois da grade
expect(services.closest("article")).toBeNull();
expect(
screen
.getAllByRole("link")
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
.filter((link) => link.getAttribute("href") === "/servicos"),
).toEqual([services]);
});
});
describe("WorkModalities: um só estilo de cartão", () => {
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
it("path: the four cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
@@ -393,3 +485,41 @@ describe("WorkModalities: sem prometer o que o dono não disse", () => {
);
});
});
describe("WorkModalities: a faixa da nota fiscal", () => {
afterEach(() => {
jest.useRealTimers();
});
it("path: up to 2027-03-31 a quiet line above the cards (no card, no colored box) says the Simples Nacional date and links to the page", () => {
jest.useFakeTimers({ now: new Date("2027-03-31T23:59:00-03:00") });
const { container } = render(<WorkModalities />);
const text = screen.getByText(
/Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027\./,
);
expect(text.textContent).toBe(
"Seu sistema emite nota fiscal? Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027.",
);
expect(
screen.getByRole("link", { name: "Ver o que muda" }),
).toHaveAttribute("href", "/servicos/nota-fiscal-reforma-tributaria");
const notice = text.parentElement as HTMLElement;
expect(notice.closest("article")).toBeNull();
expect(
notice.compareDocumentPosition(
container.querySelector("article") as Element,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(notice.className).not.toMatch(/bg-|border-l-|ds-card|shadow/);
});
it("path: from 2027-04-01 on, no notice, and the four cards stay", () => {
jest.useFakeTimers({ now: new Date("2027-04-01T00:00:00-03:00") });
const { container } = render(<WorkModalities />);
expect(screen.queryByText(/Seu sistema emite nota fiscal\?/)).toBeNull();
expect(screen.queryByRole("link", { name: "Ver o que muda" })).toBeNull();
expect(container.querySelectorAll("article")).toHaveLength(4);
});
});
+86 -153
View File
@@ -7,28 +7,35 @@ import {
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { OfferVideo } from "./OfferVideo";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
import { WhatsAppMark } from "./WhatsAppMark";
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
interface OfferExtras {
description: string;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz um vídeo ou uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string };
moreLabel: string;
moreHref: string;
}
/** Ofertas com cartão na home; a da nota fiscal entra na faixa datada acima dos cartões (FiscalNotice). */
type HomeOfferId = Exclude<ServiceOffer["id"], "fiscal">;
/**
* Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
* página de /ia citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de catálogo e
* tabela de preços, o que o texto descreve. O sistema e a revisão não têm um case que mostre o que descrevem (a vitrine
* da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
* Os textos da home para as quatro ofertas, na ordem de `serviceOffers`. Cada texto só diz o que o case, a página de /ia
* ou a página da oferta citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de
* catálogo e tabela de preços, o que o texto descreve. O sistema, o atendimento no WhatsApp e a revisão trazem uma peça
* 3D em vídeo (OfferVideo); por cima, o atendimento tem o selo do aplicativo e a revisão, um recorte do relatório.
*/
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
site: {
description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
@@ -44,6 +51,13 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
whatsapp: {
description:
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
ctaLabel: "Começar pelo atendimento",
moreLabel: "Ver como funciona o atendimento",
moreHref: "/servicos/atendimento-whatsapp-ia",
},
revisao: {
description:
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
@@ -53,6 +67,10 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
},
};
const homeOffers = serviceOffers.flatMap((offer) =>
offer.id === "fiscal" ? [] : [{ offer, extras: OFFER_EXTRAS[offer.id] }],
);
const PROCESS = [
{
title: "Entender a operação",
@@ -76,127 +94,6 @@ const PROCESS = [
},
];
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
const SHEET_COLUMNS = [0, 1, 2];
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
const SHEET_DASH_WIDTHS = [18, 12, 15];
/** Alturas das cinco barras do painel do esquema. */
const PANEL_BARS = [14, 26, 20, 34, 28];
/**
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
*/
function SystemArt() {
return (
<svg
role="img"
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
viewBox="0 0 240 150"
className="block h-full w-full text-white"
fill="none"
stroke="currentColor"
>
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
<rect x="14" y="30" width="80" height="92" />
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
</g>
<rect
x="15"
y="31"
width="78"
height="14"
fill="currentColor"
fillOpacity="0.16"
stroke="none"
/>
<g fill="currentColor" fillOpacity="0.5" stroke="none">
{SHEET_DATA_ROWS.flatMap((row) =>
SHEET_COLUMNS.map((column) => (
<rect
key={`${row}-${column}`}
x={19 + column * 26.6}
y={52 + row * 14.6}
width={
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
}
height="4"
rx="1"
/>
)),
)}
</g>
<path
data-art="seta"
d="M104 76h24M119 66l11 10-11 10"
strokeOpacity="0.85"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g data-art="painel">
<rect
x="140"
y="22"
width="92"
height="108"
rx="3"
fill="currentColor"
fillOpacity="0.06"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{[147, 175, 203].map((x) => (
<g key={x}>
<rect
x={x}
y="30"
width="25"
height="32"
strokeOpacity="0.35"
strokeWidth="1"
/>
<rect
x={x + 4}
y="35"
width="12"
height="3"
fill="currentColor"
fillOpacity="0.5"
stroke="none"
/>
<rect
x={x + 4}
y="43"
width="16"
height="9"
fill="currentColor"
fillOpacity="0.92"
stroke="none"
/>
</g>
))}
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
{PANEL_BARS.map((height, index) => (
<rect
key={height}
x={151 + index * 15}
y={118 - height}
width="10"
height={height}
fill="currentColor"
fillOpacity="0.6"
stroke="none"
/>
))}
</g>
</svg>
);
}
/**
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
@@ -209,28 +106,35 @@ const REPORT_EXCERPT = [
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
const SEVERITY_BADGE =
"inline-flex min-w-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
"inline-flex min-w-14 shrink-0 items-center justify-center border border-[var(--ink)] px-2 font-display text-[18px] @[29rem]:text-[15px] leading-6";
/**
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
* numa linha só no cartão mais estreito (três colunas em 1024 px).
* Recorte do "Modelo do relatório" de /ia junto do vídeo da revisão: três linhas de achado, cada uma com o selo da
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
* quadro, então numa caixa larga (528 px ou mais) o recorte fica por cima da metade direita, centrado na vertical. Numa
* caixa mais estreita (celular, e as duas colunas logo acima de 1024 px) ele não cabe ao lado da janela sem cobri-la:
* desce para baixo do quadro, no mesmo fundo escuro. A largura vem da caixa (container query), não da tela.
*/
/*
* Onde o trecho do relatório fica: ao lado da janela quando o quadro tem 29rem ou mais (a janela do vídeo acaba em 51%
* da largura); embaixo do vídeo quando o cartão ocupa a linha inteira e é estreito (celular); fora da tela entre 1024
* e 1279 px, em que os cartões já estão lado a lado mas o quadro é estreito demais e empilhar desalinharia os títulos.
*/
function ReviewArt() {
return (
<div
role="img"
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
className="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
className="pointer-events-none flex flex-col items-center gap-sp-2 px-sp-3 pt-sp-1 pb-sp-4 lg:max-xl:hidden @[29rem]:absolute @[29rem]:inset-y-0 @[29rem]:right-0 @[29rem]:items-end @[29rem]:justify-center @[29rem]:p-sp-4"
>
<ul
aria-hidden="true"
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
className="w-max max-w-full divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-3 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
className="flex items-center justify-between gap-sp-4 py-1 font-display text-[18px] @[29rem]:text-[15px] leading-7"
>
<span className="min-w-0">{finding}</span>
<span
@@ -243,7 +147,7 @@ function ReviewArt() {
</ul>
<p
aria-hidden="true"
className="font-display text-[15px] leading-5 text-white/75"
className="font-display text-[18px] @[29rem]:text-[15px] leading-6 text-white/80"
>
Exemplo ilustrativo
</p>
@@ -251,7 +155,11 @@ function ReviewArt() {
);
}
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
/** Classes do quadro da mídia, iguais nos quatro cartões: a foto ou o vídeo ocupam o quadro inteiro. */
const MEDIA_FRAME =
"relative aspect-[16/10] md:aspect-[21/9] lg:aspect-[16/10]";
/** Foto do case, quando há um; senão o vídeo da oferta, com o que vai junto dele. O quadro tem o mesmo tamanho em todos. */
function OfferArt({
offer,
extras,
@@ -262,21 +170,40 @@ function OfferArt({
if (poster && extras.poster) {
return (
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
<div className={MEDIA_FRAME}>
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 50vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
</div>
);
}
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
return (
<>
<div className={MEDIA_FRAME}>
<OfferVideo name={offer.id} />
{offer.id === "whatsapp" ? (
// de que aplicativo se trata; decorativo como o vídeo, porque o título do cartão já diz WhatsApp
<span
aria-hidden="true"
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-sp-2 bg-black/70 px-sp-2 py-1 font-display text-[15px] leading-5 text-white @[33rem]:top-3 @[33rem]:left-3"
>
<WhatsAppMark className="h-4 w-4 text-[var(--whatsapp)]" />
WhatsApp
</span>
) : null}
</div>
{offer.id === "revisao" ? <ReviewArt /> : null}
</>
);
}
/** Um único estilo de cartão para as três ofertas: foto ou ilustração, título com altura mínima, texto, o que você recebe, botão e link do case. */
/** Um único estilo de cartão para as quatro ofertas: foto ou vídeo, título com altura mínima, texto, o que você recebe, botão e link. */
function OfferCard({
offer,
extras,
@@ -285,7 +212,7 @@ function OfferCard({
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div
data-testid={`offer-art-${offer.id}`}
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
className="@container relative overflow-hidden border border-[var(--border)] bg-[var(--ink)]"
>
<OfferArt offer={offer} extras={extras} />
</div>
@@ -346,16 +273,22 @@ function OfferCard({
}
export function WorkModalities() {
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
return (
<Section
id="modalities"
eyebrow="Soluções"
title="Escolha por onde começar"
description="Três pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
description="Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
disableChildrenAnimation
>
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{serviceOffers.map((offer, index) => (
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
{fiscal && isFiscalNoticeActive(new Date()) ? (
<FiscalNotice href={fiscal.href} />
) : null}
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-2 lg:gap-sp-6">
{homeOffers.map(({ offer, extras }, index) => (
<Reveal
key={offer.id}
y={20}
@@ -363,12 +296,12 @@ export function WorkModalities() {
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
<OfferCard offer={offer} extras={extras} />
</Reveal>
))}
</div>
{/* Uma vez só, depois dos três cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
{/* Uma vez só, depois dos cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
+8 -7
View File
@@ -1,26 +1,27 @@
/**
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
* A resposta ao ponteiro e ao foco é uma por papel (cv-fill, cv-outline-*, cv-bar, em globals.css).
*/
const base =
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline transition-colors motion-reduce:transition-none sm:text-[16px]";
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline sm:text-[16px]";
export const primaryButton = `${base} bg-[var(--slash)] font-semibold text-[var(--ink)] hover:bg-[#d9ff40]`;
export const primaryButton = `${base} cv-fill bg-[var(--slash)] font-semibold text-[var(--ink)]`;
/** Contorno sobre fundo escuro. */
export const secondaryButtonDark = `${base} border border-white/25 bg-transparent font-medium text-[#f4f2ed] hover:border-white/55`;
export const secondaryButtonDark = `${base} cv-outline-dark border border-white/25 bg-transparent font-medium text-[#f4f2ed]`;
/** Contorno sobre fundo claro. */
export const secondaryButtonLight = `${base} border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)] hover:border-[var(--ink)]`;
export const secondaryButtonLight = `${base} cv-outline-light border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)]`;
const textLinkBase =
"inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 transition-colors motion-reduce:transition-none sm:text-[16px]";
"cv-bar inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 sm:text-[16px]";
/** Link de texto sobre fundo claro. */
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
export const textLink = textLinkBase;
/** Link de texto sobre fundo escuro. */
export const textLinkDark = `${textLinkBase} text-white/80 hover:text-white`;
export const textLinkDark = `${textLinkBase} text-white/80`;
/**
* Primeira seção de uma página interna, no celular: o padding de .ds-section (52 px) é menor que o cabeçalho fixo (60 px),
@@ -0,0 +1,614 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { act, render } from "@testing-library/react";
import type {
InstancedBufferGeometry,
Mesh,
ShaderMaterial,
Texture,
} from "three";
import { stateAt } from "@/lib/blocos";
import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device";
import BlocosScene from "./BlocosScene";
/*
* A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha
* sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o
* JSON dos estados) vêm de dublês também; o JSON é o arquivo real.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
this.options = options;
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const three = jest.requireActual("three");
return {
...three,
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
// o jsdom não carrega imagens: o matcap é uma textura vazia
TextureLoader: jest.fn(() => ({
loadAsync: () => Promise.resolve(new three.Texture()),
})),
};
});
jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => {
const { BoxGeometry, Group, Mesh } = jest.requireActual("three");
return {
GLTFLoader: jest.fn(() => ({
loadAsync: () => {
const scene = new Group();
scene.add(new Mesh(new BoxGeometry(1, 1, 1)));
return Promise.resolve({ scene });
},
})),
};
});
const blocosJson = JSON.parse(
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
);
let mockFetchOk = true;
const originalFetch = global.fetch;
const props = () => ({
progressRef: { current: 0 },
visible: true,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockRendererName = null;
mockFetchOk = true;
global.fetch = jest.fn(() =>
Promise.resolve({
ok: mockFetchOk,
status: mockFetchOk ? 200 : 404,
json: () => Promise.resolve(blocosJson),
}),
) as unknown as typeof fetch;
setDevicePixelRatio(1);
sessionStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.fetch = originalFetch;
sessionStorage.clear();
});
/** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
/** A malha que o último quadro desenhou (a cena tem uma só). */
function drawnMesh() {
const calls = mockRenderers[0].render.mock.calls as unknown as [
{ children: Mesh[] },
][];
return calls.at(-1)?.[0].children[0] as Mesh<
InstancedBufferGeometry,
ShaderMaterial
>;
}
const uniform = (name: string) =>
drawnMesh().material.uniforms[name].value as number;
// precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo
describe("BlocosScene when the assets do not download", () => {
it("tells the page once, creates no context, and lets the next mount try again", async () => {
mockFetchOk = false;
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers).toHaveLength(0);
expect(container.querySelector("canvas")).toBeNull();
unmount();
mockFetchOk = true;
const again = props();
await mount(<BlocosScene {...again} />);
expect(again.onError).not.toHaveBeenCalled();
expect(mockRenderers).toHaveLength(1);
});
});
describe("BlocosScene", () => {
it("creates a transparent, antialiased renderer on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<BlocosScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toEqual({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
});
it.each([
["full", 3, 1.75],
["full", 1.5, 1.5],
["lite", 3, 1.5],
["lite", 0.5, 1],
] as const)(
"caps the pixel ratio on %s (device %p -> %p)",
async (tier, device, expected) => {
setDevicePixelRatio(device);
await mount(<BlocosScene {...props()} tier={tier} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws every block in one instanced call that starts on marca", async () => {
await mount(<BlocosScene {...props()} />);
advance(20);
const mesh = drawnMesh();
expect(mesh.geometry.type).toBe("InstancedBufferGeometry");
expect(mesh.geometry.instanceCount).toBe(238);
expect(uniform("uFrom")).toBe(0);
expect(uniform("uTo")).toBe(0);
});
it("tells the page it is ready on the second frame drawn", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(2);
expect(scene.onReady).toHaveBeenCalledTimes(1);
advance(3000);
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("draws nothing while not visible", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<BlocosScene {...scene} visible={false} />,
);
advance(3000);
expect(frames).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
rerender(<BlocosScene {...scene} visible />);
advance(100);
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<BlocosScene {...props()} />);
advance(1500); // entrada de 1,4 s, no ritmo da tela
const entry = frames.mock.calls.length;
expect(entry).toBeGreaterThan(80);
expect(uniform("uIntro")).toBe(1);
advance(1000);
const pulse = frames.mock.calls.length - entry;
expect(pulse).toBeGreaterThan(20);
expect(pulse).toBeLessThanOrEqual(34);
});
it("follows the dive progress on full", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1500);
scene.progressRef.current = 0.63;
advance(4000);
expect(uniform("uFrom")).toBe(2);
expect(uniform("uTo")).toBe(2);
expect(uniform("uT")).toBe(0);
});
it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => {
const scene = props();
scene.progressRef.current = 0.93; // ignorado no lite
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 3500); // entrada e 3,5 s parado na marca
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]);
advance(1100); // no meio da troca de 1,2 s
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
advance(1000);
expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([
1, 1, 0,
]);
});
/** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */
const hostOf800x600 = () => {
jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600);
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockReturnValue(new DOMRect(0, 0, 800, 600));
};
const fire = (event: Event) =>
act(() => {
window.dispatchEvent(event);
});
it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1000);
expect(uniform("uVida")).toBe(0); // a entrada ainda assenta
advance(1500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
expect(uniform("uEstado")).toBe(0);
const tempo = uniform("uTempo");
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
// no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.23;
advance(1000);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uVida")).toBeLessThan(0.05);
fire(new Event("scroll"));
scene.progressRef.current = 0.35;
advance(3000);
expect(uniform("uEstado")).toBe(1);
expect(uniform("uVida")).toBeGreaterThan(0.9);
});
it("freezes the resting life with the pulse once nobody moves", async () => {
const scene = props();
scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso
await mount(<BlocosScene {...scene} />);
advance(6000); // a batida para 5 s depois do último gesto
const drawn = mockRenderers[0].render.mock.calls.length;
const tempo = uniform("uTempo");
advance(3000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
expect(uniform("uTempo")).toBe(tempo);
fire(new Event("scroll"));
advance(500);
expect(uniform("uTempo")).toBeGreaterThan(tempo);
});
/** O estado que o último quadro desenhou: de, para e t. */
const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")];
it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 2500); // entrada e 2,5 s parado na marca
expect(shown()).toEqual([0, 0, 0]);
advance(1100); // na troca de 1,2 s para a tela
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
// durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
advance(1500);
expect(shown()).toEqual([1, 1, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela
// a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
advance(4200 * 2 + 1000); // integrações, dados
expect(shown()).toEqual([3, 3, 0]);
advance(4200);
expect(shown()).toEqual([0, 0, 0]);
// cada legenda entra com a peça assentada e sai quando a troca seguinte começa
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
null,
2,
null,
3,
null,
0,
]);
});
it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo
fire(new Event("scroll"));
scene.progressRef.current = 0.02;
advance(50); // a próxima batida
// a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava
expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uT")).toBeLessThan(0.1);
expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda
advance(1000);
expect(shown()).toEqual([0, 0, 0]);
// de volta ao topo: 3 s parada na marca antes da troca seguinte
fire(new Event("scroll"));
scene.progressRef.current = 0;
advance(3000);
expect(shown()).toEqual([0, 0, 0]);
advance(1500);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
});
it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(scene.onEstado).toHaveBeenLastCalledWith(1);
fire(new Event("scroll"));
scene.progressRef.current = 0.63;
advance(4000);
expect(shown()).toEqual([2, 2, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
});
it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1400 + 4200 + 4200 + 1000);
expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo
// a rolagem para no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.26;
advance(3000);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6);
});
it("lite: tells the legend every state it builds", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 4000 + 1200 + 500);
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
]);
});
it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} />);
advance(2500);
expect(uniform("uCursorPeso")).toBe(0);
// o centro do hospedeiro cai no centro da peça
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(1000);
expect(uniform("uCursorPeso")).toBeGreaterThan(0.9);
const cursor = drawnMesh().material.uniforms.uCursor.value as {
x: number;
y: number;
};
expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5);
// perto do canto de cima à esquerda, longe da peça, o ponto vai junto
fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 }));
advance(1000);
expect(cursor.x).toBeLessThan(-3);
expect(cursor.y).toBeGreaterThan(2);
fire(new PointerEvent("pointerout", { relatedTarget: null }));
advance(1500);
expect(uniform("uCursorPeso")).toBeLessThan(0.01);
});
it("gives lite the resting life on the pulse, without the cursor", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} tier="lite" />);
advance(2500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
const tempo = uniform("uTempo");
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
expect(uniform("uCursorPeso")).toBe(0);
});
it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => {
const removed = jest.spyOn(window, "removeEventListener");
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
advance(20);
const mesh = drawnMesh();
const disposed = [
jest.spyOn(mesh.geometry, "dispose"),
jest.spyOn(mesh.material, "dispose"),
jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"),
jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"),
];
const canvasRemoved = jest.spyOn(
mockRenderers[0].domElement,
"removeEventListener",
);
unmount();
for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
expect(jest.getTimerCount()).toBe(0);
expect(removed.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining(["resize", "pointermove", "pointerout"]),
);
expect(canvasRemoved).toHaveBeenCalledWith(
"webglcontextlost",
expect.any(Function),
);
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("tells the page once when the WebGL context is lost", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
it("gives up once and remembers it when the entry frames average more than 28 ms", async () => {
// quadros de 40 ms: um aparelho que não segura a cena
jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(
(callback) =>
setTimeout(
() => callback(performance.now()),
40,
) as unknown as number,
);
jest
.spyOn(window, "cancelAnimationFrame")
.mockImplementation((id) => clearTimeout(id));
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(isKnownSlowScene()).toBe(true);
const drawn = mockRenderers[0].render.mock.calls.length;
expect(drawn).toBeLessThanOrEqual(62);
advance(2000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("keeps a device that holds 60 fps, without remembering anything", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSlowScene()).toBe(false);
});
});
describe("BlocosScene: software WebGL", () => {
it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => {
mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
});
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
+919
View File
@@ -0,0 +1,919 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import {
type BufferGeometry,
Euler,
InstancedBufferAttribute,
InstancedBufferGeometry,
MathUtils,
Matrix3,
Matrix4,
Mesh,
PerspectiveCamera,
Plane,
Ray,
Scene,
ShaderMaterial,
SRGBColorSpace,
type Texture,
TextureLoader,
Vector2,
Vector3,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
type Blocos,
caixaDosEstados,
cicloAt,
createFrameBudget,
type Estado,
type FrameVerdict,
framePiece,
papeis,
parseBlocos,
type Rect,
seguir,
stateAt,
} from "@/lib/blocos";
import {
isSoftwareWebGL,
rememberSlowScene,
rememberSoftwareWebGL,
} from "@/lib/device";
import { startFramePulse } from "./FramePulse";
export type BlocosSceneProps = {
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
progressRef: RefObject<number>;
visible: boolean;
onReady: () => void;
onError: () => void;
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
tier?: "full" | "lite";
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
placementRef?: RefObject<Rect | null>;
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
stageRef?: RefObject<Rect | null>;
/**
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
*/
onEstado?: (estado: number | null) => void;
};
/*
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
*/
type Assets = {
bloco: BufferGeometry;
blocos: Blocos;
porcelana: Texture;
lima: Texture;
};
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
const ENTRADA = 1.4;
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
const FICA = 4;
const TROCA = 1.2;
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
const FICA_TOPO = 3;
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
const TOPO = 0.01;
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
const SEGUE = 2;
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
const FRENTE = { yaw: 0.24, pitch: 0.12 };
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
const DISTANCIA = 40;
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
const VERTEX = /* glsl */ `
#define ATRASO 0.35
attribute vec4 aPos0;
attribute vec4 aPos1;
attribute vec4 aPos2;
attribute vec4 aPos3;
attribute vec3 aEsc0;
attribute vec3 aEsc1;
attribute vec3 aEsc2;
attribute vec3 aEsc3;
attribute vec3 aEntrada;
attribute float aSemente;
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
attribute vec4 aPapel;
uniform float uFrom;
uniform float uTo;
uniform float uT;
uniform float uIntro;
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
uniform float uVida;
uniform float uEstado;
uniform float uTempo;
uniform vec3 uBotao;
uniform float uChao;
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
uniform vec2 uCursor;
uniform float uCursorPeso;
varying vec3 vNormal;
varying float vTom;
// posição (xyz) e tom (w) do bloco no estado i
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
float encaixe(float t) {
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
return 1.0 - pow(1.0 - k, 3.0);
}
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
float pulo(float u) {
if (u <= 0.0 || u >= 1.0) return 0.0;
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
}
// a vida em repouso do estado: muda posição, escala e tom do bloco
void vida(inout vec3 p, inout vec3 s, inout float tom) {
if (uEstado < 0.5) {
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
} else if (uEstado < 1.5) {
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
p = uBotao + (p - uBotao) * k;
s *= k;
} else if (uEstado < 2.5) {
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
} else if (aPapel.w >= 0.0) {
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
p.z = uChao + (p.z - uChao) * k;
s.z *= k;
}
}
void main() {
vec4 a = lugar(uFrom);
vec4 b = lugar(uTo);
vec3 ea = escala(uFrom);
vec3 eb = escala(uTo);
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
float e = encaixe(uT);
vec3 p = mix(pa, pb, e);
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
vec3 s = mix(ea, eb, e);
vTom = mix(a.w, b.w, e);
if (uVida > 0.0) vida(p, s, vTom);
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
float ei = encaixe(uIntro);
p += aEntrada * (1.0 - ei);
s *= mix(0.8, 1.0, ei);
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
// escala não uniforme: a normal vai pela inversa da escala
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
gl_Position = projectionMatrix * mv;
}
`;
const FRAGMENT = /* glsl */ `
uniform sampler2D uPorcelana;
uniform sampler2D uLima;
uniform mat3 uLuz;
varying vec3 vNormal;
varying float vTom;
void main() {
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
vec3 n = uLuz * normalize(vNormal);
vec2 uv = n.xy * 0.495 + 0.5;
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
#include <colorspace_fragment>
}
`;
let assets: Promise<Assets> | null = null;
async function loadAssets(): Promise<Assets> {
const textures = new TextureLoader();
const [gltf, blocos, porcelana, lima] = await Promise.all([
new GLTFLoader().loadAsync("/3d/bloco.glb"),
fetch("/3d/blocos.json").then((response) => {
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
return response.json().then(parseBlocos);
}),
textures.loadAsync("/3d/matcap-porcelana.webp"),
textures.loadAsync("/3d/matcap-lima.webp"),
]);
let bloco: BufferGeometry | undefined;
gltf.scene.traverse((object) => {
if (!bloco && object instanceof Mesh) bloco = object.geometry;
});
if (!bloco) throw new Error("bloco.glb sem malha");
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
porcelana.colorSpace = SRGBColorSpace;
lima.colorSpace = SRGBColorSpace;
return { bloco, blocos, porcelana, lima };
}
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
function loadBlocos() {
assets ??= loadAssets().catch((error) => {
assets = null;
throw error;
});
return assets;
}
/**
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
* Nunca rejeita: uma falha aparece ao montar (`onError`).
*/
export function prepareBlocos(): Promise<void> {
return loadBlocos().then(
() => undefined,
() => undefined,
);
}
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
function sorteio(bloco: number, canal: number) {
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
const { n, estados } = blocos;
const geometry = new InstancedBufferGeometry();
geometry.index = bloco.index;
geometry.setAttribute("position", bloco.getAttribute("position"));
geometry.setAttribute("normal", bloco.getAttribute("normal"));
geometry.instanceCount = n;
estados.forEach((dados, e) => {
const lugar = new Float32Array(n * 4);
const escala = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
for (let k = 0; k < 3; k++) {
lugar[i * 4 + k] = dados[i * 7 + k];
escala[i * 3 + k] = dados[i * 7 + 3 + k];
}
lugar[i * 4 + 3] = dados[i * 7 + 6];
}
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
});
const semente = new Float32Array(n);
const entrada = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
semente[i] = sorteio(i, 0);
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
const x = sorteio(i, 1) - 0.5;
const y = sorteio(i, 2) - 0.5;
const z = sorteio(i, 3);
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
entrada[i * 3] = x * k;
entrada[i * 3 + 1] = y * k;
entrada[i * 3 + 2] = z * k;
}
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
const { vida, botao, chao } = papeis(blocos);
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
// a visão desfeita da pose da peça (ver o fragment shader)
const luz = new Matrix3();
const material = new ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms: {
uFrom: { value: 0 },
uTo: { value: 0 },
uT: { value: 0 },
uIntro: { value: 0 },
uVida: { value: 0 },
uEstado: { value: 0 },
uTempo: { value: 0 },
uBotao: { value: new Vector3(...botao) },
uChao: { value: chao },
uCursor: { value: new Vector2() },
uCursorPeso: { value: 0 },
uPorcelana: { value: porcelana },
uLima: { value: lima },
uLuz: { value: luz },
},
});
const mesh = new Mesh(geometry, material);
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
mesh.frustumCulled = false;
const scene = new Scene();
scene.add(mesh);
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
camera.position.set(0, 0, DISTANCIA);
// o raio do ponteiro sai dela antes do primeiro desenho
camera.updateMatrixWorld();
return {
scene,
camera,
mesh,
geometry,
material,
porcelana,
lima,
luz,
pose: new Euler(),
rotation: new Matrix4(),
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
inversa: new Matrix4(),
raio: new Ray(),
mira: new Vector3(),
};
}
type World = ReturnType<typeof buildWorld>;
type Tier = NonNullable<BlocosSceneProps["tier"]>;
type SceneState = {
/** Progresso amortecido (`full`). */
progress: number;
/** Entrada, de 0 a 1. */
intro: number;
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
clock: number;
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
quieto: number;
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
ultimoAlvo: number;
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
mostrado: Estado | null;
fonte: "ciclo" | "rolagem";
preso: boolean;
pointer: Vector2;
tilt: Vector2;
frames: number;
/**
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
* quadro: o balanço não salta quando o desenho volta depois de parado).
*/
vida: number;
vidaEstado: number;
vidaTempo: number;
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
cursor: Vector2;
cursorPeso: number;
};
type StepInput = {
tier: Tier;
target: number;
placement: Rect | null;
stage: Rect | null;
host: { width: number; height: number };
/** Tempo real desde o quadro anterior (ms). */
delta: number;
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
cursor: Vector2 | null;
};
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
function pesoMarca({ from, to, t }: Estado) {
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
}
/**
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
* cursor, e escreve os uniforms.
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
*/
function stepBlocos(world: World, s: SceneState, input: StepInput) {
const { tier, target, placement, stage, host, delta } = input;
const dt = Math.min(delta / 1000, 1 / 20);
s.intro = Math.min(1, s.intro + dt / ENTRADA);
let settling = s.intro < 1;
let wait = 0;
let pedido: Estado;
let fonte: SceneState["fonte"] = "ciclo";
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
const real = Math.min(delta / 1000, FICA + TROCA);
if (tier === "full") {
// rolagem amortecida: a peça segue o dedo com um leve atraso
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
if (Math.abs(target - s.progress) > 4e-4) settling = true;
else s.progress = target;
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
// s) assume; qualquer rolagem devolve o comando à rolagem
const noTopo =
s.intro >= 1 &&
target < TOPO &&
target === s.ultimoAlvo &&
s.progress === target;
s.ultimoAlvo = target;
s.quieto = noTopo ? s.quieto + real : 0;
if (s.quieto > 0) {
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
pedido = ciclo;
if (ciclo.wait === 0) settling = true;
else wait = ciclo.wait;
} else {
pedido = stateAt(s.progress);
fonte = "rolagem";
}
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
} else {
if (s.intro >= 1) s.clock += real;
const lite = cicloAt(s.clock, FICA, TROCA);
pedido = lite;
if (lite.wait === 0) settling = true;
else wait = lite.wait;
}
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
if (fonte !== s.fonte) s.preso = false;
s.fonte = fonte;
let estado = pedido;
if (s.mostrado && !s.preso) {
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
s.preso =
estado.from === pedido.from &&
estado.to === pedido.to &&
estado.t === pedido.t;
if (!s.preso) settling = true;
}
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
const u = world.material.uniforms;
u.uFrom.value = estado.from;
u.uTo.value = estado.to;
u.uT.value = estado.t;
u.uIntro.value = s.intro;
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
const w = pesoMarca(estado);
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
const fora =
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
let area = stage;
if (tier === "full" && placement) {
const topo = caixaDosEstados(placement, host);
const fim = stage ?? { x: 0, y: 0, ...host };
area = {
x: MathUtils.lerp(topo.x, fim.x, fora),
y: MathUtils.lerp(topo.y, fim.y, fora),
width: MathUtils.lerp(topo.width, fim.width, fora),
height: MathUtils.lerp(topo.height, fim.height, fora),
};
}
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
const k =
host.height /
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
const frame = framePiece(host, placement, w, area);
const x = (frame.x - host.width / 2) / k;
const y = (host.height / 2 - frame.y) / k;
world.mesh.position.set(x, y, 0);
world.mesh.scale.setScalar(frame.scale / k);
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
const pose = world.pose.set(
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
0,
);
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
world.mesh.rotation.set(
pose.x - s.tilt.y * 0.06,
pose.y + sway + s.tilt.x * 0.1,
0,
);
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
world.luz
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
.transpose();
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
const parado = estado.from === estado.to && s.intro >= 1;
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
s.vidaTempo += dt;
u.uVida.value = s.vida;
u.uEstado.value = s.vidaEstado;
u.uTempo.value = s.vidaTempo;
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
let mira = 0;
if (input.cursor) {
world.mesh.updateMatrixWorld();
const { raio, camera } = world;
raio.origin.setFromMatrixPosition(camera.matrixWorld);
raio.direction
.set(
(input.cursor.x / host.width) * 2 - 1,
1 - (input.cursor.y / host.height) * 2,
0.5,
)
.unproject(camera)
.sub(raio.origin);
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
}
if (mira) {
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
}
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
u.uCursor.value.copy(s.cursor);
u.uCursorPeso.value = s.cursorPeso * s.vida;
s.frames += 1;
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
const assentada = estado.from === estado.to && estado.t === 0;
const legenda =
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
return { again: settling, wait, ready: s.frames === 2, legenda };
}
/** Solta o contexto WebGL e tira o canvas da página. */
function disposeRenderer(renderer: WebGLRenderer) {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
/**
* O renderizador transparente, com antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
* contexto não abre ou se ele desenha por software (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
*/
function createRenderer(host: HTMLElement, tier: Tier) {
const renderer = new WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
if (isSoftwareWebGL(renderer.getContext())) {
rememberSoftwareWebGL();
disposeRenderer(renderer);
throw new Error("WebGL por software");
}
renderer.setPixelRatio(
Math.min(
tier === "lite" ? 1.5 : 1.75,
Math.max(1, window.devicePixelRatio || 1),
),
);
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
host.append(canvas);
return renderer;
}
type Parts = { renderer: WebGLRenderer; world: World };
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
function disposeParts({ renderer, world }: Parts) {
world.geometry.dispose();
world.material.dispose();
world.porcelana.dispose();
world.lima.dispose();
disposeRenderer(renderer);
}
type Callbacks = Pick<
BlocosSceneProps,
"progressRef" | "placementRef" | "stageRef" | "onEstado"
> & {
onReady: () => void;
onError: () => void;
};
export type BlocosEngine = {
setVisible: (visible: boolean) => void;
dispose: () => void;
};
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
function startBlocos(
host: HTMLElement,
parts: Parts,
{
progressRef,
placementRef,
stageRef,
onReady,
onError,
onEstado,
}: Callbacks,
tier: Tier,
): BlocosEngine {
const { renderer, world } = parts;
const canvas = renderer.domElement;
const s: SceneState = {
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
progress: progressRef.current ?? 0,
intro: 0,
clock: 0,
quieto: 0,
ultimoAlvo: progressRef.current ?? 0,
mostrado: null,
fonte: "rolagem",
preso: true,
pointer: new Vector2(),
tilt: new Vector2(),
frames: 0,
vida: 0,
vidaEstado: 0,
vidaTempo: 0,
cursor: new Vector2(),
cursorPeso: 0,
};
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
let mouse: Vector2 | null = null;
const noHost = new Vector2();
const size = { width: 1, height: 1 };
const budget = createFrameBudget();
let verdict: FrameVerdict = "pending";
let alive = true;
let visible = false;
let raf = 0;
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
let chained = false;
let last = 0;
let wake: ReturnType<typeof setTimeout> | undefined;
let stopPulse: (() => void) | undefined;
/** O último estado avisado para a legenda. */
let legenda: number | null = null;
const request = (fromFrame: boolean) => {
if (raf || !alive || !visible) return;
chained = fromFrame;
raf = requestAnimationFrame(render);
};
const invalidate = () => request(false);
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
const halt = () => {
cancelAnimationFrame(raf);
raf = 0;
clearTimeout(wake);
wake = undefined;
stopPulse?.();
stopPulse = undefined;
last = 0;
};
const fail = () => {
if (!alive) return;
alive = false;
halt();
onError();
};
const draw = (now: number) => {
const delta = last ? now - last : 1000 / 60;
last = now;
if (chained) verdict = budget(delta);
if (verdict === "slow") {
rememberSlowScene();
fail();
return;
}
let cursor: Vector2 | null = null;
if (mouse) {
const r = host.getBoundingClientRect();
noHost.set(mouse.x - r.left, mouse.y - r.top);
if (
noHost.x >= 0 &&
noHost.y >= 0 &&
noHost.x <= r.width &&
noHost.y <= r.height
)
cursor = noHost;
}
const step = stepBlocos(world, s, {
tier,
target: progressRef.current ?? 0,
placement: placementRef?.current ?? null,
stage: stageRef?.current ?? null,
host: size,
delta,
cursor,
});
renderer.render(world.scene, world.camera);
if (step.ready) onReady();
if (step.legenda !== legenda) {
legenda = step.legenda;
onEstado?.(legenda);
}
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
if (step.again || verdict === "pending") request(true);
else if (step.wait) {
clearTimeout(wake);
wake = setTimeout(invalidate, step.wait * 1000);
}
};
const render = (now: number) => {
raf = 0;
if (!alive) return;
try {
draw(now);
} catch {
fail();
}
};
const resize = () => {
size.width = Math.max(1, host.clientWidth);
size.height = Math.max(1, host.clientHeight);
renderer.setSize(size.width, size.height, false);
world.camera.aspect = size.width / size.height;
world.camera.updateProjectionMatrix();
invalidate();
};
const onPointer = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
mouse ??= new Vector2();
mouse.set(event.clientX, event.clientY);
};
// o mouse saiu da janela: os blocos voltam
const onLeave = (event: PointerEvent) => {
if (!event.relatedTarget) mouse = null;
};
const onLost = (event: Event) => {
event.preventDefault();
fail();
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
observer?.observe(host);
if (!observer) window.addEventListener("resize", resize);
if (tier === "full") {
window.addEventListener("pointermove", onPointer, { passive: true });
window.addEventListener("pointerout", onLeave, { passive: true });
}
canvas.addEventListener("webglcontextlost", onLost);
resize();
return {
setVisible(next) {
visible = next;
if (!next || !alive) return halt();
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
stopPulse ??= startFramePulse(invalidate);
},
dispose() {
alive = false;
halt();
observer?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointer);
window.removeEventListener("pointerout", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
disposeParts(parts);
},
};
}
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/**
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
* software ou se o shader não compila.
*/
export async function mountBlocos(
host: HTMLElement,
callbacks: Callbacks,
tier: Tier,
isAlive: () => boolean,
): Promise<BlocosEngine | null> {
const loaded = await loadBlocos();
await nextTask();
if (!isAlive()) return null;
const renderer = createRenderer(host, tier);
let parts: Parts | undefined;
try {
await nextTask();
if (!isAlive()) {
disposeRenderer(renderer);
return null;
}
parts = { renderer, world: buildWorld(loaded) };
await renderer.compileAsync(parts.world.scene, parts.world.camera);
await nextTask();
} catch (error) {
if (parts) disposeParts(parts);
else disposeRenderer(renderer);
throw error;
}
if (!isAlive()) {
disposeParts(parts);
return null;
}
return startBlocos(host, parts, callbacks, tier);
}
export default function BlocosScene({
progressRef,
visible,
onReady,
onError,
tier = "full",
placementRef,
stageRef,
onEstado,
}: BlocosSceneProps) {
const hostRef = useRef<HTMLDivElement>(null);
const engine = useRef<BlocosEngine | null>(null);
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
const callbacks = useRef({ onReady, onError, onEstado });
useEffect(() => {
callbacks.current = { onReady, onError, onEstado };
});
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
const wantsVisible = useRef(visible);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let alive = true;
mountBlocos(
host,
{
progressRef,
placementRef,
stageRef,
onReady: () => callbacks.current.onReady(),
onEstado: (estado) => callbacks.current.onEstado?.(estado),
onError: () => callbacks.current.onError(),
},
tier,
() => alive,
)
.then((created) => {
if (!created) return;
if (!alive) return created.dispose();
engine.current = created;
created.setVisible(wantsVisible.current);
})
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
.catch(() => {
if (alive) callbacks.current.onError();
});
return () => {
alive = false;
engine.current?.dispose();
engine.current = null;
};
}, [progressRef, placementRef, stageRef, tier]);
useEffect(() => {
wantsVisible.current = visible;
engine.current?.setVisible(visible);
}, [visible]);
return (
<div
ref={hostRef}
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
/>
);
}
@@ -41,11 +41,11 @@ describe("CinematicHome: dados estruturados", () => {
expect(container.firstElementChild).toBe(scripts[0]);
});
it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
it("describes only the five offers as Service nodes tied to the person, without repeating what the layout emits", () => {
const [data] = structuredData();
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(3);
expect(data["@graph"]).toHaveLength(5);
for (const offer of data["@graph"]) {
expect(offer["@type"]).toBe("Service");
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
* vem do layout) e a ligação dos hooks.
* As cenas e os hooks são trocados por dublês que registram o que recebem.
*/
@@ -75,59 +75,6 @@ beforeEach(() => {
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
});
describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project (Lavação 047) is in the list with its own domain -> that domain goes to the hero", () => {
render(
<CinematicHome
projects={[
project("sev-modaintima", "https://sevmodaintima.com.br"),
project("lavacao-047", "https://www.lavacao047.com.br/"),
]}
/>,
);
expect(lastHeroProps().workUrl).toEqual({
text: "lavacao047.com.br",
secure: true,
});
});
it("path: the featured project is in the list but has no site -> no address", () => {
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project is not in the list -> no address", () => {
render(
<CinematicHome
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
render(
<CinematicHome
projects={[
project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: empty project list -> no address and the page still renders", () => {
render(<CinematicHome projects={[]} />);
expect(lastHeroProps().workUrl).toBeNull();
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
});
});
describe("CinematicHome: props e estrutura", () => {
it("path: every optional prop given -> each scene receives its own data", () => {
const projects = [project("lavacao-047", "https://a.example.com")];
+1 -13
View File
@@ -8,14 +8,10 @@ import Contact from "../Contact";
import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects";
import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import CinematicMain from "./CinematicMain";
/** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
const HERO_WORK_PROJECT = "lavacao-047";
/**
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
@@ -44,19 +40,11 @@ export default function CinematicHome({
clients?: ClientLink[];
testimonials?: Testimonial[];
}) {
const shownUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
?.demoUrl,
});
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
const heroWorkUrl =
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
return (
<>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<CinematicMain>
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Hero stats={heroStats} clients={clients} />
<Projects projects={projects} />
@@ -24,16 +24,19 @@ function MockScene(props: SceneProps) {
return require("react").createElement("canvas", { "data-testid": "scene" });
}
jest.mock("./HeroFieldScene", () => ({
jest.mock("./BlocosScene", () => ({
__esModule: true,
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
get default() {
if (mockImportFails) throw new Error("chunk indisponível");
return MockScene;
},
get prepareField() {
get prepareBlocos() {
if (mockImportFails) throw new Error("chunk indisponível");
return () => mockPrepare();
return () => {
mockPrepare();
return Promise.resolve();
};
},
}));
@@ -345,7 +348,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
});
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
mockMedia(["(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 4 });
const probe = jest.fn(() => ({ getExtension: () => null }));
setContext(probe);
const addListener = jest.spyOn(window, "addEventListener");
@@ -539,7 +542,7 @@ describe("HeroField: rolagem e visibilidade", () => {
});
describe("HeroField: navegador sem matchMedia", () => {
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
it("path: no matchMedia at all -> the dive is not started, but the scene still mounts", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<Hero />);
+183 -12
View File
@@ -1,18 +1,22 @@
import { act, render, screen } from "@testing-library/react";
import { rememberSoftwareWebGL } from "@/lib/device";
import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device";
import HeroField, { DIVE_QUERY } from "./HeroField";
type SceneProps = {
visible: boolean;
progressRef: { current: number };
placementRef?: { current: unknown };
stageRef?: { current: unknown };
tier?: string;
labelsRef?: unknown;
onReady: () => void;
onError: () => void;
onEstado?: (estado: number | null) => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
jest.mock("./HeroFieldScene", () => ({
jest.mock("./BlocosScene", () => ({
__esModule: true,
default: function MockScene(props: SceneProps) {
mockScenes.push(props);
@@ -20,7 +24,10 @@ jest.mock("./HeroFieldScene", () => ({
"data-testid": "scene",
});
},
prepareField: () => mockPrepare(),
prepareBlocos: () => {
mockPrepare();
return Promise.resolve();
},
}));
const original = {
@@ -47,14 +54,41 @@ function mockNavigator(values: Record<string, unknown>) {
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
/** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */
function Hero() {
return (
<section className="hero" style={HERO_TAIL}>
<HeroField />
<div
className="hero-stage"
style={{ "--site-header-height": "60px" } as React.CSSProperties}
>
<HeroField />
<div className="hero-piece" />
</div>
</section>
);
}
const STAGE = { left: 0, top: -40, width: 1440, height: 900 };
const PIECE = { left: 760, top: 110, width: 600, height: 620 };
/** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */
const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 };
/** A caixa da peça medida a partir do palco. */
const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 };
function mockLayout() {
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockImplementation(function (this: Element) {
const box = this.classList.contains("hero-stage")
? STAGE
: this.classList.contains("hero-piece")
? PIECE
: { left: 0, top: 0, width: 0, height: 0 };
return { ...box, x: box.left, y: box.top } as DOMRect;
});
}
const section = () => document.querySelector(".hero") as HTMLElement;
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
@@ -83,6 +117,7 @@ async function idle() {
beforeEach(() => {
jest.useFakeTimers();
mockLayout();
sessionStorage.clear();
mockScenes.length = 0;
mockPrepare.mockClear();
@@ -101,6 +136,7 @@ beforeEach(() => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
sessionStorage.clear();
window.matchMedia = original.matchMedia;
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
@@ -108,7 +144,7 @@ afterEach(() => {
HTMLCanvasElement.prototype.getContext = original.getContext;
});
describe("HeroField (ambient background)", () => {
describe("HeroField (the 3D blocks over the hero poster)", () => {
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
render(<Hero />);
expect(screen.queryByTestId("scene")).toBeNull();
@@ -175,7 +211,13 @@ describe("HeroField (ambient background)", () => {
});
it.each<[string, () => void]>([
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
[
"a weak phone (touch with 5 cores)",
() => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 5 });
},
],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
@@ -187,13 +229,14 @@ describe("HeroField (ambient background)", () => {
undefined;
},
],
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
// os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática
[
"a software WebGL found earlier in the session",
() => rememberSoftwareWebGL(),
],
["a slow scene found earlier in the session", () => rememberSlowScene()],
])(
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
"keeps only the poster (no WebGL, no dive, no scroll work) with %s",
async (_label, arrange) => {
arrange();
const { container } = render(<Hero />);
@@ -211,7 +254,7 @@ describe("HeroField (ambient background)", () => {
},
);
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p", async () => {
const { container } = render(<Hero />);
const hero = container.querySelector(".hero") as HTMLElement;
expect(hero).toHaveAttribute("data-dive");
@@ -237,15 +280,13 @@ describe("HeroField (ambient background)", () => {
scroll(700); // meio do mergulho (1400 px de trilho)
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
// a máscara já se abriu por completo: o fundo aparece inteiro
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
scroll(1400);
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
expect(mockScenes.at(-1)?.visible).toBe(false);
});
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
it("does not start the dive on a short screen (the CSS does not apply there), but the scene still runs", async () => {
mockMedia([]);
render(<Hero />);
@@ -277,4 +318,134 @@ describe("HeroField (ambient background)", () => {
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
});
it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => {
const { container } = render(<Hero />);
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("full");
expect(scene?.placementRef?.current).toEqual(PLACEMENT);
const area = scene?.stageRef?.current as typeof STAGE_AREA;
for (const key of ["x", "y", "width", "height"] as const)
expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6);
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "full");
expect(field.getAttribute("style")).toBeNull();
});
it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
expect(section()).not.toHaveAttribute("data-dive");
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("lite");
expect(scene?.placementRef).toBeUndefined();
expect(scene?.stageRef).toBeUndefined();
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "lite");
expect(field.style).toMatchObject({
left: "760px",
top: "150px",
width: "600px",
height: "620px",
});
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
});
it("lite: falls back to the poster when the scene gives up", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
act(() => mockScenes.at(-1)?.onError());
const field = container.querySelector(".hero-field") as HTMLElement;
expect(screen.queryByTestId("scene")).toBeNull();
expect(field).not.toHaveAttribute("data-ready");
expect(field).not.toHaveAttribute("data-tier");
// de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha
expect([field.style.left, field.style.width]).toEqual(["", ""]);
});
/** A legenda da peça, se existir. */
const caption = (container: HTMLElement) =>
container.querySelector<HTMLElement>(".hero-legenda");
const tell = (estado: number | null) =>
act(() => mockScenes.at(-1)?.onEstado?.(estado));
it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => {
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(0);
expect(caption(container)).toBeNull();
for (const [estado, texto] of [
[1, "Sites e sistemas"],
[2, "Integrações"],
[3, "Painéis e dados"],
] as const) {
tell(estado);
const legenda = caption(container) as HTMLElement;
expect(legenda).toHaveTextContent(texto);
expect(legenda).toHaveAttribute("data-shown");
}
const legenda = caption(container) as HTMLElement;
// 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22%
// da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2)
// (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam)
expect(Number.parseFloat(legenda.style.left)).toBeCloseTo(
892 + (468 * (1 - 14.5 / 17)) / 2,
3,
);
expect(Number.parseFloat(legenda.style.top)).toBeCloseTo(
460 + (468 * 12) / 17 / 2 + 12,
3,
);
// dentro do campo decorativo
expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull();
// cada texto novo é digitado (.ds-type, um passo por caractere)
const typed = legenda.querySelector<HTMLElement>(".ds-type");
expect(typed?.style.getPropertyValue("--n")).toBe("15");
// de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga
tell(0);
expect(caption(container)).not.toHaveAttribute("data-shown");
tell(2);
tell(null);
expect(caption(container)).not.toHaveAttribute("data-shown");
});
it("shows no caption before the scene is ready, nor after it gives up", async () => {
const { container } = render(<Hero />);
await idle();
tell(1);
expect(caption(container)).not.toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onReady());
expect(caption(container)).toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onError());
expect(caption(container)).toBeNull();
});
it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(1);
expect(caption(container)?.style).toMatchObject({
left: "0px",
top: "632px",
});
expect(caption(container)).toHaveClass("max-lg:hidden");
});
});
+190 -42
View File
@@ -2,13 +2,21 @@
import {
type ComponentType,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos";
import {
isKnownSlowScene,
isKnownSoftwareWebGL,
type SceneTier,
sceneTier,
} from "@/lib/device";
import type { BlocosSceneProps } from "./BlocosScene";
import "./hero-legenda.css";
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
export const DIVE_QUERY =
@@ -28,6 +36,13 @@ function heroTailScreens(section: HTMLElement) {
const IDLE_SLACK_MS = 20;
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
const IDLE_PATIENCE_MS = 4000;
/** Distância (px) entre a caixa da peça e a legenda abaixo dela. */
const LEGENDA_GAP = 12;
/**
* Recuo da legenda no full, em fração da largura da caixa dos estados: tela, integrações e dados têm até 14,5 das 17
* unidades dela, centrados (3d/blocos.py), e a legenda começa onde eles começam.
*/
const LEGENDA_RECUO = (1 - 14.5 / 17) / 2;
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
const FIRST_ACTIVITY = [
"pointerdown",
@@ -38,15 +53,10 @@ const FIRST_ACTIVITY = [
"scroll",
] as const;
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);
};
/**
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
* no contexto que ela abre para desenhar (createRenderer em BlocosScene); o veredito "por software" fica anotado na
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
*/
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
@@ -91,21 +101,86 @@ function whenIdle(run: () => void) {
}
/**
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, os ativos (bloco, estados e
* matcaps, baixados num intervalo ocioso) e só então o canvas. Devolve `null` se a tela foi desmontada no meio do
* caminho.
*/
async function loadScene(isAlive: () => boolean) {
const scene = await import("./HeroFieldScene");
const scene = await import("./BlocosScene");
await nextIdle();
if (!isAlive()) return null;
scene.prepareField();
await scene.prepareBlocos();
await nextIdle();
return isAlive() ? scene.default : null;
}
/**
* Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho) e --hero-open (0 em
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
* Mede, em relação ao palco que a cena cobre (o pai do elemento raiz, onde ele fica com `inset: 0`), a peça do hero
* (o pôster, `.hero-piece`) e a área útil do mergulho: 6% a 94% da largura, do cabeçalho (`--site-header-height`) mais
* 24 px até 70% da altura, onde começam as legendas e a faixa escura delas. Mede na montagem, quando a entrada da peça
* acaba (`.hero-in` anima o deslocamento) e a cada mudança de tamanho do hero, um quadro depois. Sem a peça, `null`.
* Diz também o tamanho do palco. Devolve a função que desfaz tudo.
*/
function trackPiece(
root: HTMLElement,
section: HTMLElement,
onChange: (
piece: Rect | null,
stage: Rect | null,
size: { width: number; height: number },
) => void,
) {
let frame = 0;
const measure = () => {
frame = 0;
const piece = section.querySelector<HTMLElement>(".hero-piece");
const stage = root.parentElement;
if (!stage) return onChange(null, null, { width: 0, height: 0 });
const origin = stage.getBoundingClientRect();
const header =
Number.parseFloat(
getComputedStyle(stage).getPropertyValue("--site-header-height"),
) || 0;
const top = header + 24;
const area = {
x: origin.width * 0.06,
y: top,
width: origin.width * 0.88,
height: Math.max(0, origin.height * 0.7 - top),
};
const size = { width: origin.width, height: origin.height };
if (!piece) return onChange(null, area, size);
const box = piece.getBoundingClientRect();
onChange(
{
x: box.left - origin.left,
y: box.top - origin.top,
width: box.width,
height: box.height,
},
area,
size,
);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue);
observer?.observe(section);
if (!observer) window.addEventListener("resize", queue);
section.addEventListener("animationend", queue);
measure();
return () => {
cancelAnimationFrame(frame);
observer?.disconnect();
window.removeEventListener("resize", queue);
section.removeEventListener("animationend", queue);
};
}
/**
* Liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho) e marca o hero com data-dive, que
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
*/
function trackDive(
@@ -129,8 +204,6 @@ function trackDive(
if (p === progress.current) return;
progress.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
onDone(p >= 0.999);
};
const queue = () => {
@@ -145,7 +218,6 @@ function trackDive(
window.removeEventListener("resize", queue);
delete section.dataset.dive;
section.style.removeProperty("--hero-p");
section.style.removeProperty("--hero-open");
};
}
@@ -168,43 +240,71 @@ function trackVisibility(
};
}
type Mounted = {
Scene: ComponentType<BlocosSceneProps>;
tier: Exclude<SceneTier, "static">;
};
/**
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
* é das molduras.
* A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o
* primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho
* por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do
* Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote,
* ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS):
* - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do
* hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das
* legendas.
* - `lite` (celular capaz): sem mergulho; o canvas ocupa só a caixa da peça e troca de estado sozinho.
* Com a cena pronta, uma legenda abaixo da caixa da peça diz o que ela está montando (`legendaDe`, avisada pela cena
* em `onEstado`): nenhuma na marca nem fora da caixa, no mergulho.
* O desenho por software e a cena lenta só se descobrem no contexto que a cena abre (nunca num contexto de teste na
* hidratação): ela avisa por `onError` e o hero volta ao pôster.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const placementRef = useRef<Rect | null>(null);
// a área útil do mergulho (full): entre o cabeçalho e as legendas
const stageRef = useRef<Rect | null>(null);
const giveUp = useRef(() => {});
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
const [mounted, setMounted] = useState<Mounted | null>(null);
// no lite, a caixa da peça e do canvas (em estilo em linha); no full, a caixa dos estados: a legenda vai abaixo dela
const [box, setBox] = useState<Rect | null>(null);
// a última legenda (o texto fica enquanto ela some) e quantas vezes entrou (cada entrada digita de novo)
const [legenda, setLegenda] = useState<{ texto: string; vez: number } | null>(
null,
);
const [legendaOn, setLegendaOn] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
// fim do mergulho: a cena parou nos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
const tier = sceneTier();
if (tier === "static") return;
const root = rootRef.current;
const section = root?.closest<HTMLElement>(".hero");
if (!root || !section) return;
// sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, já na
// hidratação, sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai
// do contexto da cena, ocioso, sem uma tarefa longa na hidratação); se ela desistir, avisa e o hero volta ao pôster
if (!hasWebGL2() || isKnownSoftwareWebGL() || isKnownSlowScene()) return;
let alive = true;
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopDive =
tier === "full" && window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopVisibility = trackVisibility(section, setVisible);
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
const stopPiece = trackPiece(root, section, (piece, stage, size) => {
// no full, a legenda vai sob a caixa dos estados (onde a cena põe tela, integrações e dados no topo)
setBox(piece && tier === "full" ? caixaDosEstados(piece, size) : piece);
if (tier === "lite") return;
placementRef.current = piece;
stageRef.current = stage;
});
// com o pacote que não carrega, sem WebGL ou com o contexto perdido: de volta ao pôster
giveUp.current = () => {
stopDive?.();
if (alive) setFailed(true);
@@ -213,7 +313,7 @@ export default function HeroField() {
loadScene(() => alive)
.then((component) => {
if (!alive) return;
if (component) setScene(() => component);
if (component) setMounted({ Scene: component, tier });
else giveUp.current();
})
.catch(() => giveUp.current());
@@ -222,28 +322,76 @@ export default function HeroField() {
alive = false;
stopWaiting();
stopVisibility();
stopPiece();
stopDive?.();
};
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => giveUp.current(), []);
const handleEstado = useCallback((estado: number | null) => {
const texto = legendaDe(estado);
setLegendaOn(texto !== null);
if (texto)
setLegenda((anterior) => ({ texto, vez: (anterior?.vez ?? 0) + 1 }));
}, []);
const scene = mounted && !failed ? mounted : null;
const lite = scene?.tier === "lite";
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-ready={ready && !failed ? "" : undefined}
data-tier={scene?.tier}
data-ready={ready && scene ? "" : undefined}
style={
lite && box
? {
left: box.x,
top: box.y,
width: box.width,
height: box.height,
}
: undefined
}
>
{Scene && !failed && (
<Scene
{scene && (
<scene.Scene
progressRef={progressRef}
placementRef={lite ? undefined : placementRef}
stageRef={lite ? undefined : stageRef}
tier={scene.tier}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
onEstado={handleEstado}
/>
)}
{/* abaixo de 1024 px a peça fica no canto, na linha do rótulo, e a legenda cairia sobre o título: só de 1024 em diante */}
{scene && legenda && box && (
<p
className="hero-legenda absolute flex max-lg:hidden items-center gap-sp-3 whitespace-nowrap font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]"
data-shown={ready && legendaOn ? "" : undefined}
style={
lite
? { left: 0, top: box.height + LEGENDA_GAP }
: {
left: box.x + box.width * LEGENDA_RECUO,
top: box.y + box.height + LEGENDA_GAP,
}
}
>
<span className="h-2 w-2 shrink-0 bg-[var(--slash)]" />
<span
key={legenda.vez}
className="ds-type"
style={{ "--n": legenda.texto.length } as CSSProperties}
>
{legenda.texto}
</span>
</p>
)}
</div>
);
}
@@ -1,101 +0,0 @@
import { act, render } from "@testing-library/react";
import HeroFieldScene from "./HeroFieldScene";
/**
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
* compila, sucesso) estão em HeroFieldScene.test.tsx.
*/
const mockRenderers: MockRenderer[] = [];
let mockBuildThrows = false;
class MockRenderer {
domElement = document.createElement("canvas");
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = jest.fn();
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
getContext = () => ({ getExtension: () => null });
getPixelRatio = () => 1;
constructor() {
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const actual = jest.requireActual("three");
class FailingCamera extends actual.PerspectiveCamera {
constructor(...args: unknown[]) {
if (mockBuildThrows) throw new Error("sem memória para a cena");
super(...args);
}
}
return {
...actual,
PerspectiveCamera: FailingCamera,
WebGLRenderer: jest.fn(() => new MockRenderer()),
};
});
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockBuildThrows = false;
});
afterEach(() => {
jest.useRealTimers();
});
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
const buildScene = () =>
act(async () => {
await jest.advanceTimersByTimeAsync(10);
});
describe("mountField: erro antes de as peças existirem", () => {
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
mockBuildThrows = true;
const scene = props();
const { container } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
const scene = props();
const { container, unmount } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).not.toBeNull();
expect(scene.onError).not.toHaveBeenCalled();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
});
});
@@ -1,957 +0,0 @@
import {
type BufferGeometry,
type Group,
type PerspectiveCamera,
type Plane,
Ray,
type ShaderMaterial,
type Vector3,
} from "three";
import {
createField,
type FieldEngine,
isSettling,
prepareField,
type Rig,
type Tick,
updatePointer,
updateRig,
} from "./HeroFieldScene";
/**
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
* - perda do contexto WebGL, falha ao desenhar e limpeza.
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
* que o teste escolhe.
*/
const mockMaterials: ShaderMaterial[] = [];
const mockGeometries: BufferGeometry[] = [];
const mockCameras: PerspectiveCamera[] = [];
const mockGroups: Group[] = [];
const mockCounts = { vectors: 0 };
const mockRenderers: MockRenderer[] = [];
let mockRenderThrows = false;
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 2;
render = jest.fn(() => {
if (mockRenderThrows) throw new Error("falha ao desenhar");
});
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = jest.fn();
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
getContext = () => ({ getExtension: () => null });
getPixelRatio = () => this.pixelRatio;
constructor() {
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const actual = jest.requireActual("three");
class CapturingMaterial extends actual.ShaderMaterial {
constructor(parameters: unknown) {
super(parameters);
mockMaterials.push(this as unknown as ShaderMaterial);
}
}
class CapturingGeometry extends actual.BufferGeometry {
constructor() {
super();
mockGeometries.push(this as unknown as BufferGeometry);
}
}
class CapturingCamera extends actual.PerspectiveCamera {
constructor(...args: unknown[]) {
super(...args);
mockCameras.push(this as unknown as PerspectiveCamera);
}
}
class CapturingGroup extends actual.Group {
constructor() {
super();
mockGroups.push(this as unknown as Group);
}
}
class CountingVector3 extends actual.Vector3 {
constructor(x?: number, y?: number, z?: number) {
super(x, y, z);
mockCounts.vectors += 1;
}
}
return {
...actual,
WebGLRenderer: jest.fn(() => new MockRenderer()),
ShaderMaterial: CapturingMaterial,
BufferGeometry: CapturingGeometry,
PerspectiveCamera: CapturingCamera,
Group: CapturingGroup,
Vector3: CountingVector3,
};
});
type Setup = {
width?: number;
height?: number;
/** `null` simula uma ref sem valor (o código trata como 0). */
progress?: number | null;
visible?: boolean;
};
let engines: FieldEngine[] = [];
let frameRequests: jest.SpyInstance;
function setup({
width = 1200,
height = 800,
progress = 0,
visible = true,
}: Setup = {}) {
const host = document.createElement("div");
Object.defineProperty(host, "clientWidth", { value: width });
Object.defineProperty(host, "clientHeight", { value: height });
document.body.append(host);
const progressRef = {
current: progress === null ? undefined : progress,
} as { current: number };
const onReady = jest.fn();
const onError = jest.fn();
const engine = createField(host, { progressRef, onReady, onError });
engines.push(engine);
engine.setVisible(visible);
const renderer = mockRenderers[mockRenderers.length - 1];
const camera = mockCameras[mockCameras.length - 1];
const group = mockGroups[mockGroups.length - 1];
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
let clock = 1000;
/**
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
*/
function frame(deltaMs = 1000 / 60) {
frameRequests.mockClear();
clock += deltaMs;
engine.renderAt(clock);
return frameRequests.mock.calls.length;
}
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
function settle(count: number, deltaMs = 50) {
let asked = 0;
for (let i = 0; i < count; i++) asked = frame(deltaMs);
return asked;
}
return {
host,
engine,
renderer,
camera,
group,
progressRef,
onReady,
onError,
uniforms,
frame,
settle,
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
now: () => clock,
};
}
const originalHardware = Object.getOwnPropertyDescriptor(
navigator,
"hardwareConcurrency",
);
function setCores(value: number | undefined) {
Object.defineProperty(navigator, "hardwareConcurrency", {
configurable: true,
value,
});
}
function setWindowSize(width: number, height = 800) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: height,
});
}
function pointerMove(type: string, x = 600, y = 400) {
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
Object.defineProperty(event, "pointerType", { value: type });
window.dispatchEvent(event);
}
beforeEach(() => {
mockCounts.vectors = 0;
mockMaterials.length = 0;
mockGeometries.length = 0;
mockCameras.length = 0;
mockGroups.length = 0;
mockRenderers.length = 0;
mockRenderThrows = false;
engines = [];
setCores(8);
setWindowSize(1200);
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
jest.spyOn(performance, "now").mockReturnValue(1000);
frameRequests = jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(() => 1 as unknown as number);
});
afterEach(() => {
for (const engine of engines) engine.dispose();
jest.restoreAllMocks();
document.body.innerHTML = "";
if (originalHardware)
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
});
describe("createField: quadro — começo e rolagem", () => {
it("path: onReady fires on the second frame, once", () => {
const scene = setup();
scene.frame();
expect(scene.onReady).not.toHaveBeenCalled();
scene.frame();
expect(scene.onReady).toHaveBeenCalledTimes(1);
scene.frame();
scene.frame();
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
const scene = setup({ progress: 1 });
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
});
it("path: no progress available (undefined) -> treated as the top of the page", () => {
const scene = setup({ progress: null });
scene.settle(5);
expect(scene.uniforms().uScroll.value).toBe(0);
});
it("path: the intro advances every frame and stops at 1", () => {
const scene = setup();
scene.frame(50); // o primeiro quadro: 1/60 de segundo
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
scene.frame(50);
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
scene.settle(80);
expect(scene.uniforms().uIntro.value).toBe(1);
});
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
const scene = setup({ progress: 0 });
scene.settle(10);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 0.9;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 1;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
});
it("path: layers spread apart only while they face the viewer", () => {
const scene = setup({ progress: 0 });
scene.settle(5);
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
scene.progressRef.current = 0.5;
scene.settle(150);
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
});
it("path: the clock reaches the shader in seconds since the scene started", () => {
const scene = setup();
scene.frame(0);
scene.frame(2000);
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
});
});
describe("createField: enquadramento e foco", () => {
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
const scene = setup({ width: 1200, height: 800 });
scene.frame();
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
expect(scene.camera.position.z).toBeCloseTo(9, 9);
});
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
const scene = setup({ width: 400, height: 800 });
scene.frame();
expect(scene.group.position.y).toBeLessThan(-0.5);
expect(scene.camera.position.z).toBeGreaterThan(9);
});
it("path: the renderer and the camera follow the size of the host", () => {
const scene = setup({ width: 900, height: 300 });
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
expect(scene.camera.aspect).toBeCloseTo(3, 9);
});
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
const scene = setup({ width: 0, height: 0 });
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
});
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.group.position.y).toBeCloseTo(0, 6);
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
expect(scene.camera.position.z).toBeLessThan(0);
expect(scene.camera.position.z).toBeGreaterThan(-spread);
expect(scene.camera.position.z + spread).toBeLessThan(6);
});
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
const scene = setup({ progress: 0 });
scene.frame();
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
});
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.uniforms().uFocus.value).toBeCloseTo(
scene.camera.position.z + spread,
6,
);
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
});
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
const scene = setup({ progress: 0.55 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
const z = scene.camera.position.z;
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
expect(z).toBeGreaterThan(0.2);
expect(z).toBeLessThan(spread);
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
});
});
describe("createField: mouse", () => {
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
const scene = setup();
pointerMove("touch");
pointerMove("pen");
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
scene.settle(120);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
const scene = setup();
scene.frame(); // o relógio da cena começa
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
});
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
const scene = setup();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
const scene = setup();
(performance.now as jest.Mock).mockReturnValue(0);
pointerMove("mouse");
for (let i = 0; i < 30; i++) scene.frame(50);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: cursor strength fades to 0 once the dive is under way", () => {
const scene = setup({ progress: 1 });
scene.frame();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 200; i++) scene.frame(5);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
});
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
const scene = setup();
jest
.spyOn(Ray.prototype, "intersectPlane")
.mockImplementation(((_plane: Plane, target: Vector3) =>
target.set(1, 0, 2)) as never);
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
scene.group.worldToLocal = ((point: Vector3) => point) as never;
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
const before = scene.uniforms().uMouse.value.clone();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame(50);
const factor = 1 - Math.exp(-(1 / 20) * 10);
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
before.x + (1 - before.x) * factor,
6,
);
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
before.y + (2 - before.y) * factor,
6,
);
});
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
const scene = setup();
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
const before = scene.uniforms().uMouse.value.clone();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame(50);
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
});
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
const scene = setup();
scene.frame(0);
scene.frame(9000);
const { x, y } = scene.uniforms().uMouse.value;
expect(Math.hypot(x, y)).toBeGreaterThan(0);
});
});
describe("createField: quando pedir o próximo quadro", () => {
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
const scene = setup({ visible: false });
expect(scene.frame()).toBeGreaterThan(0);
});
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
const scene = setup({ visible: true });
scene.settle(200);
expect(scene.frame(50)).toBe(0);
});
it("path: everything settled and the hero is not visible -> no frame", () => {
const scene = setup({ visible: false });
scene.settle(200);
expect(scene.frame(50)).toBe(0);
});
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
const scene = setup({ visible: true });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
const drawn = scene.renderer.render.mock.calls.length;
expect(scene.frame(50)).toBe(0);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
const scene = setup({ visible: false });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
});
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
const scene = setup({ visible: false });
scene.settle(200);
scene.progressRef.current = 0.5;
expect(scene.frame()).toBeGreaterThan(0);
});
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
const scene = setup({ visible: false });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
frameRequests.mockClear();
scene.engine.setVisible(true);
expect(frameRequests).toHaveBeenCalledTimes(1);
});
it("path: several requests before a frame become ONE animation frame", () => {
const scene = setup({ visible: true });
scene.settle(200);
frameRequests.mockClear();
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
scene.engine.setVisible(false);
scene.engine.setVisible(true);
scene.engine.setVisible(false);
scene.engine.setVisible(true);
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
});
});
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
function rig(): Rig {
const scene = setup();
scene.frame();
const material = mockMaterials[mockMaterials.length - 1];
return {
group: scene.group,
camera: scene.camera,
uniforms: material.uniforms,
layout: {
wide: true,
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
spreadMax: 5.5,
rest: 9,
scale: 1000,
camera: [
[0, 9],
[1, -1],
],
} as unknown as Rig["layout"],
state: {
progress: 0,
pointer: { set() {}, x: 0, y: 0 } as never,
lastMove: -10,
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
tilt: { lerp() {}, x: 0, y: 0 } as never,
frames: 10,
},
helpers: {} as Rig["helpers"],
};
}
const tick = (overrides: Partial<Tick> = {}): Tick => ({
p: 0,
face: 0,
time: 1,
dt: 1 / 60,
cursorActive: false,
...overrides,
});
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
r.uniforms.uIntro.value = 0.5;
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
});
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.uniforms.uMouseAmt.value = 0;
r.state.frames = 1;
r.state.lastMove = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
});
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
const r = rig();
r.helpers = {
target: { set: () => ({}), x: 0, y: 0 },
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
plane: { set: () => ({ applyMatrix4() {} }) },
up: {},
hit: {},
} as never;
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
});
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
const r = rig();
updateRig(r, tick({ face: 0 }));
const resting = r.group.rotation.x;
updateRig(r, tick({ face: 1 }));
expect(resting).toBeCloseTo(0.42, 9);
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
// de frente, o balanço lateral some
expect(r.group.rotation.y).toBeCloseTo(0, 9);
});
it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.state.progress = 0.5;
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
});
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
const r = rig();
updateRig(r, tick({ p: -0.5 }));
expect(r.camera.position.z).toBe(9);
updateRig(r, tick({ p: 0.5 }));
expect(r.camera.position.z).toBeCloseTo(4, 9);
updateRig(r, tick({ p: 2 }));
expect(r.camera.position.z).toBe(-1);
});
});
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
prepareField();
expect(mockGeometries).toHaveLength(1);
const prepared = mockGeometries[0];
prepareField(); // de novo, sem montar: nada é refeito
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(1);
expect(mockGeometries[0]).toBe(prepared);
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
});
it("path: the preparation is used once; a second scene builds its own points", () => {
prepareField();
setup();
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(2);
});
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
const scene = setup();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame();
const before = mockCounts.vectors;
for (let i = 0; i < 60; i++) scene.frame();
// nenhum Vector3 novo: os auxiliares são criados uma vez só
expect(mockCounts.vectors).toBe(before);
});
});
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
const pointCount = () => {
const geometry = mockGeometries[mockGeometries.length - 1];
return geometry.getAttribute("position").count;
};
it("path: wide window and 4+ cores -> the dense field", () => {
setWindowSize(1200);
setCores(8);
setup();
const dense = pointCount();
mockGeometries.length = 0;
setWindowSize(600);
setup();
expect(dense).toBeGreaterThan(pointCount());
});
it("path: wide window but 2 cores -> the light field", () => {
setWindowSize(1200);
setCores(2);
setup();
const light = pointCount();
mockGeometries.length = 0;
setCores(8);
setup();
expect(pointCount()).toBeGreaterThan(light);
});
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
setWindowSize(1200);
const counts: Record<string, number> = {};
for (const [label, cores] of [
["dense", 8],
["light", 2],
["unknown", undefined],
] as const) {
mockGeometries.length = 0;
setCores(cores);
setup();
counts[label] = pointCount();
}
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
Math.abs(counts.unknown - counts.light),
);
});
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
const scene = setup();
const event = new Event("webglcontextlost", { cancelable: true });
scene.renderer.domElement.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(scene.onError).toHaveBeenCalledTimes(1);
});
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
const scene = setup();
mockRenderThrows = true;
expect(() => scene.frame()).not.toThrow();
expect(scene.onError).toHaveBeenCalled();
});
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
const scene = setup();
const geometry = mockGeometries[mockGeometries.length - 1];
const material = mockMaterials[mockMaterials.length - 1];
const disposeGeometry = jest.spyOn(geometry, "dispose");
const disposeMaterial = jest.spyOn(material, "dispose");
const event = new Event("webglcontextlost", { cancelable: true });
scene.engine.dispose();
scene.renderer.domElement.dispatchEvent(event);
pointerMove("mouse");
expect(disposeGeometry).toHaveBeenCalledTimes(1);
expect(disposeMaterial).toHaveBeenCalledTimes(1);
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
expect(scene.renderer.domElement.isConnected).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(scene.onError).not.toHaveBeenCalled();
});
it("path: a frame after dispose does nothing", () => {
const scene = setup();
scene.engine.dispose();
scene.renderer.render.mockClear();
scene.frame();
expect(scene.renderer.render).not.toHaveBeenCalled();
});
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
const original = globalThis.ResizeObserver;
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
globalThis.ResizeObserver = undefined;
try {
const scene = setup({ width: 600, height: 400 });
scene.renderer.setSize.mockClear();
window.dispatchEvent(new Event("resize"));
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
} finally {
globalThis.ResizeObserver = original;
}
});
});
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
function drawnOn(
scene: ReturnType<typeof setup>,
refreshMs: number,
seconds = 1,
) {
const before = scene.renderer.render.mock.calls.length;
const frames = Math.round((seconds * 1000) / refreshMs);
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
return scene.renderer.render.mock.calls.length - before;
}
/**
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
*/
function pulsedScreen(refreshMs: number) {
let beat = () => {};
jest.spyOn(window, "setInterval").mockImplementation(((
callback: () => void,
) => {
beat = callback;
return 7;
}) as never);
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
let pending: FrameRequestCallback | undefined;
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
pending = callback;
return 1;
}) as never);
const scene = setup({ visible: true });
let clock = 0;
let nextBeat = 33;
const run = (seconds: number) => {
const drawnBefore = scene.renderer.render.mock.calls.length;
frameRequests.mockClear();
const end = clock + seconds * 1000;
for (clock += refreshMs; clock <= end; clock += refreshMs) {
for (; nextBeat <= clock; nextBeat += 33) beat();
const frame = pending;
pending = undefined;
frame?.(1000 + clock);
}
return {
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
requested: frameRequests.mock.calls.length,
};
};
run(6); // a entrada corre a cada quadro da tela e assenta
pointerMove("mouse");
return run;
}
it.each([
[144, 1000 / 144],
[120, 1000 / 120],
[60, 1000 / 60],
])(
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
(_hz, refreshMs) => {
const run = pulsedScreen(refreshMs);
const { drawn, requested } = run(2);
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
expect(drawn).toBeLessThanOrEqual(2 * 31);
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
expect(requested).toBeLessThanOrEqual(drawn + 1);
},
);
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
const run = pulsedScreen(1000 / 144);
const drawn = run(0.5).drawn;
expect(drawn).toBeGreaterThanOrEqual(14);
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
});
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
const scene = setup({ visible: true });
scene.frame(); // a entrada ainda corre
const intro = drawnOn(scene, 1000 / 144, 0.5);
expect(intro).toBe(Math.round(500 / (1000 / 144)));
scene.settle(200); // tudo assentado
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
expect(scrolling).toBe(15);
});
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
const scene = setup({ visible: true });
scene.settle(200);
scene.frame(33);
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
const drawn = scene.renderer.render.mock.calls.length;
scene.frame(33);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
});
describe("createField: a batida de 30 quadros por segundo", () => {
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
const interval = jest
.spyOn(window, "setInterval")
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
const { engine } = setup({ visible: false });
expect(interval).not.toHaveBeenCalled();
engine.setVisible(true);
engine.setVisible(true);
expect(interval).toHaveBeenCalledTimes(1);
engine.setVisible(false);
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
engine.setVisible(true);
expect(interval).toHaveBeenCalledTimes(2);
});
});
@@ -1,471 +0,0 @@
import { act, render } from "@testing-library/react";
import { isKnownSoftwareWebGL } from "@/lib/device";
import HeroFieldScene from "./HeroFieldScene";
/*
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
* existe: o renderizador é um dublê.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
let mockFailToCreate = false;
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => mockCompile());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
if (mockFailToCreate) throw new Error("sem WebGL");
this.options = options;
mockRenderers.push(this);
}
}
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
jest.mock("three", () => ({
...jest.requireActual("three"),
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
}));
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockFailToCreate = false;
mockRendererName = null;
mockCompile = () => Promise.resolve();
setDevicePixelRatio(1);
});
afterEach(() => {
jest.useRealTimers();
});
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
const nextTask = () =>
act(async () => {
await jest.runOnlyPendingTimersAsync();
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
describe("HeroFieldScene (ambient 3D)", () => {
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toMatchObject({
alpha: true,
antialias: false,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
});
it.each([
[0.5, 1],
[1, 1],
[1.25, 1.25],
[2, 1.5],
[3, 1.5],
])(
"caps the pixel ratio at 1.5 (device %s -> %s)",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<HeroFieldScene {...props()} />);
expect(frames).toHaveBeenCalledTimes(1);
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
act(() => {
jest.advanceTimersByTime(5000);
});
const settled = frames.mock.calls.length;
expect(settled).toBeGreaterThan(2);
expect(settled).toBeLessThan(5000 / 16);
// hero fora de cena e tudo assentado: nenhum quadro a mais
act(() => {
jest.advanceTimersByTime(5000);
});
expect(frames.mock.calls.length).toBe(settled);
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
});
it("tells the page it is ready after the second frame", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onReady).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(100);
});
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<HeroFieldScene {...scene} visible={false} />,
);
act(() => {
jest.advanceTimersByTime(6000);
});
const hidden = frames.mock.calls.length;
expect(jest.getTimerCount()).toBe(0);
rerender(<HeroFieldScene {...scene} visible />);
act(() => {
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
});
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
act(() => {
jest.advanceTimersByTime(980);
});
const shown = frames.mock.calls.length - hidden;
expect(shown).toBeGreaterThan(20);
expect(shown).toBeLessThanOrEqual(34);
rerender(<HeroFieldScene {...scene} visible={false} />);
const stopped = frames.mock.calls.length;
act(() => {
jest.advanceTimersByTime(2000);
});
expect(frames.mock.calls.length).toBe(stopped);
expect(jest.getTimerCount()).toBe(0);
});
it("releases the context, the canvas and the timers when it unmounts", async () => {
const scene = props();
const { container, unmount } = await mount(
<HeroFieldScene {...scene} visible />,
);
expect(container.querySelector("canvas")).not.toBeNull();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(jest.getTimerCount()).toBe(0);
const drawn = mockRenderers[0].render.mock.calls.length;
act(() => {
jest.advanceTimersByTime(1000);
});
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
const first = props();
const { rerender } = await mount(<HeroFieldScene {...first} />);
const second = props();
rerender(
<HeroFieldScene
progressRef={first.progressRef}
visible={false}
onReady={second.onReady}
onError={second.onError}
/>,
);
expect(mockRenderers).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(100);
});
expect(first.onReady).not.toHaveBeenCalled();
expect(second.onReady).toHaveBeenCalledTimes(1);
});
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
mockFailToCreate = true;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it.each([
[0, 1], // aparelho que não informa a proporção: 1
[0.5, 1], // nunca abaixo de 1
[1.25, 1.25],
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
])(
"draws at a pixel ratio of %p -> the renderer gets %p",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
describe("built in separate tasks (no single long task)", () => {
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
render(<HeroFieldScene {...scene} />);
// nada acontece dentro do commit do React: nem o contexto WebGL
expect(mockRenderers).toHaveLength(0);
await nextTask();
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(frames).not.toHaveBeenCalled();
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
finishCompile();
await act(async () => {
await Promise.resolve();
});
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
expect(mockRenderers[0].setSize).toHaveBeenCalled();
expect(frames).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].render).not.toHaveBeenCalled();
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("compiles the very scene it is going to draw", async () => {
await mount(<HeroFieldScene {...props()} />);
const [scene, camera] = mockRenderers[0].compileAsync.mock
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
expect(scene.isScene).toBe(true);
expect(camera.isCamera).toBe(true);
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
});
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
const scene = props();
const { unmount } = render(<HeroFieldScene {...scene} />);
unmount();
await buildScene();
expect(mockRenderers).toHaveLength(0);
expect(scene.onError).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
const { container, unmount } = render(<HeroFieldScene {...props()} />);
await nextTask();
expect(mockRenderers).toHaveLength(1);
unmount();
await buildScene();
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
});
it("releases the context if it is unmounted while the shader compiles", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const { container, unmount } = await mount(
<HeroFieldScene {...props()} />,
);
expect(mockRenderers).toHaveLength(1);
unmount();
finishCompile();
await buildScene();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(frames).not.toHaveBeenCalled();
});
it("tells the page, and frees the context, when the shader does not compile", async () => {
mockCompile = () => Promise.reject(new Error("shader inválido"));
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it("applies the visibility the page asked for while it was still being built", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = render(
<HeroFieldScene {...scene} visible={false} />,
);
rerender(<HeroFieldScene {...scene} visible />);
await buildScene();
await act(async () => {
await jest.advanceTimersByTimeAsync(980);
});
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
expect(frames.mock.calls.length).toBeGreaterThan(20);
});
});
});
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
beforeEach(() => sessionStorage.clear());
afterEach(() => sessionStorage.clear());
it.each([
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"llvmpipe (LLVM 15.0.7, 256 bits)",
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
])(
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
async (name) => {
mockRendererName = name;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
},
);
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName =
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
it("does not report an unknown renderer (no debug extension) as software", async () => {
mockRendererName = null;
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
* segue a variável), não o texto do CSS.
*/
jest.mock("./HeroFieldScene", () => ({
jest.mock("./BlocosScene", () => ({
__esModule: true,
default: () => null,
prepareField: () => {},
prepareBlocos: () => Promise.resolve(),
}));
const win = window as unknown as Record<string, unknown>;
+23
View File
@@ -0,0 +1,23 @@
/* Legenda da peça do hero (HeroField): o que a peça está montando. Cada texto novo entra digitado, com o passo e o
cursor do rótulo das seções (.ds-type e as animações ds-type, ds-type-caret e ds-type-blink, em globals.css); ela
some só pela opacidade. Sem movimento reduzido: com ele a cena nem monta, e a legenda não existe. */
.hero-legenda {
opacity: 0;
transition: opacity 0.3s var(--ease-out);
}
.hero-legenda[data-shown] {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
.hero-legenda .ds-type {
animation: ds-type calc(var(--n) * 32ms) steps(var(--n)) forwards;
}
.hero-legenda .ds-type::after {
animation:
ds-type-caret calc(var(--n) * 32ms) steps(var(--n)) forwards,
ds-type-blink 0.9s steps(1) calc(var(--n) * 32ms) forwards;
}
}
+2
View File
@@ -25,7 +25,9 @@ describe("contact-subject", () => {
expect(CONTACT_KINDS).toEqual({
site: "Site que traz pedidos pelo WhatsApp",
sistema: "Sistema para tirar a operação da planilha",
whatsapp: "Atendimento no WhatsApp com IA sob controle",
revisao: "Revisão de sistema que já existe",
fiscal: "Nota fiscal pronta para a reforma tributária",
});
});
+5 -1
View File
@@ -15,24 +15,28 @@ import { offerLabels } from "@/lib/offer-labels";
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
/**
* As três ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* As cinco ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
*/
export const CONTACT_SUBJECT_OPTIONS = [
DEFAULT_CONTACT_SUBJECT,
offerLabels.site.name,
offerLabels.sistema.name,
offerLabels.whatsapp.name,
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
offerLabels.revisao.name,
offerLabels.fiscal.name,
] as const;
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
export const CONTACT_KINDS = {
site: offerLabels.site.name,
sistema: offerLabels.sistema.name,
whatsapp: offerLabels.whatsapp.name,
revisao: offerLabels.revisao.name,
fiscal: offerLabels.fiscal.name,
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
export type ContactKind = keyof typeof CONTACT_KINDS;
+69
View File
@@ -0,0 +1,69 @@
import { act } from "@testing-library/react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
describe("isFiscalNoticeActive", () => {
it("shows the notice up to 2027-03-31 in Brasília time and hides it from 2027-04-01 on", () => {
expect(isFiscalNoticeActive(new Date("2026-11-09T12:00:00-03:00"))).toBe(
true,
);
expect(isFiscalNoticeActive(new Date("2027-03-31T23:59:59-03:00"))).toBe(
true,
);
expect(isFiscalNoticeActive(new Date("2027-04-01T00:00:00-03:00"))).toBe(
false,
);
expect(isFiscalNoticeActive(new Date("2027-06-01T12:00:00-03:00"))).toBe(
false,
);
});
});
describe("FiscalNotice", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("path: HTML built before the end, opened after it -> hydrates with no warning, then the notice goes away", async () => {
const html = renderToString(<FiscalNotice href="/servicos/x" />);
expect(html).toContain("Seu sistema emite nota fiscal?");
const container = document.createElement("div");
container.innerHTML = html;
document.body.append(container);
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.useFakeTimers({ now: new Date("2027-04-02T10:00:00-03:00") });
await act(async () => {
hydrateRoot(container, <FiscalNotice href="/servicos/x" />, {
onRecoverableError: (error) => console.error(error),
});
});
expect(errors).not.toHaveBeenCalled();
expect(container.textContent).toBe("");
container.remove();
});
it("path: hydrated within the period -> the notice stays", async () => {
jest.useFakeTimers({ now: new Date("2027-01-10T10:00:00-03:00") });
const html = renderToString(<FiscalNotice href="/servicos/x" />);
const container = document.createElement("div");
container.innerHTML = html;
document.body.append(container);
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
await act(async () => {
hydrateRoot(container, <FiscalNotice href="/servicos/x" />, {
onRecoverableError: (error) => console.error(error),
});
});
expect(errors).not.toHaveBeenCalled();
expect(container.textContent).toContain("Ver o que muda");
container.remove();
});
});
+7
View File
@@ -0,0 +1,7 @@
/** Fim da faixa da nota fiscal na home: ela aparece até 31/03/2027, no horário de Brasília, e some a partir de 1º/04/2027. */
const FISCAL_NOTICE_END = Date.parse("2027-04-01T00:00:00-03:00");
/** A faixa da nota fiscal ainda vale nesta data? */
export function isFiscalNoticeActive(now: Date) {
return now.getTime() < FISCAL_NOTICE_END;
}
+5 -4
View File
@@ -4,10 +4,11 @@
* (globals.css, `.hero-captions li`) as liga e desliga por esses dois valores: uma rampa de 1/14 de progresso para
* entrar a partir de `from` e para sair até `to`.
*
* Os trechos são os da câmera (cameraFrames em cinematic/HeroFieldScene.tsx: a câmera enquadra a camada 1 entre
* 0,28 e 0,44, a 2 entre 0,55 e 0,69 e a 3 entre 0,78 e 0,89), com folga: cada legenda entra um pouco antes de a
* câmera chegar e sai um pouco depois de ela partir. Assim o intervalo sem texto entre uma e outra é de poucos pixels
* de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
* Os trechos seguem os estados da peça (`stateAt` em lib/blocos.ts: troca para a tela entre 0,16 e 0,30, para as
* integrações entre 0,44 e 0,56 e para os dados entre 0,70 e 0,82, parada entre uma troca e outra): cada legenda entra
* durante a troca para o seu estado (a da tela logo no começo dela, com o texto do hero saindo) e sai no meio da troca
* seguinte. Assim o intervalo sem texto entre uma e outra é
* de poucos pixels de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
*/
export const HERO_CAPTIONS = [
{
+30
View File
@@ -0,0 +1,30 @@
/*
* Os quatro blocos lima do símbolo encaixam quando o link que contém o logo recebe o ponteiro ou o foco do teclado:
* sobem cerca de 6% da altura do símbolo e assentam, um depois do outro. A animação nasce com o :hover, então roda
* uma vez por entrada do ponteiro. Só `translate` anima; em SVG o px da transformação é a unidade do viewBox, e o
* símbolo tem 322 unidades de altura (19 = 6%).
*/
@keyframes logo-encaixe {
40% {
translate: 0 -19px;
}
}
.logo-bloco:nth-child(2) {
--logo-atraso: 60ms;
}
.logo-bloco:nth-child(3) {
--logo-atraso: 120ms;
}
.logo-bloco:nth-child(4) {
--logo-atraso: 180ms;
}
@media (prefers-reduced-motion: no-preference) {
a:hover .logo-bloco,
a:focus-visible .logo-bloco {
animation: logo-encaixe 280ms var(--ease-out) var(--logo-atraso, 0ms);
}
}
+38 -10
View File
@@ -9,13 +9,16 @@ import {
useMemo,
useRef,
useState,
ViewTransition,
} from "react";
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
import { isWebKit } from "@/lib/device";
import {
chapterIndexAtScroll,
chapterScrollFraction,
getScrubSource,
getShowcaseAssets,
projectKicker,
videoFractionAtScroll,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
@@ -550,15 +553,17 @@ function ReelLayer({
<div className="ds-reel-screen">
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
{poster && (
<Image
src={desktop.poster}
alt=""
width={desktop.width}
height={desktop.height}
sizes={LAPTOP_POSTER_SIZES}
loading="eager"
fetchPriority="low"
/>
<ActiveCover id={config.id} active={isActive}>
<Image
src={desktop.poster}
alt=""
width={desktop.width}
height={desktop.height}
sizes={LAPTOP_POSTER_SIZES}
loading="eager"
fetchPriority="low"
/>
</ActiveCover>
)}
<video
ref={videoRef}
@@ -592,6 +597,29 @@ function ReelLayer({
);
}
/**
* Só o projeto em cena leva o nome da capa (`case-<id>`): em "Ver case", o pôster do notebook viaja até o topo do
* case. É o pôster, e não o vídeo por cima dele, porque é a mesma imagem do topo do case (o vídeo está no meio da
* rolagem do site). O nome não troca de lugar numa atualização: o React só esquece um nome quando o
* <ViewTransition> sai da árvore, e um nome trocado no lugar ficava preso ao projeto antigo (aviso de nome
* repetido ao abrir a capa dele depois). Por isso o projeto que entra em cena ganha um <ViewTransition> novo,
* e só a imagem remonta (o vídeo fica).
*/
function ActiveCover({
id,
active,
children,
}: {
id: string;
active: boolean;
children: React.ReactNode;
}) {
if (!active) return children;
return (
<ViewTransition {...coverTransitionProps(id)}>{children}</ViewTransition>
);
}
const pad = (value: number) => String(value).padStart(2, "0");
/**
@@ -643,7 +671,7 @@ function ReelCaption({
return (
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
<p className="ds-kicker">
{project.category} · {project.status}
{projectKicker(project.category, project.status)}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
@@ -14,7 +14,8 @@ jest.mock("./ProjectLightboxLazy", () => ({
/**
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente", o botão de ampliar e o pôster como
* elemento compartilhado da troca de página.
*/
jest.mock("./ProjectLightbox", () => ({
@@ -25,6 +26,11 @@ jest.mock("./ProjectLightbox", () => ({
),
}));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
type Callback = IntersectionObserverCallback;
/** Cada observador criado, com o elemento que observa. */
const observers: Array<{ callback: Callback; target?: Element }> = [];
@@ -114,6 +120,19 @@ describe("ProjectList", () => {
expect(images[0]).toHaveAttribute("sizes", "50vw");
});
it("path: poster near the window -> shared element named after the case (case-<id>), one name per item", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
const names = [...container.querySelectorAll("img")].map((image) =>
image.parentElement?.getAttribute("data-vt-name"),
);
expect(names).toEqual(items.map(({ config }) => `case-${config.id}`));
const transition = container.querySelector("img")?.parentElement;
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
+20 -11
View File
@@ -8,8 +8,14 @@ import {
useId,
useRef,
useState,
ViewTransition,
} from "react";
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
import {
type Device,
getShowcaseAssets,
projectKicker,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { ProjectActions } from "./ProjectActions";
@@ -78,17 +84,20 @@ function ListItem({
const ref = useRef<HTMLLIElement>(null);
const near = useNearViewport(ref);
const assets = device ? getShowcaseAssets(config.id, device) : null;
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela. É a mesma capa do topo do
// case: viaja até lá na troca de página (o item tem uma moldura só, então o nome não se repete)
const poster =
assets && near ? (
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
sizes={device === "mobile" ? "160px" : "50vw"}
/>
<ViewTransition {...coverTransitionProps(config.id)}>
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
sizes={device === "mobile" ? "160px" : "50vw"}
/>
</ViewTransition>
) : null;
return (
@@ -111,7 +120,7 @@ function ListItem({
)}
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
{projectKicker(project.category, project.status)}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
@@ -24,6 +24,11 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
// 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 PIN = 800;
const CONTAINER = 7000;
const SCROLLABLE = CONTAINER - PIN;
@@ -313,6 +318,25 @@ describe("ProjectReel", () => {
);
});
it("gives the cover name (case-<id>) to the laptop poster of the project on screen only, without remounting its video", () => {
renderReel();
const selector = ".ds-reel-layer [data-vt-name]";
expect(document.querySelectorAll(selector)).toHaveLength(1);
expect(
activeLayer().querySelector(".ds-reel-screen [data-vt-name] > img")
?.parentElement,
).toHaveAttribute("data-vt-name", "case-sev-modaintima");
const video = document.querySelectorAll(".ds-reel-layer video")[1];
scrollTo(reelOffset(weights, 1, 0.5));
expect(document.querySelectorAll(selector)).toHaveLength(1);
const named = activeLayer().querySelector(selector);
expect(named).toHaveAttribute("data-vt-name", "case-lavacao-047");
expect(named).toHaveAttribute("data-vt-share", "case-cover");
expect(document.querySelectorAll(".ds-reel-layer video")[1]).toBe(video);
});
it("names the chapter on screen and counts them", () => {
renderReel();
readyVideos();
+26 -8
View File
@@ -25,12 +25,24 @@ html.ds-lb-open {
background: rgba(5, 5, 5, 0.88);
backdrop-filter: blur(8px);
overscroll-behavior: contain;
opacity: 0;
transition:
opacity 180ms var(--ease-in),
overlay 180ms allow-discrete,
display 180ms allow-discrete;
&[open] {
display: flex;
align-items: center;
justify-content: center;
animation: ds-lb-in 0.28s var(--ease-out);
opacity: 1;
transition-duration: 280ms;
transition-timing-function: var(--ease-out);
@starting-style {
/* biome-ignore lint/suspicious/noDuplicateProperties: dentro de @starting-style é o estado inicial da entrada, não uma repetição */
opacity: 0;
}
}
&::backdrop {
@@ -38,15 +50,10 @@ html.ds-lb-open {
}
}
@keyframes ds-lb-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-lb,
.ds-lb[open] {
animation: none;
transition: none;
}
}
@@ -326,12 +333,23 @@ html.ds-lb-open {
gap: var(--sp-2) var(--sp-3);
& .ds-link.ds-link-muted {
--block: rgba(255, 255, 255, 0.12);
padding: 8px 16px;
color: #fff;
border-color: rgba(255, 255, 255, 0.38);
background: transparent;
}
& .ds-link.ds-link-muted:focus-visible {
border-color: rgba(255, 255, 255, 0.55);
}
@media (hover: hover) {
& .ds-link.ds-link-muted:hover {
border-color: rgba(255, 255, 255, 0.55);
}
}
& .ds-link.ds-link-text {
color: rgba(255, 255, 255, 0.84);
}
+28 -3
View File
@@ -87,9 +87,8 @@
gap: 6px 10px;
margin-bottom: var(--sp-5);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--slash);
}
@@ -190,6 +189,32 @@
opacity: 1;
}
/* Projeto fora de cena, sob o ponteiro ou no foco: uma barra verde entra pela esquerda, no lugar onde o projeto em
cena mostra o progresso (.ds-reel-bar). Com movimento reduzido a vitrine fixa nem existe (reel-list.css). */
.ds-reel-pick:not([aria-current="true"])::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--slash);
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
pointer-events: none;
}
.ds-reel-pick:not([aria-current="true"]):focus-visible::before {
transform: none;
}
@media (hover: hover) {
.ds-reel-pick:not([aria-current="true"]):hover::before {
transform: none;
}
}
@media (min-width: 640px) {
.ds-reel-name {
display: inline;
+8 -3
View File
@@ -81,7 +81,8 @@
overflow: hidden;
}
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. */
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. No
ponteiro e no foco, o bloco translúcido dos contornos em fundo escuro (globals.css). */
.ds-reel-actions {
display: flex;
flex-wrap: wrap;
@@ -90,16 +91,20 @@
margin-top: var(--sp-2);
& .ds-link.ds-link-muted {
--block: rgba(255, 255, 255, 0.12);
padding: 8px 16px;
color: #fff;
border-color: rgba(255, 255, 255, 0.38);
background: transparent;
}
& .ds-link.ds-link-muted:focus-visible {
border-color: rgba(255, 255, 255, 0.55);
}
@media (hover: hover) {
& .ds-link.ds-link-muted:hover {
border-color: #fff;
box-shadow: none;
border-color: rgba(255, 255, 255, 0.55);
}
}