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
+82
-15
@@ -1,17 +1,21 @@
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import { DESKTOP_USE, PHONE_USE } from "./helpers";
|
||||
import { DESKTOP_USE, PHONE, PHONE_USE } from "./helpers";
|
||||
|
||||
/**
|
||||
* (h) Contato, no computador (1440x900) e, onde dito, no celular (390x844, toque):
|
||||
* - o botão de cada oferta da home ("Escolha por onde começar") leva o cartão do formulário (#contact-form) para a
|
||||
* tela, com o tipo de projeto da oferta já escolhido e o endereço `/#contact-form` (o `?tipo=` sai), e um clique
|
||||
* seguinte em "Falar sobre meu projeto" do cabeçalho continua rolando até #contact (a página rola para outro lugar
|
||||
* entre os dois cliques);
|
||||
* - o link direto `/?tipo=<tipo>#contact-form` (quem chega de outra página) abre a home com o formulário na tela e o
|
||||
* tipo marcado;
|
||||
* - o botão de cada um dos quatro cartões da home ("Escolha por onde começar") leva o cartão do formulário
|
||||
* (#contact-form) para a tela, com o tipo de projeto da oferta já escolhido e o endereço `/#contact-form` (o `?tipo=`
|
||||
* sai), e um clique seguinte em "Falar sobre meu projeto" do cabeçalho continua rolando até #contact (a página rola
|
||||
* para outro lugar entre os dois cliques);
|
||||
* - o link direto `/?tipo=<tipo>#contact-form` das cinco ofertas (quem chega de outra página, inclusive da nota
|
||||
* fiscal, que na home é uma faixa e não um cartão) abre a home com o formulário na tela e o tipo marcado;
|
||||
* - no celular, o toque no botão da oferta deixa o cartão do formulário e o campo do tipo à vista;
|
||||
* - de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact;
|
||||
* - com movimento reduzido, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois.
|
||||
* - com movimento reduzido, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois;
|
||||
* - um destino só: "Falar sobre meu projeto" do hero leva ao cartão do formulário; o WhatsApp é o link "Chamar no
|
||||
* WhatsApp", e nenhum controle chamado "Falar sobre meu projeto" abre o WhatsApp;
|
||||
* - no celular: os dois botões do hero e o link do WhatsApp na primeira dobra; a barra de cookies com até 72 px e o
|
||||
* link da política com 24 px de alvo; o botão flutuante some enquanto a barra está aberta.
|
||||
* "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela, abaixo do
|
||||
* cabeçalho fixo e acima da barra de cookies, quando ela está aberta (primeira visita, em qualquer servidor).
|
||||
*/
|
||||
@@ -20,14 +24,19 @@ const CTA_LABEL = "Falar sobre meu projeto";
|
||||
|
||||
/**
|
||||
* Tipo de cada oferta (o `?tipo=` do href do botão) -> opção que o formulário mostra marcada. Espelha CONTACT_KINDS
|
||||
* (app/components/contact-subject.ts): uma oferta nova sem entrada aqui reprova o primeiro teste.
|
||||
* (app/components/contact-subject.ts): uma oferta nova sem entrada aqui fica sem o teste do link direto.
|
||||
*/
|
||||
const SUBJECT_BY_KIND = {
|
||||
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",
|
||||
} as const;
|
||||
|
||||
/** As ofertas com cartão e botão na home; a da nota fiscal aparece numa faixa com link para a página dela. */
|
||||
const HOME_KINDS = ["site", "sistema", "whatsapp", "revisao"] as const;
|
||||
|
||||
test.use(DESKTOP_USE);
|
||||
|
||||
/** Abre a página e espera o tempo de a hidratação assentar, como os demais testes de interação. */
|
||||
@@ -97,7 +106,7 @@ async function expectContactOnScreen(page: Page, what: string, card = false) {
|
||||
.toBe("ok");
|
||||
}
|
||||
|
||||
test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async ({
|
||||
test("a home tem os quatro cartões de oferta e cada um tem o seu tipo de projeto", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
@@ -105,13 +114,12 @@ test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async (
|
||||
.locator("#modalities a[href*='?tipo=']")
|
||||
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
|
||||
expect(hrefs.sort()).toEqual(
|
||||
Object.keys(SUBJECT_BY_KIND)
|
||||
.map((kind) => `/?tipo=${kind}#contact-form`)
|
||||
.sort(),
|
||||
[...HOME_KINDS].map((kind) => `/?tipo=${kind}#contact-form`).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
for (const kind of HOME_KINDS) {
|
||||
const subject = SUBJECT_BY_KIND[kind];
|
||||
test(`oferta "${kind}": o botão leva o formulário à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até o contato`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -155,10 +163,28 @@ for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
});
|
||||
}
|
||||
|
||||
test("the hero 'Falar sobre meu projeto' leads to the form card, and WhatsApp is the 'Chamar no WhatsApp' link", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
const hero = page.locator("#home");
|
||||
await expect(
|
||||
hero.getByRole("link", { name: "Chamar no WhatsApp" }),
|
||||
).toHaveAttribute("href", /^https:\/\/wa\.me\/\d+\?/);
|
||||
// nenhum controle com o nome do formulário abre o WhatsApp
|
||||
for (const link of await page.getByRole("link", { name: CTA_LABEL }).all())
|
||||
expect(await link.getAttribute("href")).not.toMatch(/wa\.me/);
|
||||
|
||||
await hero.getByRole("link", { name: CTA_LABEL }).click();
|
||||
await page.waitForURL(/\/#contact-form$/);
|
||||
await expectContactOnScreen(page, "clique no botão do hero", true);
|
||||
});
|
||||
|
||||
test.describe("no celular (toque)", () => {
|
||||
test.use(PHONE_USE);
|
||||
|
||||
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
|
||||
for (const kind of HOME_KINDS) {
|
||||
const subject = SUBJECT_BY_KIND[kind];
|
||||
test(`oferta "${kind}": o toque deixa o cartão do formulário e o campo de tipo à vista, com "${subject}" marcado`, async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -175,6 +201,47 @@ test.describe("no celular (toque)", () => {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps both hero buttons and the WhatsApp link in the first screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await open(page, "/");
|
||||
for (const name of [
|
||||
CTA_LABEL,
|
||||
"Ver projetos entregues",
|
||||
"Chamar no WhatsApp",
|
||||
]) {
|
||||
const box = await page
|
||||
.locator("#home")
|
||||
.getByRole("link", { name })
|
||||
.boundingBox();
|
||||
expect(box, name).not.toBeNull();
|
||||
if (!box) continue;
|
||||
expect(box.y, name).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y + box.height, name).toBeLessThanOrEqual(PHONE.height);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the cookie bar within 72 px with a 24 px policy target, and hides the floating button while it is open", async ({
|
||||
page,
|
||||
}) => {
|
||||
// fora da home não há convite à vista: só a barra esconde o botão flutuante
|
||||
await open(page, "/privacidade");
|
||||
const bar = page.getByRole("dialog", { name: "Cookies e privacidade" });
|
||||
const barBox = await bar.boundingBox();
|
||||
expect(barBox?.height).toBeLessThanOrEqual(72);
|
||||
const policy = await bar
|
||||
.getByRole("link", { name: "Política de privacidade" })
|
||||
.boundingBox();
|
||||
expect(policy?.height).toBeGreaterThanOrEqual(24);
|
||||
|
||||
const floating = page.getByRole("complementary", {
|
||||
name: "Contato rápido",
|
||||
});
|
||||
await expect(floating).toBeHidden();
|
||||
await bar.getByRole("button", { name: "Recusar" }).tap();
|
||||
await expect(floating).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact", async ({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, type Locator, test } from "@playwright/test";
|
||||
import { DESKTOP, PHONE, PHONE_USE } from "./helpers";
|
||||
|
||||
/**
|
||||
* A peça 3D do hero (a marca em blocos): o pôster está no HTML e aparece sem JavaScript; com movimento reduzido a cena
|
||||
* não baixa o modelo nem desenha canvas, e o pôster fica no lugar; no celular, a peça não cobre o título nem o
|
||||
* parágrafo, e os dois botões cabem na primeira tela.
|
||||
*/
|
||||
|
||||
/** Arquivos da cena 3D: nenhum deles pode ser pedido quando a cena não deve rodar. */
|
||||
const SCENE_FILES = ["/3d/bloco.glb", "/3d/blocos.json"];
|
||||
|
||||
type Box = { x: number; y: number; width: number; height: number };
|
||||
|
||||
/** Caixa do elemento na página; falha se ele não tiver caixa (ausente ou com display: none). */
|
||||
async function boxOf(locator: Locator, what: string): Promise<Box> {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `caixa de ${what}`).not.toBeNull();
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
function overlaps(a: Box, b: Box) {
|
||||
return (
|
||||
a.x < b.x + b.width &&
|
||||
b.x < a.x + a.width &&
|
||||
a.y < b.y + b.height &&
|
||||
b.y < a.y + a.height
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("sem JavaScript", () => {
|
||||
test.use({ viewport: DESKTOP, javaScriptEnabled: false });
|
||||
|
||||
test("the home shows the headline and ships the block poster in the HTML", async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto("/", { waitUntil: "load" });
|
||||
expect(response?.status()).toBe(200);
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
const poster = page.locator(".hero-piece img");
|
||||
await expect(poster).toHaveCount(1);
|
||||
await expect(poster).toHaveAttribute("src", /hero-poster/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("movimento reduzido no computador", () => {
|
||||
test.use({ viewport: DESKTOP, reducedMotion: "reduce" });
|
||||
|
||||
test("never fetches the 3D scene nor draws a canvas, and keeps the poster visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requested: string[] = [];
|
||||
page.on("request", (request) => requested.push(request.url()));
|
||||
|
||||
await page.goto("/", { waitUntil: "networkidle" });
|
||||
await page.mouse.move(DESKTOP.width / 2, DESKTOP.height / 2);
|
||||
await page.mouse.wheel(0, 600);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.scrollY))
|
||||
.toBeGreaterThan(0);
|
||||
// provar uma ausência pede uma janela de espera: a rolagem poderia disparar o carregamento da cena
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const sceneRequests = requested.filter((url) =>
|
||||
SCENE_FILES.some((file) => url.includes(file)),
|
||||
);
|
||||
expect(sceneRequests).toEqual([]);
|
||||
await expect(page.locator(".hero canvas")).toHaveCount(0);
|
||||
await expect(page.locator(".hero-piece img")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("celular com movimento reduzido", () => {
|
||||
test.use({ ...PHONE_USE, reducedMotion: "reduce" });
|
||||
|
||||
test("keeps both hero buttons whole in the first screen and the piece clear of the headline and its paragraph", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/", { waitUntil: "load" });
|
||||
|
||||
const buttons = [
|
||||
page.locator("#hero-contact-cta"),
|
||||
page.locator('.hero a[href="/cases"]'),
|
||||
];
|
||||
for (const button of buttons) {
|
||||
const box = await boxOf(button, "botão do hero");
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(PHONE.height);
|
||||
}
|
||||
|
||||
const piece = await boxOf(page.locator(".hero-piece"), "a peça");
|
||||
const heading = await boxOf(page.locator(".hero h1"), "o título");
|
||||
const paragraph = await boxOf(
|
||||
page.locator(".hero h1 + p"),
|
||||
"o parágrafo do título",
|
||||
);
|
||||
expect(overlaps(piece, heading), "a peça cruza o título").toBe(false);
|
||||
expect(overlaps(piece, paragraph), "a peça cruza o parágrafo").toBe(false);
|
||||
});
|
||||
});
|
||||
+12
-9
@@ -9,10 +9,10 @@ import {
|
||||
/**
|
||||
* (c) Teclado, no computador:
|
||||
* (a) atalho: Tab até "Falar sobre meu projeto" no cabeçalho, Enter, e o campo de nome do contato em até 4 Tabs.
|
||||
* Só no computador: no celular o botão do hero abre o WhatsApp, não o formulário.
|
||||
* (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 37 toques, mais os controles da
|
||||
* barra de cookies enquanto ela estiver aberta (primeira visita: 37 + 3 = 40; com o consentimento já salvo a barra
|
||||
* não abre e o limite é 37). A vitrine de projetos é navegada com as setas: um seletor e o link do case; ver o
|
||||
* Só no computador: no celular o botão do cabeçalho fica dentro do menu.
|
||||
* (b) caminho linear: do topo da home, o Tab chega ao campo de nome do contato em até 41 toques, mais os controles da
|
||||
* barra de cookies enquanto ela estiver aberta (primeira visita: 41 + 3 = 44; com o consentimento já salvo a barra
|
||||
* não abre e o limite é 41). A vitrine de projetos é navegada com as setas: um seletor e o link do case; ver o
|
||||
* inventário em LINEAR_MAX_TABS.
|
||||
* Os testes continuam apertando Tab depois do limite só para dizer, na falha, quantos toques faltavam.
|
||||
*/
|
||||
@@ -21,17 +21,20 @@ const CTA_LABEL = "Falar sobre meu projeto";
|
||||
const SHORTCUT_MAX_TABS_TO_CTA = 20;
|
||||
const SHORTCUT_MAX_TABS_AFTER_ENTER = 4;
|
||||
/**
|
||||
* Limite do caminho linear: 37 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (37 hoje):
|
||||
* Limite do caminho linear: 41 toques. Inventário das paradas legítimas, do topo da home ao campo de nome (41 hoje):
|
||||
* 1 link "Pular para o conteúdo";
|
||||
* cabeçalho: logo, 5 links (Cases, Serviços, Como trabalho, Sobre, IA) e "Falar sobre meu projeto" (7);
|
||||
* hero: "Falar sobre meu projeto", "Ver projetos entregues", os 5 clientes da faixa, LinkedIn e e-mail (9);
|
||||
* cabeçalho: logo, 5 links (Cases, Serviços, Como trabalho, Sobre, Revisão de app com IA) e "Falar sobre meu
|
||||
* projeto" (7);
|
||||
* hero: "Falar sobre meu projeto", "Ver projetos entregues", "Chamar no WhatsApp", os 5 clientes da faixa, LinkedIn
|
||||
* e e-mail (10);
|
||||
* vitrine de projetos: o seletor, o link do case do projeto aberto e "Ver os cases" (3);
|
||||
* ofertas: 3 botões e 4 links de case/página (a revisão aparece no card e no fim), mais "Ver todos os serviços" (8);
|
||||
* ofertas: o link da faixa da nota fiscal, 4 botões e 5 links de case/página (a revisão aparece no card e no fim),
|
||||
* mais "Ver todos os serviços" (11);
|
||||
* "Ver trajetória completa" (1) e as 5 perguntas da FAQ (5);
|
||||
* contato: WhatsApp e e-mail (2), e então o campo de nome.
|
||||
* Tudo isso é conteúdo e navegação que a pessoa quer alcançar; o limite não deve subir sem uma parada nova justificada.
|
||||
*/
|
||||
const LINEAR_MAX_TABS = 37;
|
||||
const LINEAR_MAX_TABS = 41;
|
||||
/** Primeira visita: a barra de cookies abre antes do cabeçalho e soma os controles dela (link da política, Aceitar, Recusar). */
|
||||
const LINEAR_MAX_TABS_FIRST_VISIT =
|
||||
LINEAR_MAX_TABS + COOKIE_BAR_CONTROLS.length;
|
||||
|
||||
@@ -71,7 +71,13 @@ test("o menu abre pelo botão e mostra os links e o contato", async ({
|
||||
}) => {
|
||||
await openMenu(page);
|
||||
const nav = mobileNav(page);
|
||||
for (const label of ["Cases", "Serviços", "Como trabalho", "Sobre", "IA"])
|
||||
for (const label of [
|
||||
"Cases",
|
||||
"Serviços",
|
||||
"Como trabalho",
|
||||
"Sobre",
|
||||
"Revisão de app com IA",
|
||||
])
|
||||
await expect(
|
||||
nav.getByRole("link", { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -161,7 +167,7 @@ test("com o menu aberto o Tab e o Shift+Tab ficam presos no cabeçalho e na barr
|
||||
"Serviços",
|
||||
"Como trabalho",
|
||||
"Sobre",
|
||||
"IA",
|
||||
"Revisão de app com IA",
|
||||
CTA_LABEL,
|
||||
];
|
||||
// a volta que mais importa: do contato à barra de cookies, ao logo e ao botão do menu
|
||||
|
||||
Reference in new issue
Block a user