feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
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:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user