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

+391
View File
@@ -0,0 +1,391 @@
/**
* A parte da cena de blocos que não depende do Three.js: os estados exportados do Blender (`public/3d/blocos.json`,
* gerado por `3d/blocos.py`), qual estado o mergulho mostra e o orçamento de quadro que decide se o aparelho segura a
* cena.
*/
export const ESTADOS = ["marca", "tela", "integracoes", "dados"] as const;
/** Números por bloco em cada estado: x, y, z, escala x, y, z e tom (0 porcelana, 1 lima). Escala 0 = bloco oculto. */
const PASSO = 7;
/** Um `Float32Array` de `n * 7` por estado, na ordem de `ESTADOS`. */
export type Blocos = { n: number; estados: Float32Array[] };
/** Valida o JSON exportado e o separa por estado. Lança se o arquivo não for o que a cena espera. */
export function parseBlocos(json: unknown): Blocos {
if (typeof json !== "object" || json === null)
throw new Error("blocos.json: esperava um objeto");
const { n, passo, estados, dados } = json as Record<string, unknown>;
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0)
throw new Error(`blocos.json: n inválido (${String(n)})`);
if (passo !== PASSO)
throw new Error(`blocos.json: passo ${String(passo)}, esperava ${PASSO}`);
if (
!Array.isArray(estados) ||
estados.length !== ESTADOS.length ||
estados.some((nome, i) => nome !== ESTADOS[i])
)
throw new Error(`blocos.json: estados devem ser ${ESTADOS.join(", ")}`);
const tamanho = n * PASSO;
if (
!Array.isArray(dados) ||
dados.length !== ESTADOS.length * tamanho ||
dados.some((v) => typeof v !== "number")
)
throw new Error(
`blocos.json: dados devem ter ${ESTADOS.length * tamanho} números`,
);
return {
n,
estados: ESTADOS.map(
(_, i) => new Float32Array(dados.slice(i * tamanho, (i + 1) * tamanho)),
),
};
}
/** Grupos de blocos encostados (centros a até 1,2 vez o maior lado do bloco), entre os índices `membros`. */
function grupos(d: Float32Array, membros: number[]) {
const livres = new Set(membros);
const saida: number[][] = [];
for (const inicio of membros) {
if (!livres.delete(inicio)) continue;
const grupo = [inicio];
for (let g = 0; g < grupo.length; g++) {
const a = grupo[g] * PASSO;
const alcance = 1.2 * Math.max(d[a + 3], d[a + 4], d[a + 5]);
for (const j of livres) {
const b = j * PASSO;
const dist = Math.hypot(
d[a] - d[b],
d[a + 1] - d[b + 1],
d[a + 2] - d[b + 2],
);
if (dist <= alcance) {
livres.delete(j);
grupo.push(j);
}
}
}
saida.push(grupo);
}
return saida;
}
/** Centro (média dos centros) de um grupo de blocos. */
function centro(d: Float32Array, grupo: number[]): [number, number, number] {
const c: [number, number, number] = [0, 0, 0];
for (const i of grupo)
for (let k = 0; k < 3; k++) c[k] += d[i * PASSO + k] / grupo.length;
return c;
}
/** Papel de cada bloco na vida em repouso de cada estado. */
export type Papeis = {
/**
* Quatro números por bloco: o quadrado lima da marca (0 a 3, da esquerda para a direita; -1 fora deles), 1 se é do
* botão da tela (senão 0), a posição na linha do núcleo até um nó nas integrações (0 no núcleo, 1 no nó; -1 fora
* das linhas) e a fase da barra dos dados (0 a 1; -1 fora das barras).
*/
vida: Float32Array;
/** Centro do botão da tela, em volta do qual ele pulsa. */
botao: [number, number, number];
/** Fundo da peça dos dados (z da face de trás da camada de base): a barra cresce a partir dele. */
chao: number;
};
/**
* Quem faz o quê na vida em repouso, tirado do tom e da posição de cada estado (sem mexer no JSON): na marca, os
* grupos lima são os quadrados; na tela, o grupo lima é o botão; nas integrações, o maior grupo lima é o núcleo, os
* outros marcam os nós, e as linhas são os blocos de porcelana de uma camada só no caminho do núcleo até um nó; nos
* dados, as barras são as colunas que saem em +Z da camada de base.
*/
export function papeis({ n, estados }: Blocos): Papeis {
const vida = new Float32Array(n * 4).fill(-1);
const visiveis = (d: Float32Array) =>
Array.from({ length: n }, (_, i) => i).filter((i) => d[i * PASSO + 3] > 0);
const lima = (d: Float32Array) =>
visiveis(d).filter((i) => d[i * PASSO + 6] > 0.5);
const [marca, tela, integracoes, dados] = estados;
const quadrados = grupos(marca, lima(marca)).sort(
(a, b) => centro(marca, a)[0] - centro(marca, b)[0],
);
quadrados.forEach((grupo, q) => {
for (const i of grupo) vida[i * 4] = q;
});
for (let i = 0; i < n; i++) vida[i * 4 + 1] = 0;
const botao = lima(tela);
for (const i of botao) vida[i * 4 + 1] = 1;
const [nucleo, ...nos] = grupos(integracoes, lima(integracoes))
.sort((a, b) => b.length - a.length)
.map((grupo) => centro(integracoes, grupo));
const vistos = visiveis(integracoes);
const raios = nos.map(() => [] as [number, number][]);
for (const i of vistos) {
const a = i * PASSO;
if (integracoes[a + 6] > 0.5) continue;
// os nós e o núcleo têm duas camadas; a linha, uma
const empilhado = vistos.some(
(j) =>
j !== i &&
Math.abs(integracoes[a] - integracoes[j * PASSO]) < 1e-3 &&
Math.abs(integracoes[a + 1] - integracoes[j * PASSO + 1]) < 1e-3,
);
if (empilhado) continue;
// o nó cuja reta desde o núcleo passa por este bloco (a menos de 3/4 de bloco), entre o núcleo e o nó
const x = integracoes[a] - nucleo[0];
const y = integracoes[a + 1] - nucleo[1];
nos.forEach(([nx, ny], k) => {
const ux = nx - nucleo[0];
const uy = ny - nucleo[1];
const comprimento = Math.hypot(ux, uy);
const aoLongo = (x * ux + y * uy) / comprimento;
const fora = Math.abs(x * uy - y * ux) / comprimento;
if (
aoLongo > 0 &&
aoLongo < comprimento &&
fora < 0.75 * integracoes[a + 3]
)
raios[k].push([i, aoLongo]);
});
}
for (const raio of raios) {
const ds = raio.map(([, d]) => d);
const min = Math.min(...ds);
const faixa = Math.max(...ds) - min || 1;
for (const [i, d] of raio) vida[i * 4 + 2] = (d - min) / faixa;
}
const base = visiveis(dados);
const fundo = (i: number) => dados[i * PASSO + 2] - dados[i * PASSO + 5] / 2;
const chao = Math.min(...base.map(fundo));
const altos = base.filter((i) => fundo(i) > chao + 1e-3);
const barras = grupos(
dados,
base.filter((i) =>
altos.some(
(j) =>
Math.abs(dados[i * PASSO] - dados[j * PASSO]) < 1e-3 &&
Math.abs(dados[i * PASSO + 1] - dados[j * PASSO + 1]) < 1e-3,
),
),
).sort((a, b) => centro(dados, b)[1] - centro(dados, a)[1]);
barras.forEach((grupo, k) => {
for (const i of grupo) vida[i * 4 + 3] = k / barras.length;
});
return {
vida,
botao: botao.length ? centro(tela, botao) : [0, 0, 0],
chao,
};
}
/**
* Trechos do mergulho em que a peça troca de estado (início, fim, de, para); entre eles ela fica parada. Batem com as
* legendas de `hero-captions.ts` (tela, integrações, dados).
*/
const TRECHOS = [
// a peça só se desmonta quando o título do hero já quase se apagou
[0.16, 0.3, 0, 1],
[0.44, 0.56, 1, 2],
[0.7, 0.82, 2, 3],
] as const;
/** Um estado da peça: parado em `from` (`from === to` e `t === 0`) ou em trânsito de `from` para `to` (`t` de 0 a 1). */
export type Estado = { from: number; to: number; t: number };
/**
* O estado que o progresso do mergulho (0 a 1) mostra. Parado: `from === to` e `t === 0`; em trânsito, `t` vai de 0 a
* 1 com smoothstep dentro do trecho.
*/
export function stateAt(p: number): Estado {
const x = Math.min(1, Math.max(0, p || 0));
let parado = 0;
for (const [inicio, fim, from, to] of TRECHOS) {
if (x <= inicio) break;
if (x < fim) {
const k = (x - inicio) / (fim - inicio);
return { from, to, t: k * k * (3 - 2 * k) };
}
parado = to;
}
return { from: parado, to: parado, t: 0 };
}
/**
* O ciclo em repouso, depois de `clock` segundos parado: `fica` s em cada estado e `troca` s para o seguinte (com
* smoothstep), na ordem marca, tela, integrações, dados e de volta à marca. Começa parado na marca. `wait` diz em
* quantos segundos vem a próxima troca (0 durante uma).
*/
export function cicloAt(
clock: number,
fica: number,
troca: number,
): Estado & { wait: number } {
const ciclo = fica + troca;
const atual = Math.floor(clock / ciclo) % ESTADOS.length;
const dentro = (clock % ciclo) - fica;
if (dentro < 0) return { from: atual, to: atual, t: 0, wait: -dentro };
const k = Math.min(1, dentro / troca);
return {
from: atual,
to: (atual + 1) % ESTADOS.length,
t: k * k * (3 - 2 * k),
wait: 0,
};
}
/** O estado em que a peça está parada, ou `null` em trânsito. */
function paradoEm({ from, to, t }: Estado) {
if (t <= 0) return from;
if (t >= 1) return to;
return null;
}
/** Leva `t` até `alvo`, andando no máximo `passo`; numa ponta, o estado fica parado nela. */
function mover({ from, to, t }: Estado, alvo: number, passo: number): Estado {
const k =
Math.abs(alvo - t) <= passo ? alvo : t + Math.sign(alvo - t) * passo;
if (k <= 0) return { from, to: from, t: 0 };
if (k >= 1) return { from: to, to, t: 0 };
return { from, to, t: k };
}
/**
* Um passo do estado mostrado (`atual`) em direção ao pedido (`alvo`), sem salto: `t` anda no máximo `passo` por
* chamada. Na mesma troca, `t` vai ao do alvo; parado num estado da troca pedida, entra nela por essa ponta; fora
* dela, troca direto para a ponta do pedido mais perto dele; no meio de outra troca, anda para a ponta que é um
* estado do pedido (ou, sem nenhuma, para a mais perto) e segue de lá. É como a rolagem retoma a peça do ponto em
* que o ciclo em repouso a deixou (e o ciclo, da rolagem). Quando chega, devolve exatamente o alvo.
*/
export function seguir(atual: Estado, alvo: Estado, passo: number): Estado {
const a = paradoEm(atual);
const b = paradoEm(alvo);
if (a !== null) {
if (b === a) return { from: a, to: a, t: 0 };
if (b !== null) return mover({ from: a, to: b, t: 0 }, 1, passo);
if (a === alvo.from)
return mover({ from: a, to: alvo.to, t: 0 }, alvo.t, passo);
if (a === alvo.to)
return mover({ from: alvo.from, to: a, t: 1 }, alvo.t, passo);
// fora da troca pedida: direto para a ponta dela onde o alvo está mais perto
const ponta = alvo.t < 0.5 ? alvo.from : alvo.to;
return mover({ from: a, to: ponta, t: 0 }, 1, passo);
}
if (b === null && atual.from === alvo.from && atual.to === alvo.to)
return mover(atual, alvo.t, passo);
// a mesma troca, no sentido contrário: vira para o sentido do alvo
if (b === null && atual.from === alvo.to && atual.to === alvo.from)
return mover(
{ from: alvo.from, to: alvo.to, t: 1 - atual.t },
alvo.t,
passo,
);
// outra troca: vai à ponta que já é um estado do pedido (sem voltar atrás quando o pedido muda), ou à mais perto
const pedidos = b === null ? [alvo.from, alvo.to] : [b];
if (pedidos.includes(atual.to)) return mover(atual, 1, passo);
if (pedidos.includes(atual.from)) return mover(atual, 0, passo);
return mover(atual, atual.t < 0.5 ? 0 : 1, passo);
}
/** O que a peça está montando em cada estado, na legenda abaixo dela; a marca não tem legenda. */
const LEGENDAS = [
null,
"Sites e sistemas",
"Integrações",
"Painéis e dados",
] as const;
/** A legenda do estado (índice de `ESTADOS`), ou `null` sem legenda (a marca, nenhum estado ou um índice inválido). */
export function legendaDe(estado: number | null): string | null {
return estado === null ? null : (LEGENDAS[estado] ?? null);
}
export type FrameVerdict = "pending" | "ok" | "slow";
/**
* Mede se o aparelho segura a cena: recebe o tempo de cada quadro (ms) e devolve `"pending"` até juntar `frames`
* quadros, depois `"slow"` se a média passou de `limitMs` (senão `"ok"`), e repete esse veredito dali em diante.
* Quadros acima de 250 ms (aba trocada, pausa do coletor de lixo) não contam.
*/
export function createFrameBudget(limitMs = 28, frames = 60) {
let soma = 0;
let contados = 0;
let veredito: FrameVerdict = "pending";
return (deltaMs: number): FrameVerdict => {
if (veredito !== "pending" || !(deltaMs <= 250)) return veredito;
soma += deltaMs;
contados += 1;
if (contados >= frames)
veredito = soma / contados > limitMs ? "slow" : "ok";
return veredito;
};
}
/** Retângulo em pixels CSS, relativo ao elemento hospedeiro do canvas. */
export type Rect = { x: number; y: number; width: number; height: number };
/**
* Caixas (unidades da cena) em que a peça é enquadrada. Na `marca`, o quadro do próprio pôster (`3d/blocos.py`: câmera a
* 36 unidades, lente de 85 mm em filme de 36 mm, 36 × 36 / 85 ≈ 15,25 de lado), para o canvas cair sobre ele; nos
* outros estados, 17 x 12.
*/
const CAIXA_MARCA = { width: 15.25, height: 15.25 };
const CAIXA_ESTADOS = { width: 17, height: 12 };
/** A caixa ajustada dentro do retângulo, centrada nele: centro (px) e pixels por unidade. */
function fit(rect: Rect, caixa: { width: number; height: number }) {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
scale: Math.min(rect.width / caixa.width, rect.height / caixa.height),
};
}
/**
* Onde a peça aparece no hospedeiro: centro (`x`, `y`, px a partir do canto de cima à esquerda) e `scale` (px por
* unidade da cena). `w` é o peso do estado `marca` (1 parado nela, 0 nos outros, entre os dois em trânsito): a marca
* vai no retângulo `placement` (ou no hospedeiro inteiro sem ele), os outros estados na área útil `stage` (ou no
* hospedeiro inteiro sem ela).
*/
export function framePiece(
host: { width: number; height: number },
placement: Rect | null,
w: number,
stage?: Rect | null,
) {
const inteiro = { x: 0, y: 0, width: host.width, height: host.height };
const marca = fit(placement ?? inteiro, CAIXA_MARCA);
const resto = fit(stage ?? inteiro, CAIXA_ESTADOS);
const k = Math.min(1, Math.max(0, w || 0));
return {
x: resto.x + (marca.x - resto.x) * k,
y: resto.y + (marca.y - resto.y) * k,
scale: resto.scale + (marca.scale - resto.scale) * k,
};
}
/** Margem (px) entre a caixa dos estados no topo e a borda direita do hospedeiro. */
const MARGEM_ESTADOS = 24;
/**
* Onde tela, integrações e dados ficam no ciclo em repouso do topo (e onde vai a legenda, logo abaixo): dentro da
* caixa da peça, sem a faixa da esquerda (22%), que a marca deixa vazia e que fica perto do texto do hero, e sem
* passar da borda direita do hospedeiro; na proporção da caixa dos estados (17 x 12), centrada na altura da peça.
*/
export function caixaDosEstados(
piece: Rect,
host: { width: number; height: number },
): Rect {
const x = piece.x + piece.width * 0.22;
const width = Math.max(
0,
Math.min(piece.x + piece.width, host.width - MARGEM_ESTADOS) - x,
);
const height = (width * CAIXA_ESTADOS.height) / CAIXA_ESTADOS.width;
return { x, y: piece.y + (piece.height - height) / 2, width, height };
}