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.
250 lines
8.0 KiB
TypeScript
250 lines
8.0 KiB
TypeScript
import { act, render, screen } from "@testing-library/react";
|
|
import { DrawLine } from "./animations/DrawLine";
|
|
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
|
import { Reveal } from "./Reveal";
|
|
import { Section } from "./Section";
|
|
|
|
/**
|
|
* Entradas por rolagem sem biblioteca de animação: o componente só marca data-shown (ou data-loaded) quando o
|
|
* IntersectionObserver avisa e o CSS (globals.css) faz a transição. Aqui vale o contrato com o CSS: as classes,
|
|
* as variáveis e o momento em que as marcas aparecem.
|
|
*/
|
|
|
|
type Observer = {
|
|
callback: IntersectionObserverCallback;
|
|
observed: Element[];
|
|
};
|
|
let observers: Observer[] = [];
|
|
const original = global.IntersectionObserver;
|
|
|
|
beforeEach(() => {
|
|
observers = [];
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
global.IntersectionObserver = class {
|
|
private readonly record: Observer;
|
|
constructor(callback: IntersectionObserverCallback) {
|
|
this.record = { callback, observed: [] };
|
|
observers.push(this.record);
|
|
}
|
|
observe(target: Element) {
|
|
this.record.observed.push(target);
|
|
}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
} as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.IntersectionObserver = original;
|
|
});
|
|
|
|
/** Avisa que `target` entrou inteiro na janela. */
|
|
function reveal(target: Element) {
|
|
act(() => {
|
|
for (const observer of observers)
|
|
if (observer.observed.includes(target))
|
|
observer.callback(
|
|
[
|
|
{
|
|
isIntersecting: true,
|
|
intersectionRatio: 1,
|
|
intersectionRect: { height: 400 },
|
|
target,
|
|
} as unknown as IntersectionObserverEntry,
|
|
],
|
|
{} as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
|
|
describe("Reveal", () => {
|
|
it("renders its children and waits for the viewport to mark them as shown", () => {
|
|
const { container } = render(
|
|
<Reveal>
|
|
<p>Conteúdo</p>
|
|
</Reveal>,
|
|
);
|
|
const wrapper = container.firstElementChild as HTMLElement;
|
|
|
|
expect(screen.getByText("Conteúdo")).toBeInTheDocument();
|
|
expect(wrapper).toHaveClass("ds-reveal");
|
|
expect(wrapper).not.toHaveAttribute("data-shown");
|
|
|
|
reveal(wrapper);
|
|
expect(wrapper).toHaveAttribute("data-shown");
|
|
});
|
|
|
|
it("hands the CSS its offset, duration and delay as custom properties", () => {
|
|
const { container } = render(
|
|
<Reveal y={40} duration={0.9} delay={0.12} className="extra">
|
|
x
|
|
</Reveal>,
|
|
);
|
|
const wrapper = container.firstElementChild as HTMLElement;
|
|
|
|
expect(wrapper.style.getPropertyValue("--reveal-y")).toBe("40px");
|
|
expect(wrapper.style.getPropertyValue("--reveal-duration")).toBe("0.9s");
|
|
expect(wrapper.style.getPropertyValue("--reveal-delay")).toBe("0.12s");
|
|
expect(wrapper).toHaveClass("ds-reveal", "extra");
|
|
});
|
|
|
|
it("uses no inline opacity or transform: the hidden state lives in the CSS, so no script means visible content", () => {
|
|
const { container } = render(<Reveal>x</Reveal>);
|
|
const wrapper = container.firstElementChild as HTMLElement;
|
|
|
|
expect(wrapper.style.opacity).toBe("");
|
|
expect(wrapper.style.transform).toBe("");
|
|
expect(wrapper.style.clipPath).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("DrawLine", () => {
|
|
it("draws once the line is mostly on screen, and takes its delay from the prop", () => {
|
|
const { container } = render(<DrawLine delay={0.16} className="x" />);
|
|
const line = container.firstElementChild as HTMLElement;
|
|
|
|
expect(line).toHaveClass("ds-draw-line", "x");
|
|
expect(line.style.getPropertyValue("--reveal-delay")).toBe("0.16s");
|
|
expect(line).not.toHaveAttribute("data-shown");
|
|
|
|
reveal(line);
|
|
expect(line).toHaveAttribute("data-shown");
|
|
});
|
|
});
|
|
|
|
describe("LazyRevealImage", () => {
|
|
it("shows the picture (data-loaded) only after it has loaded", async () => {
|
|
const { container } = render(
|
|
<LazyRevealImage src="/foto.webp" alt="Foto" className="h-40" />,
|
|
);
|
|
const wrapper = container.firstElementChild as HTMLElement;
|
|
const image = screen.getByAltText("Foto");
|
|
|
|
expect(wrapper).toHaveClass("ds-lazy-reveal", "h-40");
|
|
expect(wrapper).not.toHaveAttribute("data-loaded");
|
|
|
|
// o next/image só repassa o onLoad depois de decodificar a imagem (uma microtarefa)
|
|
await act(async () => {
|
|
image.dispatchEvent(new Event("load"));
|
|
});
|
|
expect(wrapper).toHaveAttribute("data-loaded");
|
|
});
|
|
|
|
it("does not wait forever for an image that was already complete before the page hydrated", async () => {
|
|
const complete = jest
|
|
.spyOn(HTMLImageElement.prototype, "complete", "get")
|
|
.mockReturnValue(true);
|
|
const width = jest
|
|
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
|
|
.mockReturnValue(640);
|
|
|
|
let view: ReturnType<typeof render> | undefined;
|
|
await act(async () => {
|
|
view = render(<LazyRevealImage src="/foto.webp" alt="Foto" />);
|
|
});
|
|
|
|
expect(view?.container.firstElementChild).toHaveAttribute("data-loaded");
|
|
complete.mockRestore();
|
|
width.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Section", () => {
|
|
it("renders the heading, the description and the content, and marks only the header as it enters", () => {
|
|
const { container } = render(
|
|
<Section
|
|
id="sobre"
|
|
eyebrow="Sobre"
|
|
title="Quem faz"
|
|
description="Texto de apoio"
|
|
aside={<span>lateral</span>}
|
|
>
|
|
<p>Corpo</p>
|
|
</Section>,
|
|
);
|
|
const header = container.querySelector(".ds-section-header") as HTMLElement;
|
|
|
|
expect(container.querySelector("section#sobre")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("heading", { level: 2, name: "Quem faz" }),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByText("Texto de apoio")).toHaveClass(
|
|
"ds-section-description",
|
|
"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");
|
|
expect(side).toHaveClass("ds-reveal");
|
|
expect(container.querySelector("aside")).toBeNull();
|
|
expect(screen.queryByRole("complementary")).toBeNull();
|
|
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
|
expect(header).not.toHaveAttribute("data-shown");
|
|
|
|
reveal(header);
|
|
expect(header).toHaveAttribute("data-shown");
|
|
});
|
|
|
|
it("staggers the header items with growing delays", () => {
|
|
render(
|
|
<Section
|
|
eyebrow="Sobre"
|
|
title="Quem faz"
|
|
description="Texto"
|
|
aside={<span>lateral</span>}
|
|
>
|
|
x
|
|
</Section>,
|
|
);
|
|
const delay = (element: HTMLElement) =>
|
|
Number.parseFloat(element.style.getPropertyValue("--reveal-delay"));
|
|
|
|
const eyebrow = delay(screen.getByText("Sobre"));
|
|
const description = delay(screen.getByText("Texto"));
|
|
// o conteúdo lateral é um <div class="ds-reveal">, não um <aside> (ver Section.tsx)
|
|
const aside = delay(
|
|
screen.getByText("lateral").closest(".ds-reveal") as HTMLElement,
|
|
);
|
|
|
|
expect(eyebrow).toBeLessThan(description);
|
|
expect(description).toBeLessThan(aside);
|
|
});
|
|
|
|
it("wraps the body in Reveal, unless the section animates its own children", () => {
|
|
const { container, rerender } = render(
|
|
<Section title="Titulo">
|
|
<p>Corpo</p>
|
|
</Section>,
|
|
);
|
|
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeInTheDocument();
|
|
|
|
rerender(
|
|
<Section title="Titulo" disableChildrenAnimation>
|
|
<p>Corpo</p>
|
|
</Section>,
|
|
);
|
|
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeNull();
|
|
expect(container.querySelector(".ds-section-shell")).toBeInTheDocument();
|
|
});
|
|
|
|
it("uses the dark skin when asked", () => {
|
|
const { container } = render(
|
|
<Section title="Titulo" dark>
|
|
x
|
|
</Section>,
|
|
);
|
|
|
|
expect(container.querySelector("section")).toHaveClass(
|
|
"ds-section",
|
|
"ds-section-dark",
|
|
);
|
|
});
|
|
});
|