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

+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>
);
}