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

+3
View File
@@ -61,3 +61,6 @@ next-env.d.ts
.omo/
.playwright-mcp/
/3d/out/
/3d/__pycache__/
+619
View File
@@ -0,0 +1,619 @@
"""Kit de blocos do hero: um bloco chanfrado e quatro estados com o mesmo N.
Rodar da raiz do repositório: blender -b --python 3d/blocos.py
Gera public/3d/bloco.glb, public/3d/blocos.json, os dois matcaps, o pôster do hero e os dos estados (Cycles) e, em
3d/out/, o .blend e as prévias de cada estado.
Coordenadas do JSON: Y para cima, peça de frente para +Z, centrada na origem.
"""
import ctypes
import gzip
import math
import os
import sys
import bmesh
import bpy
import numpy as np
from bpy_extras.object_utils import world_to_camera_view
from mathutils import Vector
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUBLICO = os.path.join(RAIZ, "public", "3d")
SAIDA = os.path.join(RAIZ, "3d", "out")
GLB = os.path.join(PUBLICO, "bloco.glb")
JSON = os.path.join(PUBLICO, "blocos.json")
ESTADOS = ["marca", "tela", "integracoes", "dados"]
PASSO = 7 # números por bloco: x, y, z, sx, sy, sz, tom
CAIXA = (16, 11, 4)
# ---------------------------------------------------------------- marca
# public/brand/mark-dark.svg (viewBox 210 220 830 800)
POLIGONO = [
(245, 757), (321, 757), (321, 681), (399, 681), (399, 606), (476, 606),
(476, 757), (399, 757), (399, 878), (523, 878), (523, 757), (654, 757),
(654, 913), (594, 913), (594, 982), (245, 982),
]
# a forma branca em retângulos exatos (u0, v0, u1, v1), sem sobreposição
BRANCOS = [
(399, 606, 476, 757),
(321, 681, 399, 757),
(245, 757, 399, 982),
(523, 757, 594, 878),
(594, 757, 654, 913),
(399, 878, 594, 982),
]
LIMAS = [
(511, 512, 667, 688),
(667, 358, 821, 512),
(844, 255, 947, 358),
(906, 432, 1007, 533),
]
LADO_SVG = 38
ESCALA = 10 / 727 # altura da marca (255..982) vira 10
CU, CV = (245 + 1007) / 2, (255 + 982) / 2 # centro da marca no SVG
def svg_para_mundo(u, v):
return (u - CU) * ESCALA, -(v - CV) * ESCALA
def estado_marca():
sz = LADO_SVG * ESCALA
blocos = []
for tom, retangulos in ((0, BRANCOS), (1, LIMAS)):
for u0, v0, u1, v1 in retangulos:
nx = max(1, round((u1 - u0) / LADO_SVG))
ny = max(1, round((v1 - v0) / LADO_SVG))
bu, bv = (u1 - u0) / nx, (v1 - v0) / ny
for i in range(nx):
for j in range(ny):
x, y = svg_para_mundo(u0 + (i + 0.5) * bu, v0 + (j + 0.5) * bv)
for z in (-sz / 2, sz / 2):
blocos.append((x, y, z, bu * ESCALA, bv * ESCALA, sz, tom))
return blocos
# ------------------------------------------- tela, integrações, dados
# Desenhados numa grade inteira (coluna i, linha j com j = 0 no topo, camada k
# para +Z). O passo em mundo sai do tamanho da grade; a densidade de cada
# desenho foi escolhida para ficar entre 85% e 100% de N.
class Grade:
def __init__(self):
self.celulas = {}
def bloco(self, i, j, k=0, tom=0):
# lima prevalece quando dois elementos caem na mesma célula
chave = (round(i), round(j), k)
self.celulas[chave] = max(tom, self.celulas.get(chave, 0))
def cheio(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
for j in range(j0, j1 + 1):
self.bloco(i, j, k, tom)
def contorno(self, i0, j0, i1, j1, k=0, tom=0):
for i in range(i0, i1 + 1):
self.bloco(i, j0, k, tom)
self.bloco(i, j1, k, tom)
for j in range(j0 + 1, j1):
self.bloco(i0, j, k, tom)
self.bloco(i1, j, k, tom)
def linha(self, a, b, k=0, tom=0):
n = math.ceil(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) * 2 + 1
for t in range(n + 1):
f = t / n
self.bloco(
math.floor(a[0] + (b[0] - a[0]) * f + 0.5),
math.floor(a[1] + (b[1] - a[1]) * f + 0.5),
k,
tom,
)
def disco(self, ci, cj, r, k=0, tom=0):
n = math.floor(r)
for di in range(-n, n + 1):
for dj in range(-n, n + 1):
if di * di + dj * dj <= r * r:
self.bloco(ci + di, cj + dj, k, tom)
def blocos(self):
cel = self.celulas
ii = [c[0] for c in cel]
jj = [c[1] for c in cel]
kk = [c[2] for c in cel]
largura = max(ii) - min(ii) + 1
altura = max(jj) - min(jj) + 1
camadas = max(kk) - min(kk) + 1
# maior passo que cabe na caixa, com folga
p = min(14.5 / largura, 9.8 / altura, 3.8 / camadas)
mi, mj, mk = (max(ii) + min(ii)) / 2, (max(jj) + min(jj)) / 2, (max(kk) + min(kk)) / 2
return [
((i - mi) * p, -(j - mj) * p, (k - mk) * p, p, p, p, tom)
for (i, j, k), tom in sorted(cel.items())
]
def estado_tela():
# referência: drawScreen em app/components/cinematic/HeroFieldScene.tsx
g = Grade()
C, R = 26, 18
g.contorno(0, 0, C - 1, R - 1) # moldura
for i in (2, 4, 6):
g.bloco(i, 1) # três pontos
g.cheio(1, 2, C - 2, 2) # barra do navegador
g.cheio(2, 4, 9, 5) # título
g.cheio(2, 7, 12, 7) # texto
g.cheio(2, 9, 9, 9)
for k in (0, 1):
g.cheio(2, 11, 7, 11, k, tom=1) # botão, saltado para a frente
g.contorno(15, 4, C - 3, 11) # imagem
g.linha((16, 10), (19, 7))
g.linha((19, 7), (21, 9))
g.bloco(21, 6)
for i0 in (2, 10, 18):
g.contorno(i0, 13, i0 + 5, 15) # cartões
return g.blocos()
def estado_integracoes():
# referência: drawIntegrations em HeroFieldScene.tsx
g = Grade()
A, B = 11, 7.5 # semieixos da elipse dos nós
r_nucleo, r_no = 2.6, 1.6
for k in (0, 1):
g.disco(0, 0, r_nucleo, k, tom=1)
nos = []
for n in range(6):
a = n / 6 * math.tau + math.pi / 6
nos.append((round(math.cos(a) * A), round(math.sin(a) * B)))
for n, (x, y) in enumerate(nos):
for k in (0, 1):
g.disco(x, y, r_no, k)
g.bloco(x, y, 1, tom=1)
d = math.hypot(x, y)
ux, uy = x / d, y / d
g.linha((ux * (r_nucleo + 0.5), uy * (r_nucleo + 0.5)), (x - ux * (r_no + 0.5), y - uy * (r_no + 0.5)))
nx, ny = nos[(n + 1) % 6]
dx, dy = nx - x, ny - y
d = math.hypot(dx, dy)
g.linha((x + dx / d * (r_no + 0.5), y + dy / d * (r_no + 0.5)), (nx - dx / d * (r_no + 0.5), ny - dy / d * (r_no + 0.5)))
return g.blocos()
def estado_dados():
# referência: drawData em HeroFieldScene.tsx; a coluna da direita vira barras em +Z
g = Grade()
C, R = 24, 15
g.contorno(0, 0, C - 1, R - 1)
g.cheio(1, 1, C - 2, 2, tom=1) # cabeçalho
colunas = (7, 13, 18)
for c in colunas:
g.cheio(c, 3, c, R - 2)
valores = ((5, 3, 2), (3, 4, 3), (4, 2, 1), (2, 4, 3), (5, 3, 2))
alturas = (2, 4, 1, 5, 3)
for linha, (v, h) in enumerate(zip(valores, alturas)):
j = 4 + linha * 2
for inicio, comprimento in zip((2, 9, 15), v):
g.cheio(inicio, j, inicio + comprimento - 1, j)
for k in range(h + 1):
g.cheio(20, j, 21, j, k, tom=int(k == h)) # topo da barra em lima
return g.blocos()
# ------------------------------------------------ montagem dos estados
def chave(b):
return (round(b[0], 3), round(b[1], 3), round(b[2], 3))
def completar(blocos, n):
"""Ordena por (x, y, z) e põe os que faltam para n ocultos sobre visíveis espalhados."""
visiveis = sorted(blocos, key=chave)
v, h = len(visiveis), n - len(visiveis)
if h < 0:
falhar(f"estado com {v} blocos, mais que N={n}")
ocultos = []
for m in range(h):
x, y, z, _, _, _, tom = visiveis[(m * 2 + 1) * v // (h * 2)]
ocultos.append((x, y, z, 0, 0, 0, tom))
# oculto fica logo depois do visível em que está, mesma chave espacial
return sorted(visiveis + ocultos, key=lambda b: (chave(b), b[3] == 0))
def fmt(v):
s = f"{v:.3f}"
return "0.000" if s == "-0.000" else s
# ------------------------------------------------------------- Blender
def srgb(h):
def lin(c):
c /= 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return (*(lin(int(h[i : i + 2], 16)) for i in (0, 2, 4)), 1.0)
def criar_bloco():
malha = bpy.data.meshes.new("bloco")
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1)
bm.to_mesh(malha)
bm.free()
obj = bpy.data.objects.new("bloco", malha)
bpy.context.scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
chanfro = obj.modifiers.new("chanfro", "BEVEL")
chanfro.width = 0.08
chanfro.segments = 2
malha.shade_smooth()
pesos = obj.modifiers.new("normais", "WEIGHTED_NORMAL")
pesos.keep_sharp = True
for m in ("chanfro", "normais"):
bpy.ops.object.modifier_apply(modifier=m)
return obj
def exportar_glb(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.ops.export_scene.gltf(
filepath=GLB,
export_format="GLB",
use_selection=True,
export_yup=True,
export_apply=True,
export_normals=True,
export_texcoords=False,
export_tangents=False,
export_materials="NONE",
export_vertex_color="NONE",
export_attributes=False,
export_extras=False,
export_animations=False,
export_draco_mesh_compression_enable=False,
)
def montar_cena(estados, malha):
cena = bpy.context.scene
materiais = criar_materiais()
malha.materials.append(materiais[0])
colecoes = {}
for nome, blocos in zip(ESTADOS, estados):
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
for n, (x, y, z, sx, sy, sz, tom) in enumerate(blocos):
obj = bpy.data.objects.new(f"{nome}.{n:03d}", malha)
col.objects.link(obj)
# arquivo Y-up/+Z frente -> Blender Z-up/-Y frente
obj.location = (x, -z, y)
obj.scale = (sx, sz, sy)
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
obj.hide_render = obj.hide_viewport = sx == 0
return colecoes, materiais
def renderizar(colecoes):
cena = bpy.context.scene
cena.render.engine = "BLENDER_WORKBENCH"
cena.render.resolution_x, cena.render.resolution_y = 800, 600
cena.render.resolution_percentage = 100
cena.render.image_settings.file_format = "PNG"
cena.view_settings.view_transform = "Standard"
cena.display.shading.light = "STUDIO"
cena.display.shading.color_type = "MATERIAL"
cena.display.shading.show_cavity = True
cena.world = bpy.data.worlds.new("fundo")
cena.world.color = (0.008, 0.008, 0.01)
cam = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(cam)
cam.location = (-6.0, -25.0, 3.0) # pela esquerda: barras de dados saem para fora da tabela
cam.rotation_euler = (-cam.location.normalized()).to_track_quat("-Z", "Y").to_euler()
cena.camera = cam
for nome in ESTADOS:
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
cena.render.filepath = os.path.join(SAIDA, f"previa-{nome}.png")
bpy.ops.render.render(write_still=True)
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != "marca"
# ------------------------------------------------------- render final
# Porcelana fosca com um verniz fino e esmalte lima com emissão leve, numa luz de estúdio de três pontos.
# Os matcaps (uma esfera de cada material, vista de frente) levam essa mesma luz para o tempo real.
def criar_materiais():
def material(nome, cor, **entradas):
mat = bpy.data.materials.new(nome)
mat.diffuse_color = srgb(cor) # cor da prévia (Workbench)
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = srgb(cor)
for entrada, valor in entradas.items():
bsdf.inputs[entrada].default_value = valor
return mat
return {
0: material("porcelana", "f4f3ef", **{"Roughness": 0.5, "Coat Weight": 0.12, "Coat Roughness": 0.3}),
1: material(
"lima",
"d5fa00",
**{
"Roughness": 0.28,
"Coat Weight": 0.3,
"Coat Roughness": 0.12,
"Emission Color": srgb("d5fa00"),
"Emission Strength": 0.06,
},
),
}
def olhar_para_a_origem(obj):
obj.rotation_euler = (-Vector(obj.location).normalized()).to_track_quat("-Z", "Y").to_euler()
def enquadrar(camera, objetos, direcao, ocupa):
"""Põe a câmera na direção dada, olhando a origem, na distância em que os objetos ocupam essa fração do quadro."""
cena = bpy.context.scene
cantos = [obj.matrix_world @ Vector(c) for obj in objetos for c in obj.bound_box]
distancia = 40
for _ in range(3): # com lente longa o tamanho na tela é quase 1 / distância: três passos convergem
camera.location = Vector(direcao).normalized() * distancia
olhar_para_a_origem(camera)
bpy.context.view_layer.update()
vistos = [world_to_camera_view(cena, camera, p) for p in cantos]
distancia *= 2 * max(max(abs(p.x - 0.5), abs(p.y - 0.5)) for p in vistos) / ocupa
def luz(nome, posicao, lado, potencia, cor=(1, 1, 1)):
dados = bpy.data.lights.new(nome, "AREA")
dados.shape = "SQUARE"
dados.size = lado
dados.energy = potencia
dados.color = cor
obj = bpy.data.objects.new(nome, dados)
bpy.context.scene.collection.objects.link(obj)
obj.location = posicao
olhar_para_a_origem(obj)
def ligar_cycles(cena):
"""Cycles na GPU com OptiX quando a máquina tem; senão segue na CPU."""
cena.render.engine = "CYCLES"
cena.cycles.use_denoising = True
prefs = bpy.context.preferences.addons["cycles"].preferences
try:
prefs.compute_device_type = "OPTIX"
except TypeError:
return
prefs.get_devices_for_type("OPTIX")
for aparelho in prefs.devices:
aparelho.use = aparelho.type == "OPTIX"
if any(aparelho.use for aparelho in prefs.devices):
cena.cycles.device = "GPU"
cena.cycles.denoiser = "OPTIX"
def sair(cena, caminho, lado, formato, modo, amostras, transparente):
cena.render.resolution_x, cena.render.resolution_y = lado
cena.render.film_transparent = transparente
cena.render.image_settings.file_format = formato
cena.render.image_settings.color_mode = modo
cena.render.image_settings.quality = 84
cena.cycles.samples = amostras
cena.render.filepath = caminho
bpy.ops.render.render(write_still=True)
def luz_de_estudio(cena):
fundo = cena.world.node_tree.nodes["Background"]
fundo.inputs["Color"].default_value = (0.02, 0.022, 0.02, 1)
fundo.inputs["Strength"].default_value = 1.0
# três tons por bloco: topo claro (principal, quase a pino), frente média (preenchimento baixo e frontal) e a lateral
# que a câmera vê na sombra; o contraluz recorta a silhueta por trás
luz("principal", (6, -10, 20), 14, 7000)
luz("preenchimento", (-10, -18, 3), 18, 2000, (0.92, 0.96, 1.0))
luz("contraluz", (-8, 12, 10), 10, 2000, (0.85, 1.0, 0.75))
def render_final(colecoes, materiais):
cena = bpy.context.scene
ligar_cycles(cena)
luz_de_estudio(cena)
def so(nome):
for outro, col in colecoes.items():
col.hide_render = col.hide_viewport = outro != nome
# prévias finais dos quatro estados, na câmera das prévias rápidas
for nome in ESTADOS:
so(nome)
sair(cena, os.path.join(SAIDA, f"final-{nome}.png"), (800, 600), "PNG", "RGB", 64, False)
camera = bpy.data.objects.new("camera-poster", bpy.data.cameras.new("camera-poster"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
# pôsteres dos estados: quase de frente como as prévias, mas de lente longa e com a peça em uns 85% do quadro
cena.render.resolution_x, cena.render.resolution_y = 1200, 900 # o enquadramento usa a proporção do quadro
for nome in ESTADOS[1:]:
so(nome)
enquadrar(camera, [obj for obj in colecoes[nome].objects if not obj.hide_render], (-6, -25, 3), 0.85)
sair(cena, os.path.join(PUBLICO, f"estado-{nome}.webp"), (1200, 900), "WEBP", "RGBA", 256, True)
# pôster: a marca em três quartos, fundo transparente
camera.location = Vector((-0.42, -0.86, 0.2)).normalized() * 36
olhar_para_a_origem(camera)
so("marca")
sair(cena, os.path.join(PUBLICO, "hero-poster.webp"), (1200, 1200), "WEBP", "RGBA", 256, True)
# matcaps: uma esfera de cada material, de frente, em vista ortográfica
so(None)
malha = bpy.data.meshes.new("esfera")
bm = bmesh.new()
bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=1)
bm.to_mesh(malha)
bm.free()
malha.shade_smooth()
esfera = bpy.data.objects.new("esfera", malha)
cena.collection.objects.link(esfera)
frente = bpy.data.objects.new("camera-matcap", bpy.data.cameras.new("camera-matcap"))
cena.collection.objects.link(frente)
frente.data.type = "ORTHO"
frente.data.ortho_scale = 2.0
frente.location = (0, -12, 0)
olhar_para_a_origem(frente)
cena.camera = frente
malha.materials.append(None)
# a esfera de frente pega mais luz de preenchimento que as faces do pôster: a porcelana sai 0,3 ponto de exposição
# abaixo, para a cena em tempo real ter o mesmo branco do pôster (a lima já bate)
for tom, nome, exposicao in ((0, "porcelana", -0.3), (1, "lima", 0.0)):
malha.materials[0] = materiais[tom]
cena.view_settings.exposure = exposicao
sair(cena, os.path.join(PUBLICO, f"matcap-{nome}.webp"), (256, 256), "WEBP", "RGB", 128, False)
cena.view_settings.exposure = 0.0
bpy.data.objects.remove(esfera)
bpy.data.objects.remove(frente)
cena.camera = camera
so("marca")
# ------------------------------------------------------- autoconferência
def falhar(msg):
print(f"FALHOU: {msg}", flush=True)
sys.exit(1)
def conferir(tris):
with open(JSON, "rb") as f:
bruto = f.read()
import json
doc = json.loads(bruto)
n = doc["n"]
dados = np.array(doc["dados"], dtype=float)
if dados.size != len(ESTADOS) * n * PASSO:
falhar(f"dados com {dados.size} números, esperado {len(ESTADOS) * n * PASSO} (mesmo N em todos os estados)")
dados = dados.reshape(len(ESTADOS), n, PASSO)
if n > 320:
falhar(f"N={n} > 320")
if n * tris > 60000:
falhar(f"N x triângulos = {n * tris} > 60000")
if os.path.getsize(GLB) > 40 * 1024:
falhar(f"bloco.glb com {os.path.getsize(GLB)} bytes > 40 KB")
estados = [(f"estado-{nome}.webp", 90) for nome in ESTADOS[1:]]
for nome, limite in (("hero-poster.webp", 90), *estados, ("matcap-porcelana.webp", 30), ("matcap-lima.webp", 30)):
tamanho = os.path.getsize(os.path.join(PUBLICO, nome))
if tamanho > limite * 1024:
falhar(f"{nome} com {tamanho} bytes > {limite} KB")
if len(gzip.compress(bruto)) > 40 * 1024:
falhar("blocos.json em gzip > 40 KB")
meia = np.array(CAIXA) / 2 + 1e-3
for nome, e in zip(ESTADOS, dados):
vis = e[e[:, 3] > 0]
ocultos = n - len(vis)
if ocultos > 0.15 * n:
falhar(f"{nome}: {ocultos} ocultos > 15% de {n}")
if np.any(np.abs(vis[:, :3]) + vis[:, 3:6] / 2 > meia):
falhar(f"{nome}: sai da caixa {CAIXA}")
if nome != "marca":
if np.any(np.abs(vis[:, 3] - vis[:, 4]) > 1e-3) or np.any(np.abs(vis[:, 3] - vis[:, 5]) > 1e-3):
falhar(f"{nome}: blocos não cúbicos")
if vis[:, 6].sum() > 0.25 * len(vis):
falhar(f"{nome}: lima em {int(vis[:, 6].sum())} de {len(vis)} visíveis > 25%")
# marca: em cada camada, os blocos lima ladrilham os quatro retângulos com tolerância
# de 0.5 unidade do SVG: cada bloco dentro de um retângulo, sem sobreposição e com a
# área de cada retângulo preenchida (folga de 0.5 em todo o perímetro).
tol = 0.5
marca = dados[0]
area_poligono = abs(sum(
a[0] * b[1] - b[0] * a[1] for a, b in zip(POLIGONO, POLIGONO[1:] + POLIGONO[:1])
)) / 2
for z in np.unique(marca[:, 2]):
camada = marca[marca[:, 2] == z]
u = camada[:, 0] / ESCALA + CU
v = -camada[:, 1] / ESCALA + CV
lu = camada[:, 3] / ESCALA
lv = camada[:, 4] / ESCALA
caixas = [(u[b] - lu[b] / 2, v[b] - lv[b] / 2, u[b] + lu[b] / 2, v[b] + lv[b] / 2)
for b in np.flatnonzero(camada[:, 6] == 1)]
areas = [0.0] * len(LIMAS)
for c in caixas:
dentro = [r for r, (u0, v0, u1, v1) in enumerate(LIMAS)
if c[0] >= u0 - tol and c[1] >= v0 - tol and c[2] <= u1 + tol and c[3] <= v1 + tol]
if not dentro:
falhar(f"marca z={z}: bloco lima {c} fora dos retângulos lima")
areas[dentro[0]] += (c[2] - c[0]) * (c[3] - c[1])
for (u0, v0, u1, v1), area in zip(LIMAS, areas):
if abs(area - (u1 - u0) * (v1 - v0)) > tol * 2 * (u1 - u0 + v1 - v0):
falhar(f"marca z={z}: retângulo lima {(u0, v0, u1, v1)} coberto com área {area:.0f}")
for a in range(len(caixas)):
for b in range(a):
p, q = caixas[a], caixas[b]
if min(p[2], q[2]) - max(p[0], q[0]) > tol and min(p[3], q[3]) - max(p[1], q[1]) > tol:
falhar(f"marca z={z}: blocos lima sobrepostos {p} {q}")
brancos = camada[:, 6] == 0
area = float((lu[brancos] * lv[brancos]).sum())
if abs(area - area_poligono) > 0.01 * area_poligono:
falhar(f"marca z={z}: área branca {area:.0f} != polígono {area_poligono:.0f}")
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
os.makedirs(PUBLICO, exist_ok=True)
os.makedirs(SAIDA, exist_ok=True)
marca = estado_marca()
n = len(marca)
estados = [completar(b, n) for b in (marca, estado_tela(), estado_integracoes(), estado_dados())]
numeros = ",".join(fmt(v) for e in estados for b in e for v in b)
lista = ",".join(f'"{e}"' for e in ESTADOS)
with open(JSON, "w") as f:
f.write(f'{{"n":{n},"passo":{PASSO},"estados":[{lista}],"dados":[{numeros}]}}')
bloco = criar_bloco()
bloco.data.calc_loop_triangles()
tris = len(bloco.data.loop_triangles)
exportar_glb(bloco)
bpy.context.scene.collection.objects.unlink(bloco) # a malha fica só nas instâncias
colecoes, materiais = montar_cena(estados, bloco.data)
bpy.data.objects.remove(bloco)
renderizar(colecoes)
render_final(colecoes, materiais)
bpy.context.preferences.filepaths.save_version = 0 # sem blocos.blend1
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(SAIDA, "blocos.blend"))
conferir(tris)
for nome, e in zip(ESTADOS, estados):
vis = [b for b in e if b[3] > 0]
print(f"{nome}: {len(vis)} visíveis, {sum(b[6] for b in vis)} lima", flush=True)
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK blocos={n} tris={n * tris}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
if __name__ == "__main__":
main()
+341
View File
@@ -0,0 +1,341 @@
"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s.
Rodar da raiz do repositório: blender -b --python 3d/pecas.py
Renderiza os quadros em 3d/out/pecas/<nome>/ e o ffmpeg do sistema gera em public/3d/
peca-<nome>.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero.
"""
import ctypes
import math
import os
import subprocess
import sys
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from blocos import ( # noqa: E402
LIMAS,
PUBLICO,
SAIDA,
criar_bloco,
criar_materiais,
estado_marca,
falhar,
ligar_cycles,
luz_de_estudio,
sair,
svg_para_mundo,
)
QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0
LADO = (640, 400)
TINTA = "0x0a0a0a" # --ink
AMOSTRAS = 64
LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000
def encaixe(f, inicio, duracao):
"""0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto."""
t = min(max((f - inicio) / duracao, 0), 1)
return 1 - (1 - t) ** 3
# ---------------------------------------------------------------- peças
# Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f.
# Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1.
def sistema(novo):
# planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho
for c in range(7):
for r in range(5):
novo((c - 3, 2 - r, 0), 1, int(r == 0))
# as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas
barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))]
def pose(f):
for k, pilha in enumerate(barras):
sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8)
for j, obj in enumerate(pilha):
obj.location.z = (1 + j) * sobe
return pose
def whatsapp(novo):
# celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela
for c in range(9):
for r in range(16):
borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15))
canto_de_dentro = c in (1, 7) and r in (1, 14)
if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima
novo((c - 4, 0, 7.5 - r), 1, 0)
novo((-2, 0, 6.5), 1, 1) # foto do contato
for i in range(4):
novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto
# balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo)
p = 0.7
def balao(k, esquerda, tom):
borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga
sentido = 1 if esquerda else -1
topo = 2.95 - 3.15 * k
blocos = []
for i in range(6):
for j in range(3):
if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2):
continue
alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p))
blocos.append((novo(alvo, p, tom), alvo))
return blocos, Vector((borda, 0, topo - 2.5 * p))
baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)]
pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro
def pose(f):
some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo
for (blocos, rabo), chega in zip(baloes, (2, 14, 44)):
s = encaixe(f, chega, 8) * (1 - some)
for obj, alvo in blocos:
obj.location = rabo + (alvo - rabo) * s
obj.scale = (p * max(s, 0.001),) * 3
obj.hide_render = s <= 0
aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3))
for d, obj in enumerate(pontos):
pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro
s = aparece * 0.55 * (1 + 0.45 * pulso)
obj.scale = (max(s, 0.001),) * 3
obj.hide_render = s <= 0
return pose
def quatrocentos(novo):
# o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar
x0, y1 = svg_para_mundo(*LIMAS[2][:2])
x1, y0 = svg_para_mundo(*LIMAS[2][2:])
solto = []
for x, y, z, sx, sy, sz, tom in estado_marca():
obj = novo((x, -z, y), (sx, sz, sy), tom)
if tom and x0 < x < x1 and y0 < y < y1:
solto.append((obj, Vector((x, -z, y))))
centro = sum((alvo for _, alvo in solto), Vector()) / len(solto)
def pose(f):
if f < 32:
queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera
d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda))
giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai
else:
d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8))))
giro = Matrix.Identity(3)
for obj, alvo in solto:
obj.location = centro + giro @ (alvo - centro) + d
obj.rotation_euler = giro.to_euler()
return pose
def selo_conferido(novo, x0, z0, y, passo):
"""Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda."""
desenho = ("....#", "...##", "#.##.", "###..", ".#...")
vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"}
blocos = []
for c in range(7):
for r in range(7):
if (c, r) not in vazado:
alvo = Vector((x0 + c * passo, y, z0 - r * passo))
blocos.append((novo(alvo, passo, 1), alvo))
return blocos
def fiscal(novo):
# nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1
linhas = {2: 7, 4: 9, 6: 6, 8: 8}
for c in range(11):
for r in range(16):
if not 1 <= c <= linhas.get(r, 0):
novo((c - 5, 0, 7.5 - r), 1, 0)
selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V
de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente
para_cima = Vector((6, -4, 20))
def pose(f):
sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando
d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai
for obj, alvo in selo:
obj.location = alvo + d
return pose
REVISAO_CAMERA = (-0.25, -0.95, 0.15)
def revisao(novo):
# janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo
linhas = {4: 8, 6: 10, 8: 6, 10: 9}
# três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro)
fora = {
(3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)),
(5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)),
(7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)),
}
soltos = []
for c in range(13):
for r in range(13):
if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0):
obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0)
if (c, r) in fora:
desloc, giro = fora[(c, r)]
soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro)))
# lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita
lupa = []
for dx in range(-2, 3):
for dz in range(-2, 3):
if 1.5 < math.hypot(dx, dz) <= 2.6:
lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz))))
for k in range(3):
d = (3.0 + 0.95 * k) / math.sqrt(2)
cabo = novo((0, 0, 0), 1, 1)
cabo.rotation_euler = (0, math.pi / 4, 0)
lupa.append((cabo, Vector((d, 0, -d))))
lima = lupa[0][0].material_slots[0].material
porcelana = soltos[0][0].material_slots[0].material
# a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro
rumo = Vector(REVISAO_CAMERA).normalized()
def na_frente(p):
return Vector(p) + rumo * (3.5 / -rumo.y)
a, b, c = (alvo for _, alvo, _, _ in soltos)
caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)),
(25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip
chegadas = (9, 21, 33)
selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita
de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro
def lupa_em(f):
for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]):
if f <= f1:
t = min(max((f - f0) / (f1 - f0), 0), 1)
return p0.lerp(p1, t * t * (3 - 2 * t))
return caminho[-1][1]
def pose(f):
centro = lupa_em(f)
for obj, desvio in lupa:
obj.location = centro + desvio
for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)):
s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar
obj.location = alvo + desloc * (1 - s)
obj.rotation_euler = giro * (1 - s)
obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana
d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3)
for obj, alvo in selo:
obj.location = alvo + d
return pose
# nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada)
PECAS = [
("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36),
("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56),
("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0),
("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36),
("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20),
]
# ------------------------------------------------------------- ffmpeg
def ffmpeg(*args):
fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"]
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True)
def codificar(pasta, nome, poster):
destino = os.path.join(PUBLICO, f"peca-{nome}")
# quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores)
compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"]
cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1",
f"{destino}.webm") # fmt: skip
ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart",
f"{destino}.mp4") # fmt: skip
ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80",
f"{destino}.webp") # fmt: skip
for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)):
tamanho = os.path.getsize(f"{destino}.{ext}")
print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True)
if tamanho > limite:
falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}")
# ------------------------------------------------------------- Blender
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
cena = bpy.context.scene
cena.view_settings.view_transform = "Standard"
cena.world = bpy.data.worlds.new("fundo")
cena.render.use_persistent_data = True
ligar_cycles(cena)
luz_de_estudio(cena)
bloco = criar_bloco()
cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias
malha = bloco.data
bpy.data.objects.remove(bloco)
materiais = criar_materiais()
malha.materials.append(materiais[0])
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(camera)
camera.data.lens = 85
cena.camera = camera
colecoes = {}
for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS:
col = bpy.data.collections.new(nome)
cena.collection.children.link(col)
colecoes[nome] = col
def novo(posicao, escala, tom, col=col):
obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha)
col.objects.link(obj)
obj.location = posicao
obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3
obj.material_slots[0].link = "OBJECT"
obj.material_slots[0].material = materiais[tom]
return obj
pose = peca(novo)
for outro, c in colecoes.items():
c.hide_render = outro != nome
direcao = Vector(direcao).normalized()
camera.location = Vector(alvo) + direcao * distancia
camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler()
pasta = os.path.join(SAIDA, "pecas", nome)
os.makedirs(pasta, exist_ok=True)
for f in range(QUADROS):
pose(f)
sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True)
codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png"))
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
print(f"OK pecas={len(PECAS)}", flush=True)
sys.exit(0) # sai já, para o OK ser a última linha
main()
+83
View File
@@ -0,0 +1,83 @@
# DESIGN.md
Registro das decisões de desenho do evandro.dev.br. O código (`app/globals.css`, componentes) é a fonte dos valores; este arquivo diz o porquê.
## Conceito
```
Ideia: os blocos da marca são as peças com que se monta o site ou o sistema de cada cliente
Metáfora: kit de blocos de encaixe, como uma maquete
Assinatura: no hero, os blocos da marca se soltam e se remontam com a rolagem em tela, integrações e dados
Emoção: nos três primeiros segundos, "isto foi montado peça por peça"
Verbo: encaixar: chega rápido, assenta curto, sem quique
Quadro: a marca em porcelana com os quatro cubos verdes ao lado do título
```
Âncora: o símbolo é feito de blocos (`public/brand/mark-dark.svg`); o conceito não serve para outra marca.
## Paleta
| Papel | Token | Valor |
|---|---|---|
| Fundo escuro, tinta | `--ink` | `#0a0a0a` |
| Fundo claro | `--paper` | `#f4f2ed` |
| Fundo claro, segundo plano | `--paper-dark` | `#eceae4` |
| Acento | `--slash` | `#ccff00` |
| Texto secundário | `--meta` | `#4a4a4a` |
| Controles que abrem o WhatsApp | `--whatsapp` | `#25d366` |
O acento aparece em pouca área: um trecho do título, um botão, os cubos da marca. Verde sobre preto é a identidade da marca desde 2026-09, não uma escolha de tendência.
## Tipografia
- Display e corpo: Space Grotesk (`--font-display`).
- Rótulos, números e código: Geist Mono (`--font-mono`), 13 px, sem caixa alta, `letter-spacing` de 0,02em.
## Forma
Cantos retos por padrão (blocos, molduras, botões do hero). Raio aparece em poucos lugares: formulário de contato, botão do WhatsApp e indicadores circulares.
## Movimento
Só CSS e Three.js direto. Sem biblioteca de animação (GSAP, Motion e React Three Fiber saíram em 2026-10-14 por desempenho).
| Uso | Duração | Curva |
|---|---|---|
| Entrada de componente | 280 ms | `--ease-out` |
| Saída de componente | 180 ms | `--ease-in` |
| Entrada do título e do hero | 850 a 1100 ms | `--ease-out` |
| Troca de página | 600 ms; a capa compartilhada viaja em 500 ms | cortina em degraus de 8 colunas; `--ease-out` na capa |
| Detalhes de bloco (cursor, cortina) | conforme o caso | `steps()` |
Regras:
- Animam só `transform` (e `translate`, `scale`), `opacity`, `clip-path` e `mask`. Única exceção: a altura das perguntas frequentes ao abrir e fechar.
- O texto está no HTML desde o primeiro quadro; a animação nunca esconde conteúdo de quem não a recebe.
- Coisa grande se move devagar, detalhe se move rápido; escalonamento entre 20 e 80 ms.
- Com `prefers-reduced-motion: reduce` não há cena, rolagem suave nem animação; o conteúdo aparece inteiro.
## Resposta ao ponteiro e ao foco
Um tratamento por papel, sempre entrando pela esquerda em 280 ms com `--ease-out`; com movimento reduzido a troca é instantânea.
| Papel | Tratamento |
|---|---|
| Botão verde | um bloco cor de papel cobre o botão e o texto continua escuro; sobre fundo claro o bloco é escuro e o texto inverte |
| Botão de contorno | o mesmo bloco: translúcido no fundo escuro, escuro no fundo claro (o texto inverte) |
| Link de texto e item do menu | uma barra de 2 px cresce sob o texto |
| Item da vitrine | uma barra verde entra pela esquerda |
| Tocar vídeo | o círculo cresce e fica verde |
| Logo | os quatro quadrados verdes sobem em sequência e assentam |
"Falar sobre meu projeto" sempre leva ao formulário. Controle que abre o WhatsApp se chama "Chamar no WhatsApp".
## Cena 3D
Fonte em `3d/blocos.py` (Blender), saída em `public/3d/`. Um bloco chanfrado, repetido, com quatro estados: `marca`, `tela`, `integracoes`, `dados`.
| Nível (`sceneTier()` em `lib/device.ts`) | Quem recebe | O que recebe |
|---|---|---|
| `full` | computador com GPU, mais de 4 núcleos e mais de 4 GB | cena em tempo real guiada pela rolagem |
| `lite` | celular com 6 núcleos ou mais e 4 GB ou mais | cena em tempo real leve, que troca de estado sozinha |
| `static` | o resto, movimento reduzido, economia de dados, ou quem não segura 28 ms por quadro | pôster renderizado no Blender |
O pôster é o primeiro quadro de todos os níveis e o LCP continua sendo o título.
+53
View File
@@ -0,0 +1,53 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- **Principal:** dono ou gerente de pequena ou média empresa de Joinville e região (comércio, serviços, indústria pequena). Não é técnico, chega pelo Google ou por indicação, decide sozinho e prefere falar pelo WhatsApp. Quer um site que traga pedidos ou um sistema para a operação.
- **Secundário:** recrutadores e empresas de tecnologia avaliando o Evandro como desenvolvedor. O site também precisa mostrar a eles o nível técnico do trabalho.
## Product Purpose
Site de Evandro Fonseca Junior, engenheiro da computação de Garuva, SC, que vende sites e sistemas sob medida. O site existe para levar o visitante a uma conversa pelo WhatsApp sobre o projeto dele. Sucesso é conversa iniciada com o tipo de projeto já identificado.
## Positioning
"Você fala com quem desenvolve." A mesma pessoa entende o negócio, decide a estrutura e programa. Cuida de tela, dados e equipamentos (integração com hardware e IoT, experiência na Apolo Energia desde 2022). Usa IA como ferramenta e responde pelo resultado.
## Operating Context
- A conversa começa pelo WhatsApp, com a mensagem já montada pelo formulário; nada é enviado automaticamente.
- Ofertas atuais: site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema feito com IA (`/ia`).
- Páginas de serviço em `/servicos` e casos em `/cases`.
## Capabilities and Constraints
- Sem preço e sem prazo no site: isso se combina na conversa.
- O título é o primeiro conteúdo a pintar (LCP); o site funciona sem JavaScript e com movimento reduzido.
- 3D e vídeo só onde o aparelho aguenta; quem não aguenta recebe a versão estática.
- Métricas de navegação só com consentimento.
## Brand Commitments
- Nome: Evandro.dev.br. Símbolo de blocos (`public/brand/mark-dark.svg`), verde `#ccff00` sobre preto.
- Voz: português direto, frase curta, sem jargão, sem promessa que não dá para provar.
## Evidence on Hand
- Clientes reais: SEV Moda Íntima, Lavação 047, SC Plus Aquicultura, AS Serviços, Julia Beauty, com vídeos e capturas em `public/videos/`.
- 5 anos de experiência, calculados a partir de `public/portfolio-data.json`.
- Casos de falha em sistemas feitos com IA, com fonte, em `app/ia/page.tsx`.
- Não há depoimento com número, prêmio nem métrica de resultado de cliente: não inventar.
## Product Principles
1. Cada afirmação tem fonte ou prova à vista; o que não foi provado é dito como não provado.
2. Mostrar trabalho real antes de falar de tecnologia.
3. O visitante leigo entende em três segundos o que é oferecido e como falar.
4. Desempenho e acessibilidade vêm antes do efeito.
5. A IA aparece como ferramenta sob responsabilidade de um engenheiro, nunca como inimiga de quem contrata.
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
/**
* O React estável do Jest não exporta <ViewTransition> (só o canary do Next). Para conferir as props dele:
* `jest.mock("react", () => jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe());`
* O dublê põe `name`, `share` e `default` num <div> em volta dos filhos (data-vt-name, data-vt-share, data-vt-default).
*/
export function reactWithProbe() {
const actual = jest.requireActual("react");
function ViewTransition({
children,
name,
share,
default: fallback,
}: {
children?: ReactNode;
name?: string;
share?: string;
default?: string;
}) {
return actual.createElement(
"div",
{
"data-vt-name": name,
"data-vt-share": share,
"data-vt-default": fallback,
},
children,
);
}
return { ...actual, ViewTransition };
}
+1 -1
View File
@@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
const contacts = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(contacts).toHaveLength(1);
expect(contacts[0]).toBeVisible();
+10 -10
View File
@@ -62,8 +62,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos.
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
@@ -72,7 +72,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
expect(ctas).toHaveLength(1);
for (const cta of ctas) {
const destination = new URL(cta.getAttribute("href") ?? "");
@@ -179,13 +179,13 @@ it("renders a client case in the sales order, with media, contact and collapsed
const capabilitiesSection = sectionHeadings[4].closest("section");
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
}),
).toBeNull();
const closingSection = sectionHeadings[5].closest("section");
expect(closingSection).toContainElement(
within(closingSection as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
}),
);
for (const capability of caseStudy.capabilities ?? []) {
@@ -371,10 +371,10 @@ it("writes what the client can do now right after 'Como ficou', with no contact
expect(items.length).toBeLessThanOrEqual(3);
// sem número inventado
expect(items.join(" ")).not.toMatch(/\d/);
// o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
}),
).toBeNull();
const closingSection = screen
@@ -386,7 +386,7 @@ it("writes what the client can do now right after 'Como ficou', with no contact
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
expect(
within(closingSection as HTMLElement).getAllByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
}),
).toHaveLength(1);
unmount();
@@ -477,7 +477,7 @@ it("shows at most two related services, as cards under the contact button", asyn
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
// o botão do fecho (o único) vem antes dos cartões
const contact = screen.getByRole("link", { name: buttonLabels.contact });
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
expect(isBefore(contact, cards[0])).toBe(true);
unmount();
@@ -748,7 +748,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
expect(hrefs).toContain("/servicos");
const cta = screen.getByRole("link", { name: buttonLabels.contact });
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
@@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
/**
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
*/
@@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => {
for (const item of capabilities) {
expect(screen.getByText(item)).toBeVisible();
}
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(0);
});
@@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content";
/**
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
*/
export function CaseCapabilitiesSection({
caseStudy,
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
name: "Quer um projeto parecido com este?",
}),
).toBeVisible();
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(cta).toHaveAttribute("target", "_blank");
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
expect(messageOf(cta)).toContain(
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
// o rótulo do botão é o mesmo do case de cliente: uma lista só
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
+3 -3
View File
@@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices";
/**
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
* (e no botão do cabeçalho): `similarProjectSubject`.
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
* `similarProjectSubject`.
*/
const contactCopy = {
client: { title: "Quer um projeto parecido com este?" },
@@ -47,7 +47,7 @@ export function CaseContactSection({
placement="case_detail"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
+27 -2
View File
@@ -1,15 +1,23 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
* e o pôster como elemento compartilhado da troca de página.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
@@ -22,6 +30,23 @@ afterEach(() => {
});
describe("CaseCover", () => {
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
const transition = image.parentElement;
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(new Set(names).size).toBe(names.length);
});
it("path: no desktop capture -> renders nothing", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCover {...props} />);
+12 -8
View File
@@ -1,9 +1,11 @@
import Image from "next/image";
import { ViewTransition } from "react";
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
@@ -54,14 +56,16 @@ export function CaseCover({
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
<ViewTransition {...coverTransitionProps(id)}>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</ViewTransition>
</div>
</BrowserFrame>
);
+2 -2
View File
@@ -70,9 +70,9 @@ export function CaseHero({
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
{caseStudy.title}
{isClient ? "Case de cliente" : "Projeto de laboratório"}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
+1 -1
View File
@@ -43,7 +43,7 @@ export function CaseMediaSection({
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
+12 -3
View File
@@ -27,9 +27,18 @@ describe("CasePlayer", () => {
name: `Reproduzir o vídeo do site ${title} no computador`,
});
expect(button).toHaveAttribute("type", "button");
// só botão de contato é limão: o de play é escuro, com contorno branco
expect(button.innerHTML).not.toContain("--slash");
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
const circle = button.querySelector("[aria-hidden='true']");
const restClasses = (circle?.className ?? "")
.split(" ")
.filter((name) => !name.startsWith("group-"));
expect(restClasses.join(" ")).not.toContain("--slash");
expect(circle).toHaveClass(
"group-hover:scale-108",
"group-hover:bg-[var(--slash)]",
"group-focus-visible:scale-108",
"group-focus-visible:bg-[var(--slash)]",
);
});
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
+2 -1
View File
@@ -78,9 +78,10 @@ export function CasePlayer({
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] motion-reduce:transition-none sm:size-16"
>
<Play className="ml-1 size-6" fill="currentColor" />
</span>
+10
View File
@@ -0,0 +1,10 @@
/**
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
* CaseCover lê as capturas só no servidor.
*/
export function coverTransitionProps(id: string) {
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
}
+5 -2
View File
@@ -43,11 +43,14 @@ describe("CasesPage: carregamento das capas", () => {
it("loads the first card cover at once with high priority, and every other image lazily", () => {
const { container } = render(<CasesPage />);
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
// sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === "");
expect(decorative).toHaveLength(0);
expect(decorative).toHaveLength(1);
expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp");
expect(decorative[0]).toHaveAttribute("loading", "lazy");
expect(decorative[0]).not.toHaveAttribute("fetchpriority");
const [first, ...others] = coverImages(container);
expect(first).toHaveAttribute("loading", "eager");
+29 -7
View File
@@ -77,11 +77,27 @@ describe("CasesPage: cases de cliente", () => {
).toBeNull();
});
it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
it("path: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => {
const { container } = render(<CasesPage />);
const wide = clientCasesForListing.map((caseStudy) =>
clientCard(container, caseStudy.id).className.includes(
"min-[1025px]:col-span-2",
),
);
const odd = clientCasesForListing.length % 2 === 1;
expect(wide).toEqual(
clientCasesForListing.map(
(_, index) => odd && index === clientCasesForListing.length - 1,
),
);
});
it("path: contact -> 'Chamar no WhatsApp', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
const { container } = render(<CasesPage />);
expect(
screen.getByRole("link", { name: buttonLabels.contact }),
screen.getByRole("link", { name: buttonLabels.whatsapp }),
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
const clientTitles = Array.from(
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
@@ -98,18 +114,24 @@ describe("CasesPage: cases de cliente", () => {
]);
// um botão de contato só na página: o do fecho
expect(
screen.getAllByRole("link", { name: buttonLabels.contact }),
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
).toHaveLength(1);
});
});
describe("CasesPage: topo sem mosaico", () => {
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
const images = hero?.querySelectorAll("img") ?? [];
expect(images).toHaveLength(1);
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
// decorativa e só de 1024 px em diante
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
expect(hero?.className).toContain("md:!pb-sp-12");
// o título e o subtítulo não repetem a mesma frase
const heading = within(hero as HTMLElement).getByRole("heading", {
@@ -130,7 +152,7 @@ describe("CasesPage: topo sem mosaico", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelectorAll("img")).toHaveLength(1);
const firstCover = clientCard(
container,
clientCasesForListing[0].id,
+45 -28
View File
@@ -8,6 +8,7 @@ import {
textLink,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { PagePiece } from "@/app/components/PagePiece";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -20,7 +21,7 @@ import {
} from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero } from "./_components/hero-styles";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
@@ -42,45 +43,60 @@ export const metadata: Metadata = {
}),
};
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
/**
* Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case.
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
*/
function ClientCaseCard({
caseStudy,
priority = false,
wide = false,
}: {
caseStudy: CaseStudyContent;
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
priority?: boolean;
wide?: boolean;
}) {
return (
<article className="ds-card !h-auto">
<article
className={`ds-card !h-auto ${
// a partir de 1025 px, onde .ds-grid-2 (globals.css) passa a duas colunas; o "!" vence o display:flex de .ds-card,
// que fica fora das camadas do Tailwind
wide
? "min-[1025px]:col-span-2 min-[1025px]:!grid min-[1025px]:grid-cols-2 min-[1025px]:items-center min-[1025px]:gap-x-sp-8 min-[1025px]:[&>:first-child]:mb-0"
: ""
}`}
>
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
<div>
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
{buttonLabels.site}
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
</div>
</article>
);
@@ -144,15 +160,14 @@ export default function CasesPage() {
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className="ds-section-shell">
<div className="max-w-[860px]">
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
<div className="min-w-0 max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<p className="ds-eyebrow">Trabalhos entregues</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Veja o que já fiz para empresas.
</h1>
@@ -160,6 +175,7 @@ export default function CasesPage() {
{pageLead}
</p>
</div>
<PagePiece image="/3d/estado-tela.webp" />
</div>
</section>
@@ -169,11 +185,12 @@ export default function CasesPage() {
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCasesForListing.map((caseStudy, index) => (
{clientCasesForListing.map((caseStudy, index, all) => (
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
priority={index === 0}
wide={index === all.length - 1 && all.length % 2 === 1}
/>
))}
</div>
@@ -211,7 +228,7 @@ export default function CasesPage() {
placement="cases_hub_footer"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
{buttonLabels.allServices}
+29 -2
View File
@@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit
expect(none.container).toBeEmptyDOMElement();
});
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11");
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
}
});
@@ -107,3 +107,30 @@ it("keeps the years number in the display font (the mono font is not downloaded
expect(screen.getByText("5").className).not.toContain("font-mono");
});
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
render(
<ClientStrip
clients={[
{
id: "sev-modaintima",
name: "SEV Moda Íntima",
sector: "loja virtual",
},
{ id: "lavacao-047", name: "Lavação 047" },
]}
/>,
);
const sector = screen.getByText("loja virtual");
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
expect(link).not.toContainElement(sector);
expect(link.parentElement).toContainElement(sector);
expect(link.parentElement).toHaveClass("flex-col");
// o nome continua levando ao case
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
const plain = screen.getByRole("link", { name: "Lavação 047" });
expect(plain.parentElement?.childElementCount).toBe(1);
});
+28 -11
View File
@@ -1,11 +1,19 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
export type ClientLink = { id: string; name: string };
export type ClientLink = {
id: string;
name: string;
/** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */
sector?: string;
};
/**
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
* De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso,
* só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a
* altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham
* pela linha dos nomes (`sm:items-start`).
*
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
@@ -30,12 +38,19 @@ export default function ClientStrip({
...clients.map((client) => ({
key: client.id,
node: (
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</HoverPrefetchLink>
<span className="flex flex-col">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center self-start font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] sm:min-h-6"
>
{client.name}
</HoverPrefetchLink>
{client.sector && (
<span className="text-[13px] leading-[18px] max-sm:hidden">
{client.sector}
</span>
)}
</span>
),
})),
...(years
@@ -57,14 +72,16 @@ export default function ClientStrip({
return (
<div
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] sm:items-start ${className}`.trim()}
>
{clients.length > 0 && <p>Já entregue para:</p>}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
{/* de 640 px em diante a lista fica ao lado do rótulo enquanto sobram 24rem, e quebra por dentro; menos que isso,
ela desce para baixo do rótulo e usa a largura toda */}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1 sm:min-w-[min(24rem,100%)] sm:flex-1 sm:basis-0 sm:items-start sm:gap-y-sp-2">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
className={`flex items-center pr-4 sm:items-start${index > 0 ? " -ml-4" : ""}`}
>
{index > 0 && (
<span
+15 -1
View File
@@ -24,7 +24,7 @@ afterEach(() => {
});
describe("Contact: opções do tipo de projeto", () => {
it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
it("lists the five offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option");
@@ -32,10 +32,12 @@ describe("Contact: opções do tipo de projeto", () => {
"Ainda preciso definir",
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
@@ -61,7 +63,9 @@ describe("Contact: tipo escolhido pela oferta", () => {
it.each([
["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"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
@@ -156,7 +160,9 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
it.each([
["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"],
])(
"after the offer %s the form says 'Você escolheu: <name>'",
(kind, name) => {
@@ -201,6 +207,14 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
expect(directMessage()).toContain(
"Quero conversar sobre a revisão de um sistema que já existe.",
);
choose("whatsapp");
expect(directMessage()).toContain(
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
);
choose("fiscal");
expect(directMessage()).toContain(
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
);
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
await user.selectOptions(
subjectSelect(),
+7 -7
View File
@@ -265,11 +265,11 @@ export default function Contact() {
service={directSubject}
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-[15px] text-white/65">
@@ -277,7 +277,7 @@ export default function Contact() {
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
aria-hidden="true"
/>
</WhatsAppLink>
@@ -310,7 +310,7 @@ export default function Contact() {
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
@@ -412,7 +412,7 @@ export default function Contact() {
</Field>
<button
type="submit"
className="cv-btn flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
className="cv-btn cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
@@ -444,9 +444,9 @@ export default function Contact() {
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Conversar no WhatsApp
Chamar no WhatsApp
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
+1 -1
View File
@@ -42,7 +42,7 @@ export function Faq({
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
className="ds-faq-item group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useSyncExternalStore } from "react";
import { textLink } from "./button-styles";
import { isFiscalNoticeActive } from "./fiscal-notice";
import HoverPrefetchLink from "./HoverPrefetchLink";
/** A data não muda enquanto a página está aberta: nada a observar. */
const subscribe = () => () => {};
/**
* Faixa datada da nota fiscal, acima dos cartões da home. Quem a coloca na página (WorkModalities) só a renderiza dentro
* do prazo; aqui o navegador confere a data de novo, porque a home é gerada no build: depois do prazo ela some sem um
* build novo. Na hidratação vale o valor do servidor (visível), e só depois a data do navegador: sem aviso de hidratação.
*/
export function FiscalNotice({ href }: Readonly<{ href: string }>) {
const active = useSyncExternalStore(
subscribe,
() => isFiscalNoticeActive(new Date()),
() => true,
);
if (!active) return null;
return (
<div className="mb-sp-6 flex flex-col gap-sp-1 border-y border-[var(--border)] py-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
Seu sistema emite nota fiscal?
</strong>{" "}
Os optantes do Simples Nacional entram na reforma tributária em 1º de
janeiro de 2027.
</p>
<HoverPrefetchLink
href={href}
className={`${textLink} shrink-0 self-start text-[var(--ink-80)]`}
>
Ver o que muda
</HoverPrefetchLink>
</div>
);
}
+10 -5
View File
@@ -49,7 +49,7 @@ class FakeObserver {
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
resetNavigation();
@@ -221,7 +221,7 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
it("path: the bar marks <html> while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
@@ -237,12 +237,16 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// o botão flutuante usa a variável publicada pela barra para subir
// a barra publica a altura (o menu do celular a usa) e marca o <html>; o botão flutuante some por CSS com a marca
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
);
await user.click(refuse);
@@ -250,6 +254,7 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
@@ -35,7 +35,7 @@ class FakeObserver {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
const messageOf = (link: HTMLElement | null) =>
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
+5 -16
View File
@@ -52,7 +52,7 @@ function setVisibility(hero: boolean, contact: boolean) {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
beforeEach(() => {
global.IntersectionObserver =
@@ -112,25 +112,14 @@ it("shows right away on a page that has neither the hero button nor the contact
expect(floatingLink()).toBeInTheDocument();
});
it("lifts itself by the height of the open cookie bar", () => {
render(<Page />);
setVisibility(false, false);
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
});
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
it("hides by CSS while the cookie bar is open (the data-cookie-bar mark on <html>), instead of sitting on top of it", () => {
render(<Page />);
setVisibility(false, false);
const className = floatingLink()?.parentElement?.className ?? "";
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
expect(className).toContain("transition-transform");
expect(className).toContain("motion-reduce:transition-none");
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
expect(className).not.toContain("transition-[bottom]");
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
// não sobe mais pela altura da barra: some enquanto ela está aberta
expect(className).not.toContain("--cookie-bar-height");
expect(className).toContain(
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
);
+7 -7
View File
@@ -6,16 +6,16 @@ import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
/** Link "Chamar no WhatsApp" do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact";
/**
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
*/
export default function FloatingWhatsApp({
contexts,
@@ -61,15 +61,15 @@ export default function FloatingWhatsApp({
return (
<aside
aria-label="Contato rápido"
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
className="ds-enter-pop fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] md:hidden [[data-cookie-bar=open]_&]:hidden"
>
<WhatsAppLink
placement="floating"
service={subject}
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25d366] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[#54e58b] motion-reduce:transition-none"
className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--whatsapp)] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[var(--whatsapp-hover)] motion-reduce:transition-none"
>
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Conversar no WhatsApp</span>
<span className="sr-only">Chamar no WhatsApp</span>
</WhatsAppLink>
</aside>
);
+10 -8
View File
@@ -39,20 +39,22 @@ describe("Footer contact and navigation", () => {
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("offers WhatsApp in the contact column, in a new tab", () => {
it("offers WhatsApp in the contact column, in a new tab, named like every control that opens it", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
const whatsApp = within(contact).getByRole("link", {
name: "Chamar no WhatsApp",
});
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
});
it("lists the six services from the services hub as same-tab links", () => {
it("lists the eight services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
expect(serviceHubLinks).toHaveLength(6);
expect(serviceHubLinks).toHaveLength(8);
const services = screen.getByRole("navigation", { name: "Serviços" });
const links = within(services).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
@@ -64,7 +66,7 @@ describe("Footer contact and navigation", () => {
for (const link of links) expect(link).not.toHaveAttribute("target");
});
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
it("links Cases, the /ia page by its menu label, Privacidade and a discreet Currículo, with no PDF download", () => {
render(<Footer />);
const more = screen.getByRole("navigation", { name: "Mais" });
@@ -74,7 +76,7 @@ describe("Footer contact and navigation", () => {
);
expect(
within(more).getByRole("link", {
name: "Revisão de sistema que já existe",
name: "Revisão de app com IA",
}),
).toHaveAttribute("href", "/ia");
expect(
@@ -145,11 +147,11 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
it("names /ia like the menu and the page, and no old 'app feito com IA' name", () => {
render(<Footer />);
expect(
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
screen.getByRole("link", { name: "Revisão de app com IA" }),
).toHaveAttribute("href", "/ia");
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
});
+4 -4
View File
@@ -5,9 +5,9 @@ import {
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import { offerLabels } from "@/lib/offer-labels";
import {
CONTACT_EMAIL,
IA_PAGE_LABEL,
INSTAGRAM_URL,
LINKEDIN_URL,
type ServiceLink,
@@ -18,7 +18,7 @@ import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const headingClassName =
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
"font-mono text-[13px] font-medium tracking-[0.02em] text-[var(--meta)]";
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
const linkClassName =
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
@@ -48,7 +48,7 @@ export default function Footer({
placement="footer"
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
>
WhatsApp
Chamar no WhatsApp
</WhatsAppLink>
</li>
<li>
@@ -118,7 +118,7 @@ export default function Footer({
</li>
<li>
<HoverPrefetchLink href="/ia" className={linkClassName}>
{offerLabels.revisao.name}
{IA_PAGE_LABEL}
</HoverPrefetchLink>
</li>
<li>
+1 -1
View File
@@ -60,7 +60,7 @@ describe("GoogleAnalytics consent", () => {
).toBeVisible();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Usamos métricas de navegação do Google só se você permitir\./,
/Métricas de navegação do Google, só se você permitir\./,
);
expect(
screen.queryByTestId("google-analytics-script"),
+8 -6
View File
@@ -231,16 +231,18 @@ function GoogleAnalyticsConsent() {
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
className="fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] sm:px-sp-6"
className="ds-enter fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] [--enter-y:16px] sm:px-sp-6"
>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
>
Usamos métricas de navegação do Google só se você permitir.{" "}
Métricas de navegação do Google, só se você permitir.{" "}
{/* o preenchimento vertical dá ao link 24 px de alvo de toque sem engordar a linha */}
<a
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
className="whitespace-nowrap py-1 underline underline-offset-2 hover:text-white hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
@@ -257,14 +259,14 @@ function GoogleAnalyticsConsent() {
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
onClick={denyAnalytics}
>
Recusar
+20 -8
View File
@@ -69,7 +69,13 @@ describe("Header", () => {
within(navigation)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
).toEqual([
"Cases",
"Serviços",
"Como trabalho",
"Sobre",
"Revisão de app com IA",
]);
// "Projetos" saiu: duplicava "Cases"
expect(
within(navigation).queryByRole("link", { name: "Projetos" }),
@@ -179,11 +185,15 @@ describe("Header", () => {
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
const links = within(mobileNavigation).getAllByRole("link");
for (const link of links.slice(0, 5)) {
expect(link).toHaveClass("text-[22px]", "min-h-14");
// sob o ponteiro e no foco, a barra que cresce sob o texto (globals.css)
expect(link).toHaveClass("text-[22px]", "min-h-14", "cv-bar");
}
const contact = links[5];
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
expect(contact.parentElement).toHaveClass("mt-auto");
// o botão verde ganha o bloco cor de papel, não só uma troca de cor
expect(contact).toHaveClass("cv-fill");
expect(contact.className).not.toMatch(/hover:bg-/);
});
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
@@ -245,9 +255,13 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
// "Falar sobre meu projeto" é só do formulário: o botão que abre o WhatsApp tem outro nome
expect(
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(sentence);
},
@@ -258,7 +272,7 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
@@ -274,7 +288,7 @@ describe("Header", () => {
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const buttons = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
name: "Chamar no WhatsApp",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) {
@@ -295,9 +309,7 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
expect(
whatsAppHref(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
),
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
).toContain("Quero conversar sobre um projeto.");
});
});
+10 -7
View File
@@ -15,6 +15,8 @@ import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
const WHATSAPP_LABEL = "Chamar no WhatsApp";
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
const INERT_IDS = ["main-content", "site-footer"];
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
@@ -55,7 +57,8 @@ function trapTab(event: KeyboardEvent, header: HTMLElement) {
/**
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
* já com o assunto da página, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
* ("Falar sobre meu projeto").
*/
function ContactCta({
className,
@@ -78,7 +81,7 @@ function ContactCta({
placement={placement}
onClick={onNavigate}
>
{CTA_LABEL}
{WHATSAPP_LABEL}
</WhatsAppLink>
);
}
@@ -218,7 +221,7 @@ export function Header({
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
className="cv-bar inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline [--bar-x:var(--sp-3)] hover:text-white"
>
{item.label}
</HoverPrefetchLink>
@@ -230,7 +233,7 @@ export function Header({
<div className="flex shrink-0 items-center">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
className="cv-fill inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
@@ -262,7 +265,7 @@ export function Header({
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
<div className="ds-enter absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] [--enter-y:-8px] lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-1 flex-col">
<nav
id={MENU_ID}
@@ -274,7 +277,7 @@ export function Header({
<li key={item.href} className="border-b border-white/10">
<HoverPrefetchLink
href={item.href}
className="flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline transition-colors hover:text-white"
className="cv-bar flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline [--bar-y:100%] hover:text-white"
onClick={closeMobileMenu}
>
{item.label}
@@ -284,7 +287,7 @@ export function Header({
</ul>
<div className="mt-auto pt-sp-8">
<ContactCta
className="flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
className="cv-fill flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
+38 -17
View File
@@ -3,7 +3,7 @@ import Hero from "./Hero";
/**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
* links sociais (externo x mailto, com rastreio) e faixa de clientes.
*/
jest.mock("./cinematic/HeroField", () => ({
@@ -107,25 +107,46 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
expect(screen.getByTestId("hero-work")).toHaveTextContent(
"lavacao.example.com",
);
});
it("path: workUrl omitted -> the browser bar shows no address", () => {
render(<Hero />);
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
});
it("path: the main button carries the id that hides the floating WhatsApp", () => {
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).not.toHaveAttribute("id");
});
});
describe("Hero: contato", () => {
it("path: 'Chamar no WhatsApp' -> a whatsapp_open from the hero, with the home subject", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
placement: "hero",
service: "um site ou sistema para a minha empresa",
}),
);
});
it("path: 'Falar sobre meu projeto' -> the form, with no WhatsApp event", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: /Falar sobre meu projeto/ });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(link).toHaveAttribute("href", "/#contact-form");
expect(window.gtag).not.toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.anything(),
);
});
});
+85 -45
View File
@@ -36,49 +36,76 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
});
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
expect(contactLink).toHaveAttribute("target", "_blank");
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(
contactLink.getAttribute("href") ?? "",
).searchParams.get("text");
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
// o botão primário é o único com o fundo limão; o secundário é contorno
expect(contactLink).toHaveAttribute("href", "/#contact-form");
expect(contactLink).not.toHaveAttribute("target");
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
expect(contactLink).toHaveClass("cv-fill");
expect(contactLink.className).not.toMatch(/hover:bg-/);
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).toHaveClass("cv-outline-dark");
expect(projects).not.toHaveAttribute("target");
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
"text",
);
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
expect(whatsApp.className).not.toContain("bg-[var(--slash)]");
expect(whatsApp.querySelector("svg")).toHaveAttribute(
"aria-hidden",
"true",
);
// a ordem do Tab: os dois botões e depois o link
expect(
contactLink.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
projects.compareDocumentPosition(whatsApp) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
);
});
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
render(<Hero />);
const heading = screen.getByRole("heading", { level: 1 });
const highlighted = Array.from(
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
)
.filter((word) => word.className.includes("text-[var(--slash)]"))
.map((word) => word.textContent);
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
heading.querySelectorAll<HTMLElement>(".hero-word"),
).filter((word) => word.className.includes("text-[var(--slash)]"));
expect(highlighted.map((word) => word.textContent)).toEqual([
"sob",
"medida",
]);
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
const marks = heading.querySelectorAll(".hero-mark");
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveClass("whitespace-nowrap");
expect(marks[0].textContent).toBe("sob medida");
for (const word of highlighted) expect(marks[0]).toContainElement(word);
});
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
@@ -106,11 +133,22 @@ describe("Hero", () => {
expect(
screen.getByText(
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
// o primeiro passo, logo abaixo dos botões
const step = screen.getByText(
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
);
expect(step.className).toContain("text-[15px]");
expect(
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
screen
.getByRole("link", { name: "Chamar no WhatsApp" })
.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
// o botão flutuante do WhatsApp observa este id: some enquanto o link do hero está à vista
expect(
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
});
@@ -136,27 +174,15 @@ describe("Hero", () => {
}
});
it("renders the work column with lazy posters that stay out of small screens", () => {
render(<Hero clients={clients} />);
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
const { container } = render(<Hero clients={clients} />);
const work = screen.getByTestId("hero-work");
expect(work).toHaveClass("hidden", "lg:block");
const images = work.querySelectorAll("img");
expect(images).toHaveLength(2);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
}
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
const original = (image: Element) =>
new URL(
image.getAttribute("src") ?? "",
"http://localhost",
).searchParams.get("url");
expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(images[0]).toHaveAccessibleName(
"Site da Lavação 047 aberto no computador",
);
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
const piece = container.querySelector(".hero-piece");
expect(piece).not.toBeNull();
expect(piece).toHaveAttribute("aria-hidden", "true");
const image = piece?.querySelector("img");
expect(image?.getAttribute("src")).toContain("hero-poster");
expect(image).toHaveAttribute("alt", "");
});
it("shows a wide card to the SEV case below the buttons on small screens", () => {
@@ -173,13 +199,27 @@ describe("Hero", () => {
});
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
render(<Hero />);
const { container } = render(<Hero />);
const label = screen.getByText(
/Evandro Fonseca Junior · Engenharia de software/,
const label = container.querySelector(".hero-kicker") as HTMLElement;
expect(label.textContent).toBe(
"Evandro Fonseca Junior · Engenharia de software",
);
expect(label.className).toContain("font-display");
expect(label.className).not.toContain("font-mono");
// sem caixa alta nem espaçamento largo
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
});
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
render(<Hero />);
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
"max-lg:hidden",
);
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
"hidden",
);
});
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
+121 -45
View File
@@ -1,16 +1,20 @@
"use client";
import Image from "next/image";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import { textLinkDark } from "./button-styles";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { CONTACT_FORM_HASH } from "./contact-subject";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
import HeroWork from "./HeroWork";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
const SOCIAL_LINKS = [
{
@@ -33,28 +37,58 @@ export type HeroStats = {
years?: number;
};
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
const ACCENT = new Set([5, 6, 7]);
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
const ACCENT = new Set([3, 4]);
type TitleWord = { word: string; key: string; index: number };
/**
* Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. As palavras
* do destaque seguidas viram um trecho só: elas nunca se separam numa quebra de linha, e o bloco do marcador as cobre
* de uma vez.
*/
const TITLE =
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
.split(" ")
.map((word, index) => ({
word,
// a mesma palavra se repete ("e"), então a posição entra na chave
key: `${word}-${index}`,
accent: ACCENT.has(index),
}));
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
(runs, word, index) => {
const accent = ACCENT.has(index);
// a mesma palavra se repete ("e"), então a posição entra na chave
const entry = { word, key: `${word}-${index}`, index };
const last = runs.at(-1);
if (accent && last?.accent) last.words.push(entry);
else runs.push({ key: entry.key, accent, words: [entry] });
return runs;
},
[],
);
function TitleWords({
words,
accent,
}: {
words: TitleWord[];
accent: boolean;
}) {
return words.map(({ word, key, index }, position) => (
<Fragment key={key}>
{position > 0 && " "}
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>
</Fragment>
));
}
export default function Hero({
stats,
clients,
workUrl,
}: {
stats?: HeroStats;
clients?: ClientLink[];
/** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
workUrl?: HeroWorkUrl;
}) {
return (
<section
@@ -63,8 +97,9 @@ export default function Hero({
>
<div className="hero-stage">
<div className="hero-glow" aria-hidden="true" />
<HeroField />
<div className="hero-scrim" aria-hidden="true" />
{/* a cena 3D fica acima do véu (o mesmo brilho do pôster) e abaixo das legendas e do texto */}
<HeroField />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
@@ -81,38 +116,48 @@ export default function Hero({
</ol>
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)]">
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
{/* Abaixo de 1024 px o rótulo divide a linha com a peça: fica só a profissão, e o nome volta na tela larga. */}
<p className="hero-in hero-kicker mb-sp-4 flex items-center gap-sp-3 font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
/>
<span>
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
Engenharia de software
</span>
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
<h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
{TITLE.map(({ word, key, accent }, index) => (
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
<h1 className="hero-title hero-col max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em]">
{TITLE.map(({ key, accent, words }) => (
<Fragment key={key}>
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>{" "}
{accent ? (
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
<span
className="hero-mark whitespace-nowrap"
style={{ "--i": words[0].index } as CSSProperties}
>
<TitleWords words={words} accent />
</span>
) : (
<TitleWords words={words} accent={false} />
)}{" "}
</Fragment>
))}
</h1>
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
<p className="hero-in hero-col mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.4s]">
Do site institucional ao sistema que a sua equipe usa todo dia. Você
fala com quem desenvolve.
</p>
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
<div className="hero-in hero-col hero-actions mt-sp-5 flex flex-col gap-sp-3 [--d:.48s] sm:flex-row sm:flex-wrap sm:items-center">
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
<HoverPrefetchLink
href={`/${CONTACT_FORM_HASH}`}
className="cv-fill group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline sm:px-sp-6 sm:text-[16px]"
>
Falar sobre meu projeto
<svg
@@ -125,28 +170,59 @@ export default function Hero({
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</WhatsAppLink>
</HoverPrefetchLink>
<HoverPrefetchLink
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
className="cv-outline-dark inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline sm:text-[16px]"
>
Ver projetos entregues
</HoverPrefetchLink>
{/* Abre o WhatsApp com o assunto da home; enquanto está à vista, o botão flutuante (que observa este id) some. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className={`${textLinkDark} gap-sp-2 self-start sm:self-auto`}
>
<WhatsAppMark className="h-4 w-4 shrink-0" />
Chamar no WhatsApp
</WhatsAppLink>
</div>
{/* O primeiro passo, dito antes de a pessoa clicar: tira a dúvida de "e depois, o que acontece?". */}
<p className="hero-in hero-col mt-sp-3 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] [--d:.54s]">
Começa com uma conversa para entender o seu caso. A proposta vem por
escrito.
</p>
<ClientStrip
clients={clients}
years={stats?.years}
className="hero-in mt-sp-3 [--d:.75s]"
className="hero-in hero-col mt-sp-3 [--d:.6s]"
/>
<HeroWork url={workUrl} />
<HeroWork />
{/* A marca em blocos: pôster renderizado no Blender, decorativo. Carrega junto com a página (eager, sem prioridade
alta). O título segue como o maior elemento medido (LCP) no celular e em tela larga: a imagem entra com
fade a partir de opacidade zero e some por `visibility` quando a cena assume (globals.css). */}
<div className="hero-piece hero-in [--d:.3s]" aria-hidden="true">
<Image
src="/3d/hero-poster.webp"
alt=""
width={1200}
height={1200}
sizes="(min-width: 1024px) min(56vw, 816px), 30vw"
loading="eager"
className="block h-auto w-full"
/>
</div>
</div>
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5">
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo; no
computador ficam numa linha só, para o palco fixo caber em 1024x768 */}
<div className="flex-wrap items-center gap-x-sp-8 max-lg:hidden lg:flex">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
@@ -185,7 +261,7 @@ export default function Hero({
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-display text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
+9 -53
View File
@@ -1,9 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import HeroWork from "./HeroWork";
/**
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
* O pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
* LCP), no cartão largo e clicável da SEV para as telas abaixo de 1024 px. Em tela larga a coluna é da peça 3D.
*/
const original = (image: Element) =>
@@ -12,23 +12,9 @@ const original = (image: Element) =>
);
describe("HeroWork: pôsteres no tamanho certo", () => {
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
it("serves the SEV poster through the image optimizer, with a `sizes` that matches how it shows", () => {
render(<HeroWork />);
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
expect(desktop).toHaveLength(2);
expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 500px, 36vw",
);
expect(desktop[1]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 136px, 10vw",
);
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
const small = screen
@@ -55,23 +41,11 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
render(<HeroWork />);
const [browser, phone] = within(
screen.getByTestId("hero-work"),
).getAllByRole("img");
expect(browser).toHaveAttribute("width", "1440");
expect(browser).toHaveAttribute("height", "900");
expect(phone).toHaveAttribute("width", "390");
expect(phone).toHaveAttribute("height", "844");
});
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
const { container } = render(<HeroWork />);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(3);
expect(images).toHaveLength(1);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("fetchpriority", "low");
@@ -82,13 +56,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
});
describe("HeroWork: cartão do celular", () => {
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
render(<HeroWork />);
const column = screen.getByTestId("hero-work");
expect(column).toHaveClass("hidden", "lg:block");
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
const { container } = render(<HeroWork />);
const card = screen.getByTestId("hero-work-mobile");
expect(container.firstChild).toBe(card);
expect(container.childElementCount).toBe(1);
expect(card).toHaveClass("lg:hidden", "w-full");
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
expect(card.querySelector(".ds-phone")).toBeNull();
@@ -119,21 +92,4 @@ describe("HeroWork: cartão do celular", () => {
expect(screen.getAllByRole("link")).toHaveLength(1);
});
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
render(<HeroWork />);
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
expect(
within(screen.getByTestId("hero-work")).queryByRole("link"),
).toBeNull();
});
it("passes the address to the browser bar of the desktop frame", () => {
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
expect(
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
).toBeInTheDocument();
});
});
+44 -93
View File
@@ -1,16 +1,8 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "lavacao-047";
const DESKTOP_PROJECT_TITLE = "Lavação 047";
const MOBILE_PROJECT = "as-servicos";
/** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
/** O cartão mostra o site da SEV e leva ao case. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
@@ -28,95 +20,54 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
/**
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
* Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
* fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
* Trabalho real do hero, só abaixo de 1024 px: um cartão largo e clicável (a largura da coluna, cerca de 350 px num
* celular) com o pôster do site da SEV, abaixo dos botões, que leva ao case da SEV; antes era uma moldura de celular
* de 65 por 160 px, pequena demais para ver o que era. Em telas largas a coluna da direita é da peça 3D, e o trabalho
* real começa na seção Projetos.
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
*/
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
export default function HeroWork() {
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return (
<>
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
<HoverPrefetchLink
data-testid="hero-work-mobile"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<BrowserFrame url={url} className="w-[88%]">
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
<Image
src={desktop.poster}
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
width={desktop.width}
height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw"
loading="lazy"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
>
<Image
src={phone.poster}
alt="Site da AS Serviços aberto no celular"
width={phone.width}
height={phone.height}
sizes="(min-width: 1400px) 136px, 10vw"
loading="lazy"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
</div>
<HoverPrefetchLink
data-testid="hero-work-mobile"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
</>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { Logo } from "./Logo";
describe("Logo", () => {
it("shows the text cutout with the same alt text and a decorative inline mark", () => {
const { container } = render(<Logo />);
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-dark.webp");
expect(text).toHaveAttribute("width", "1600");
expect(text).toHaveAttribute("height", "330");
const mark = container.querySelector("svg");
expect(mark).toHaveAttribute("aria-hidden", "true");
expect(mark?.querySelectorAll(".logo-bloco")).toHaveLength(4);
expect(screen.getAllByRole("img")).toHaveLength(1);
});
it("keeps the caller's className and loading", () => {
const { container } = render(
<Logo theme="light" loading="eager" className="w-40 sm:w-44" />,
);
expect(container.firstElementChild).toHaveClass("w-40", "sm:w-44");
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
expect(text.getAttribute("src")).toContain("wordmark-text-light.webp");
expect(text).toHaveAttribute("loading", "eager");
expect(container.querySelector("svg path")).toHaveAttribute(
"fill",
"#00090b",
);
});
});
+46 -9
View File
@@ -1,4 +1,5 @@
import Image from "next/image";
import "./logo.css";
interface LogoProps {
theme?: "dark" | "light";
@@ -6,20 +7,56 @@ interface LogoProps {
loading?: "eager" | "lazy";
}
/*
* O logo em duas partes sobre a caixa do wordmark-<tema>.webp (1600x330): o símbolo em SVG em linha, para os blocos
* lima poderem se mexer (logo.css), e o texto, que é o mesmo .webp com o símbolo apagado (wordmark-text-<tema>.webp,
* do mesmo tamanho, para passar pelo mesmo redimensionamento e dar a mesma altura de antes). As coordenadas do SVG
* são pixels do .webp original, medidas nas bordas das formas; cor e opacidade também vêm dele.
*/
const SHAPE_FILL = { dark: "#f4f3ee", light: "#00090b" } as const;
// em ordem de encaixe, de baixo para cima
const BLOCKS = [
{ x: 130.66, y: 110.25, width: 76.47, height: 86.05 },
{ x: 204.39, y: 43.9, width: 67.72, height: 69.39 },
{ x: 287.27, y: 3.28, width: 39.58, height: 40.82 },
{ x: 312.91, y: 79.72, width: 39.39, height: 39.76 },
];
export function Logo({
theme = "dark",
className = "w-48",
loading = "lazy",
}: Readonly<LogoProps>) {
return (
<Image
src={`/brand/wordmark-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className={`block h-auto max-w-full ${className}`}
/>
<span className={`relative block max-w-full ${className}`}>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 360 330"
preserveAspectRatio="none"
className="absolute top-0 left-0 h-full w-[22.5%] overflow-visible"
>
<path
fill={SHAPE_FILL[theme]}
fillOpacity={0.933}
d="M10.17 223.44H40.23V188.63H74.87V153.98H111.2V222.33H74.93V277.73H139.93V217.4H200.14V291.36H171.59V325.32H10.17Z"
/>
<g fill="#ccfa00" opacity={0.98}>
{BLOCKS.map((block) => (
<rect key={block.x} className="logo-bloco" {...block} />
))}
</g>
</svg>
<Image
src={`/brand/wordmark-text-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className="block h-auto w-full"
/>
</span>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { act, render, waitFor } from "@testing-library/react";
import { OfferVideo } from "./OfferVideo";
/**
* O vídeo da peça 3D toca quando aparece (o IntersectionObserver do jest.setup avisa logo depois de observar) e fica
* parado no pôster com movimento reduzido ou "Economizar dados".
*/
const realMatchMedia = window.matchMedia;
let play: jest.SpyInstance;
let pause: jest.SpyInstance;
beforeEach(() => {
play = jest
.spyOn(HTMLMediaElement.prototype, "play")
.mockResolvedValue(undefined);
pause = jest
.spyOn(HTMLMediaElement.prototype, "pause")
.mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.matchMedia = realMatchMedia;
Reflect.deleteProperty(navigator, "connection");
});
it("path: in view -> plays, muted and in a loop", async () => {
const { container } = render(<OfferVideo name="whatsapp" />);
await waitFor(() => expect(play).toHaveBeenCalled());
const video = container.querySelector("video") as HTMLVideoElement;
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
});
it("path: reduced motion -> never plays, stays on the poster", () => {
jest.useFakeTimers();
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
const { container } = render(<OfferVideo name="sistema" />);
// o aviso de "à vista" do IntersectionObserver chega num setTimeout
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
expect(container.querySelector("video")).toHaveAttribute(
"poster",
"/3d/peca-sistema.webp",
);
});
it("path: save data -> never plays", () => {
jest.useFakeTimers();
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
render(<OfferVideo name="sistema" />);
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef } from "react";
import { useInView } from "@/app/hooks/useInView";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useSaveData } from "./showcase/useShowcaseMedia";
/**
* Peça 3D em loop no cartão da oferta (`public/3d/peca-<nome>.{webm,mp4}`, 640x400, com o pôster `.webp`). É decorativa:
* o nome do cartão e a lista dizem o que é. Nada baixa antes de tocar (`preload="none"`); toca só enquanto está à vista e
* fica no pôster com movimento reduzido ou "Economizar dados".
*/
export function OfferVideo({ name }: Readonly<{ name: string }>) {
const ref = useRef<HTMLVideoElement>(null);
const inView = useInView(ref, { amount: 0.3, once: false });
const reducedMotion = useReducedMotion();
const saveData = useSaveData();
const play = inView && !reducedMotion && !saveData;
useEffect(() => {
const video = ref.current;
if (!video) return;
if (!play) {
video.pause();
return;
}
// o navegador pode recusar a reprodução (economia de energia, aba em segundo plano): o vídeo fica no pôster
video.play()?.catch(() => {});
}, [play]);
const src = `/3d/peca-${name}`;
return (
<video
ref={ref}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
muted
loop
playsInline
preload="none"
poster={`${src}.webp`}
width={640}
height={400}
// contain, não cover: o fundo do vídeo é o mesmo do quadro, e a peça nunca é cortada num quadro mais largo
className="block h-full w-full object-contain"
>
<source src={`${src}.webm`} type="video/webm" />
<source src={`${src}.mp4`} type="video/mp4" />
</video>
);
}
+32
View File
@@ -0,0 +1,32 @@
import Image from "next/image";
import { OfferVideo } from "./OfferVideo";
/**
* Peça de blocos na coluna da direita do topo de uma página interna (a grade é `heroWithMediaGrid`), só de 1024 px em
* diante. Decorativa: fora da leitura. A peça não mede nada: ocupa a altura que o texto ao lado já deu à linha (e um
* pouco da folga de cima e de baixo), então não empurra o texto nem aumenta o topo.
* `image`: imagem de fundo transparente (`/3d/estado-*.webp`, 1200x900). `loop`: o loop `public/3d/peca-<nome>`
* (640x400, fundo #0a0a0a, que some no topo escuro), pelo OfferVideo.
*/
export function PagePiece(
props: Readonly<{ image: string } | { loop: string }>,
) {
return (
<div aria-hidden="true" className="relative hidden self-stretch lg:block">
{/* object-contain: a peça inteira à vista; o loop chega com object-cover do OfferVideo e não pode perder as bordas */}
<div className="absolute inset-x-0 -top-sp-8 -bottom-sp-6 [&_video]:object-contain">
{"image" in props ? (
<Image
src={props.image}
alt=""
fill
sizes="(min-width: 1024px) 40vw, 1px"
className="object-contain"
/>
) : (
<OfferVideo name={props.loop} />
)}
</div>
</div>
);
}
+1 -1
View File
@@ -178,7 +178,7 @@ describe("Section", () => {
"ds-section-description",
"ds-reveal",
);
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "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");
+3 -1
View File
@@ -43,7 +43,9 @@ describe("Section", () => {
</Section>,
);
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
const eyebrow = screen.getByText("Sobre");
expect(eyebrow).toHaveClass("ds-eyebrow", "ds-type");
expect(eyebrow.style.getPropertyValue("--n")).toBe("5");
expect(container.querySelector(".ds-section-description")).toBeNull();
expect(
container.querySelector(".ds-section-header > .ds-reveal"),
+9 -1
View File
@@ -45,7 +45,15 @@ export const Section = ({
>
<div className="ds-section-meta">
{eyebrow && (
<p className="ds-eyebrow ds-reveal" style={stagger(0.02)}>
<p
className="ds-eyebrow ds-type"
style={
{
...stagger(0.02),
"--n": eyebrow.length,
} as CSSProperties
}
>
{eyebrow}
</p>
)}
+199 -69
View File
@@ -5,9 +5,10 @@ import { WorkModalities } from "./WorkModalities";
/**
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
* vez só depois dos cartões), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as quatro ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo"
* uma vez só depois dos cartões), a faixa da nota fiscal acima deles, o estilo único de cartão, o texto de pelo menos
* 15 px e a lista compacta de etapas.
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
*/
@@ -33,6 +34,18 @@ jest.mock("./Section", () => ({
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
// o jsdom não toca vídeo: os cartões do sistema e do atendimento chamam play() ao aparecer
beforeEach(() => {
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
const HOME_OFFERS = serviceOffers.filter(({ id }) => id !== "fiscal");
const original = (image: Element) =>
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
"url",
@@ -43,20 +56,20 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
expect(reveals).toHaveLength(3);
expect(reveals).toHaveLength(4);
expect(
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
).toEqual(["20px", "20px", "20px"]);
).toEqual(["20px", "20px", "20px", "20px"]);
expect(
reveals.map((element) =>
element.style.getPropertyValue("--reveal-duration"),
),
).toEqual(["0.75s", "0.75s", "0.75s"]);
).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]);
expect(
reveals.map((element) =>
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
),
).toEqual([0.08, 0.16, 0.24]);
).toEqual([0.08, 0.16, 0.24, 0.32]);
// o estado escondido vive no CSS: sem opacidade nem transformação inline
for (const element of reveals) {
expect(element.style.opacity).toBe("");
@@ -68,7 +81,7 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(3);
expect(cards).toHaveLength(4);
for (const card of cards) {
expect(card).not.toHaveAttribute("tabindex");
expect(card.className).not.toContain("outline-none");
@@ -87,7 +100,7 @@ describe("WorkModalities: movimento", () => {
});
});
describe("WorkModalities: as três ofertas", () => {
describe("WorkModalities: as quatro ofertas", () => {
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
const { container } = render(<WorkModalities />);
@@ -98,7 +111,7 @@ describe("WorkModalities: as três ofertas", () => {
}),
).toBeInTheDocument();
const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(3);
expect(articles).toHaveLength(4);
const titles = articles.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
@@ -106,10 +119,24 @@ describe("WorkModalities: as três ofertas", () => {
expect(titles).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Revisão de sistema que já existe",
]);
// a fonte é uma só, a mesma de /servicos
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
// a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão
expect(titles).toEqual(HOME_OFFERS.map(({ name }) => name));
expect(
screen.getByText(
"Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado.",
),
).toBeInTheDocument();
});
it("path: two columns from 1024 px up and one below it", () => {
const { container } = render(<WorkModalities />);
const grid = container.querySelector("article")?.closest(".grid");
expect(grid).toHaveClass("grid-cols-1", "lg:grid-cols-2");
expect(grid?.className).not.toMatch(/grid-cols-3/);
});
it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
@@ -124,10 +151,10 @@ describe("WorkModalities: as três ofertas", () => {
).toEqual(serviceOffers[0].deliverables);
});
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row); the other two have an illustration of their own", () => {
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for half the row); the system, the WhatsApp and the review cards loop a video", () => {
const { container } = render(<WorkModalities />);
const [site, system, review] = Array.from(
const [site, system, whatsapp, review] = Array.from(
container.querySelectorAll("article"),
);
const siteImage = within(site).getByRole("img");
@@ -137,55 +164,114 @@ describe("WorkModalities: as três ofertas", () => {
);
expect(siteImage).toHaveAttribute(
"sizes",
"(min-width: 1024px) 33vw, 100vw",
"(min-width: 1024px) 50vw, 100vw",
);
expect(siteImage).toHaveAttribute("loading", "lazy");
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
expect(system.querySelector("img")).toBeNull();
expect(review.querySelector("img")).toBeNull();
for (const card of [system, whatsapp, review]) {
expect(card.querySelector("img")).toBeNull();
}
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
expect(site.querySelector("video")).toBeNull();
expect(review.querySelector("video")).not.toBeNull();
});
it("path: the system and the review cards have no black box with an icon: each has an illustration in the same box as the site photo", () => {
it("path: the system, the WhatsApp and the review videos are decorative 3D pieces: muted loop, no preload, the poster first, webm then mp4", () => {
render(<WorkModalities />);
for (const id of ["sistema", "whatsapp", "revisao"]) {
const art = screen.getByTestId(`offer-art-${id}`);
const video = art.querySelector("video") as HTMLVideoElement;
expect(video).not.toBeNull();
expect(video).toHaveAttribute("aria-hidden", "true");
expect(video).toHaveAttribute("tabindex", "-1");
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
expect(video).toHaveAttribute("playsinline");
expect(video).toHaveAttribute("preload", "none");
expect(video).toHaveAttribute("poster", `/3d/peca-${id}.webp`);
expect(
Array.from(video.querySelectorAll("source")).map((source) => [
source.getAttribute("src"),
source.getAttribute("type"),
]),
).toEqual([
[`/3d/peca-${id}.webm`, "video/webm"],
[`/3d/peca-${id}.mp4`, "video/mp4"],
]);
}
// a planilha em SVG saiu do cartão do sistema
expect(
screen.getByTestId("offer-art-sistema").querySelector("svg"),
).toBeNull();
});
it("path: the WhatsApp video carries a decorative app badge in the top left corner: the WhatsApp mark in its green and the word, light on dark", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-whatsapp");
expect(art.querySelector("video")?.parentElement).toHaveClass("relative");
const badge = within(art).getByText("WhatsApp");
expect(badge).toHaveAttribute("aria-hidden", "true");
expect(badge).toHaveClass("absolute", "top-2", "left-2", "text-white");
expect(badge.className).toMatch(/bg-black\/\d+/);
expect(badge.className).not.toMatch(/rounded/);
const mark = badge.querySelector("svg");
expect(mark).not.toBeNull();
expect(mark?.getAttribute("class")).toContain("text-[var(--whatsapp)]");
// só o vídeo do atendimento leva o selo
for (const id of ["sistema", "revisao"]) {
expect(
within(screen.getByTestId(`offer-art-${id}`)).queryByText("WhatsApp"),
).toBeNull();
}
});
it("path: the report excerpt lies over the right edge of the review video, vertically centered, in a wide box, and below the frame in a narrow one, 20% larger (18 px)", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-revisao");
expect(art).toHaveClass("@container", "relative");
const excerpt = within(art).getByRole("img");
// depois do quadro do vídeo, nunca dentro dele: na caixa estreita fica embaixo, sem cobrir a janela
expect(
excerpt.previousElementSibling?.querySelector("video"),
).not.toBeNull();
expect(excerpt).not.toHaveClass("absolute");
expect(excerpt).toHaveClass(
"@[29rem]:absolute",
"@[29rem]:inset-y-0",
"@[29rem]:right-0",
"@[29rem]:items-end",
"@[29rem]:justify-center",
);
for (const item of excerpt.querySelectorAll("li")) {
expect(item).toHaveClass("text-[18px]");
}
expect(within(excerpt).getByText("Exemplo ilustrativo")).toHaveClass(
"text-[18px]",
);
});
it("path: no black box with an icon: the photo and the videos sit in the same frame, in the same box", () => {
const { container } = render(<WorkModalities />);
expect(screen.queryByTestId("offer-icon")).toBeNull();
const boxes = ["site", "sistema", "revisao"].map((id) =>
const boxes = HOME_OFFERS.map(({ id }) =>
screen.getByTestId(`offer-art-${id}`),
);
// as três caixas têm as mesmas classes, então o mesmo tamanho
// as quatro caixas e os quatro quadros têm as mesmas classes, então o mesmo tamanho
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
for (const box of boxes) {
expect(box.className).toContain("aspect-[16/10]");
expect(box.className).toContain("md:aspect-[21/9]");
expect(box.className).toContain("lg:aspect-[16/10]");
expect(box.firstElementChild).not.toBeNull();
const frames = boxes.map((box) => box.firstElementChild as HTMLElement);
expect(new Set(frames.map((frame) => frame.className)).size).toBe(1);
for (const frame of frames) {
expect(frame.className).toContain("aspect-[16/10]");
expect(frame.className).toContain("md:aspect-[21/9]");
expect(frame.className).toContain("lg:aspect-[16/10]");
expect(frame.querySelector("img, video")).not.toBeNull();
}
expect(container.querySelectorAll("article")).toHaveLength(3);
expect(container.querySelectorAll("article")).toHaveLength(4);
});
it("path: the system illustration is a sheet -> arrow -> dashboard scheme in SVG, with three indicators, no text and no numbers", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-sistema");
const scheme = within(art).getByRole("img");
expect(scheme.tagName.toLowerCase()).toBe("svg");
expect(scheme).toHaveAccessibleName(
"Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras",
);
expect(scheme.getAttribute("viewBox")).toBe("0 0 240 150");
expect(scheme.querySelector('[data-art="planilha"]')).not.toBeNull();
expect(scheme.querySelector('[data-art="seta"]')).not.toBeNull();
const panel = scheme.querySelector('[data-art="painel"]') as Element;
expect(panel).not.toBeNull();
// três indicadores genéricos: um quadro com um traço de rótulo e um bloco de valor cada, sem dígito algum
expect(panel.querySelectorAll("g").length).toBe(3);
expect(scheme.querySelector("text")).toBeNull();
expect(art.textContent).toBe("");
// nada de cor de destaque: o limão é só do contato
expect(art.innerHTML).not.toMatch(/ccff00|var\(--slash\)/i);
});
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
render(<WorkModalities />);
@@ -206,26 +292,21 @@ describe("WorkModalities: as três ofertas", () => {
for (const severity of ["Alta", "Média"]) {
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
}
// o texto da ilustração também tem 15 px, como o resto da seção
// o texto do recorte tem 18 px embaixo do vídeo e 15 px ao lado da janela; nunca abaixo dos 15 px do resto da seção
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
});
it("path: the deliverables of the system and of the review come from serviceOffers, the same lists of /servicos", () => {
it("path: the deliverables of the system, the WhatsApp and the review come from serviceOffers, the same lists of /servicos", () => {
const { container } = render(<WorkModalities />);
const [, system, review] = Array.from(
container.querySelectorAll("article"),
);
expect(
within(system)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[1].deliverables);
expect(
within(review)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[2].deliverables);
const articles = Array.from(container.querySelectorAll("article"));
HOME_OFFERS.forEach((offer, index) => {
expect(
within(articles[index])
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(offer.deliverables);
});
});
it("path: every card says what you receive, with three or four items, once", () => {
@@ -241,7 +322,7 @@ describe("WorkModalities: as três ofertas", () => {
}
});
it("path: the first step shows once, below the three cards: a first conversation and, after it, the scope and the proposal in writing", () => {
it("path: the first step shows once, below the cards: a first conversation and, after it, the scope and the proposal in writing", () => {
const { container } = render(<WorkModalities />);
for (const card of container.querySelectorAll("article")) {
@@ -262,13 +343,14 @@ describe("WorkModalities: as três ofertas", () => {
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
});
it("path: each button opens the contact form card with the project type already chosen, all three with the same filled style", () => {
it("path: each button opens the contact form card with the project type already chosen, all four with the same filled style", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
const buttons = [
["Começar pelo site", "/?tipo=site#contact-form"],
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
["Começar pelo atendimento", "/?tipo=whatsapp#contact-form"],
["Começar pela revisão", "/?tipo=revisao#contact-form"],
] as const;
buttons.forEach(([name, href], index) => {
@@ -292,9 +374,19 @@ describe("WorkModalities: as três ofertas", () => {
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
within(articles[2]).getByRole("link", {
name: "Ver como funciona o atendimento",
}),
).toHaveAttribute("href", "/servicos/atendimento-whatsapp-ia");
expect(
within(articles[3]).getByRole("link", {
name: "Ver como funciona a revisão",
}),
).toHaveAttribute("href", "/ia");
expect(
within(articles[2]).getByText(
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
),
).toBeInTheDocument();
});
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
@@ -306,19 +398,19 @@ describe("WorkModalities: as três ofertas", () => {
name: "Ver todos os serviços",
});
expect(services).toHaveAttribute("href", "/servicos");
expect(container.querySelectorAll("article")).toHaveLength(3);
expect(container.querySelectorAll("article")).toHaveLength(4);
// o link fica fora dos cartões, depois da grade
expect(services.closest("article")).toBeNull();
expect(
screen
.getAllByRole("link")
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
.filter((link) => link.getAttribute("href") === "/servicos"),
).toEqual([services]);
});
});
describe("WorkModalities: um só estilo de cartão", () => {
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
it("path: the four cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
@@ -393,3 +485,41 @@ describe("WorkModalities: sem prometer o que o dono não disse", () => {
);
});
});
describe("WorkModalities: a faixa da nota fiscal", () => {
afterEach(() => {
jest.useRealTimers();
});
it("path: up to 2027-03-31 a quiet line above the cards (no card, no colored box) says the Simples Nacional date and links to the page", () => {
jest.useFakeTimers({ now: new Date("2027-03-31T23:59:00-03:00") });
const { container } = render(<WorkModalities />);
const text = screen.getByText(
/Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027\./,
);
expect(text.textContent).toBe(
"Seu sistema emite nota fiscal? Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027.",
);
expect(
screen.getByRole("link", { name: "Ver o que muda" }),
).toHaveAttribute("href", "/servicos/nota-fiscal-reforma-tributaria");
const notice = text.parentElement as HTMLElement;
expect(notice.closest("article")).toBeNull();
expect(
notice.compareDocumentPosition(
container.querySelector("article") as Element,
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(notice.className).not.toMatch(/bg-|border-l-|ds-card|shadow/);
});
it("path: from 2027-04-01 on, no notice, and the four cards stay", () => {
jest.useFakeTimers({ now: new Date("2027-04-01T00:00:00-03:00") });
const { container } = render(<WorkModalities />);
expect(screen.queryByText(/Seu sistema emite nota fiscal\?/)).toBeNull();
expect(screen.queryByRole("link", { name: "Ver o que muda" })).toBeNull();
expect(container.querySelectorAll("article")).toHaveLength(4);
});
});
+86 -153
View File
@@ -7,28 +7,35 @@ import {
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { OfferVideo } from "./OfferVideo";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
import { WhatsAppMark } from "./WhatsAppMark";
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
interface OfferExtras {
description: string;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz um vídeo ou uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string };
moreLabel: string;
moreHref: string;
}
/** Ofertas com cartão na home; a da nota fiscal entra na faixa datada acima dos cartões (FiscalNotice). */
type HomeOfferId = Exclude<ServiceOffer["id"], "fiscal">;
/**
* Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
* página de /ia citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de catálogo e
* tabela de preços, o que o texto descreve. O sistema e a revisão não têm um case que mostre o que descrevem (a vitrine
* da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
* Os textos da home para as quatro ofertas, na ordem de `serviceOffers`. Cada texto só diz o que o case, a página de /ia
* ou a página da oferta citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de
* catálogo e tabela de preços, o que o texto descreve. O sistema, o atendimento no WhatsApp e a revisão trazem uma peça
* 3D em vídeo (OfferVideo); por cima, o atendimento tem o selo do aplicativo e a revisão, um recorte do relatório.
*/
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
site: {
description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
@@ -44,6 +51,13 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
whatsapp: {
description:
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
ctaLabel: "Começar pelo atendimento",
moreLabel: "Ver como funciona o atendimento",
moreHref: "/servicos/atendimento-whatsapp-ia",
},
revisao: {
description:
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
@@ -53,6 +67,10 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
},
};
const homeOffers = serviceOffers.flatMap((offer) =>
offer.id === "fiscal" ? [] : [{ offer, extras: OFFER_EXTRAS[offer.id] }],
);
const PROCESS = [
{
title: "Entender a operação",
@@ -76,127 +94,6 @@ const PROCESS = [
},
];
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
const SHEET_COLUMNS = [0, 1, 2];
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
const SHEET_DASH_WIDTHS = [18, 12, 15];
/** Alturas das cinco barras do painel do esquema. */
const PANEL_BARS = [14, 26, 20, 34, 28];
/**
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
*/
function SystemArt() {
return (
<svg
role="img"
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
viewBox="0 0 240 150"
className="block h-full w-full text-white"
fill="none"
stroke="currentColor"
>
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
<rect x="14" y="30" width="80" height="92" />
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
</g>
<rect
x="15"
y="31"
width="78"
height="14"
fill="currentColor"
fillOpacity="0.16"
stroke="none"
/>
<g fill="currentColor" fillOpacity="0.5" stroke="none">
{SHEET_DATA_ROWS.flatMap((row) =>
SHEET_COLUMNS.map((column) => (
<rect
key={`${row}-${column}`}
x={19 + column * 26.6}
y={52 + row * 14.6}
width={
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
}
height="4"
rx="1"
/>
)),
)}
</g>
<path
data-art="seta"
d="M104 76h24M119 66l11 10-11 10"
strokeOpacity="0.85"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g data-art="painel">
<rect
x="140"
y="22"
width="92"
height="108"
rx="3"
fill="currentColor"
fillOpacity="0.06"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{[147, 175, 203].map((x) => (
<g key={x}>
<rect
x={x}
y="30"
width="25"
height="32"
strokeOpacity="0.35"
strokeWidth="1"
/>
<rect
x={x + 4}
y="35"
width="12"
height="3"
fill="currentColor"
fillOpacity="0.5"
stroke="none"
/>
<rect
x={x + 4}
y="43"
width="16"
height="9"
fill="currentColor"
fillOpacity="0.92"
stroke="none"
/>
</g>
))}
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
{PANEL_BARS.map((height, index) => (
<rect
key={height}
x={151 + index * 15}
y={118 - height}
width="10"
height={height}
fill="currentColor"
fillOpacity="0.6"
stroke="none"
/>
))}
</g>
</svg>
);
}
/**
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
@@ -209,28 +106,35 @@ const REPORT_EXCERPT = [
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
const SEVERITY_BADGE =
"inline-flex min-w-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
"inline-flex min-w-14 shrink-0 items-center justify-center border border-[var(--ink)] px-2 font-display text-[18px] @[29rem]:text-[15px] leading-6";
/**
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
* numa linha só no cartão mais estreito (três colunas em 1024 px).
* Recorte do "Modelo do relatório" de /ia junto do vídeo da revisão: três linhas de achado, cada uma com o selo da
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
* quadro, então numa caixa larga (528 px ou mais) o recorte fica por cima da metade direita, centrado na vertical. Numa
* caixa mais estreita (celular, e as duas colunas logo acima de 1024 px) ele não cabe ao lado da janela sem cobri-la:
* desce para baixo do quadro, no mesmo fundo escuro. A largura vem da caixa (container query), não da tela.
*/
/*
* Onde o trecho do relatório fica: ao lado da janela quando o quadro tem 29rem ou mais (a janela do vídeo acaba em 51%
* da largura); embaixo do vídeo quando o cartão ocupa a linha inteira e é estreito (celular); fora da tela entre 1024
* e 1279 px, em que os cartões já estão lado a lado mas o quadro é estreito demais e empilhar desalinharia os títulos.
*/
function ReviewArt() {
return (
<div
role="img"
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
className="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
className="pointer-events-none flex flex-col items-center gap-sp-2 px-sp-3 pt-sp-1 pb-sp-4 lg:max-xl:hidden @[29rem]:absolute @[29rem]:inset-y-0 @[29rem]:right-0 @[29rem]:items-end @[29rem]:justify-center @[29rem]:p-sp-4"
>
<ul
aria-hidden="true"
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
className="w-max max-w-full divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-3 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
className="flex items-center justify-between gap-sp-4 py-1 font-display text-[18px] @[29rem]:text-[15px] leading-7"
>
<span className="min-w-0">{finding}</span>
<span
@@ -243,7 +147,7 @@ function ReviewArt() {
</ul>
<p
aria-hidden="true"
className="font-display text-[15px] leading-5 text-white/75"
className="font-display text-[18px] @[29rem]:text-[15px] leading-6 text-white/80"
>
Exemplo ilustrativo
</p>
@@ -251,7 +155,11 @@ function ReviewArt() {
);
}
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
/** Classes do quadro da mídia, iguais nos quatro cartões: a foto ou o vídeo ocupam o quadro inteiro. */
const MEDIA_FRAME =
"relative aspect-[16/10] md:aspect-[21/9] lg:aspect-[16/10]";
/** Foto do case, quando há um; senão o vídeo da oferta, com o que vai junto dele. O quadro tem o mesmo tamanho em todos. */
function OfferArt({
offer,
extras,
@@ -262,21 +170,40 @@ function OfferArt({
if (poster && extras.poster) {
return (
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
<div className={MEDIA_FRAME}>
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 50vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
</div>
);
}
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
return (
<>
<div className={MEDIA_FRAME}>
<OfferVideo name={offer.id} />
{offer.id === "whatsapp" ? (
// de que aplicativo se trata; decorativo como o vídeo, porque o título do cartão já diz WhatsApp
<span
aria-hidden="true"
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-sp-2 bg-black/70 px-sp-2 py-1 font-display text-[15px] leading-5 text-white @[33rem]:top-3 @[33rem]:left-3"
>
<WhatsAppMark className="h-4 w-4 text-[var(--whatsapp)]" />
WhatsApp
</span>
) : null}
</div>
{offer.id === "revisao" ? <ReviewArt /> : null}
</>
);
}
/** Um único estilo de cartão para as três ofertas: foto ou ilustração, título com altura mínima, texto, o que você recebe, botão e link do case. */
/** Um único estilo de cartão para as quatro ofertas: foto ou vídeo, título com altura mínima, texto, o que você recebe, botão e link. */
function OfferCard({
offer,
extras,
@@ -285,7 +212,7 @@ function OfferCard({
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div
data-testid={`offer-art-${offer.id}`}
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
className="@container relative overflow-hidden border border-[var(--border)] bg-[var(--ink)]"
>
<OfferArt offer={offer} extras={extras} />
</div>
@@ -346,16 +273,22 @@ function OfferCard({
}
export function WorkModalities() {
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
return (
<Section
id="modalities"
eyebrow="Soluções"
title="Escolha por onde começar"
description="Três pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
description="Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
disableChildrenAnimation
>
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{serviceOffers.map((offer, index) => (
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
{fiscal && isFiscalNoticeActive(new Date()) ? (
<FiscalNotice href={fiscal.href} />
) : null}
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-2 lg:gap-sp-6">
{homeOffers.map(({ offer, extras }, index) => (
<Reveal
key={offer.id}
y={20}
@@ -363,12 +296,12 @@ export function WorkModalities() {
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
<OfferCard offer={offer} extras={extras} />
</Reveal>
))}
</div>
{/* Uma vez só, depois dos três cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
{/* Uma vez só, depois dos cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
+8 -7
View File
@@ -1,26 +1,27 @@
/**
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
* A resposta ao ponteiro e ao foco é uma por papel (cv-fill, cv-outline-*, cv-bar, em globals.css).
*/
const base =
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline transition-colors motion-reduce:transition-none sm:text-[16px]";
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline sm:text-[16px]";
export const primaryButton = `${base} bg-[var(--slash)] font-semibold text-[var(--ink)] hover:bg-[#d9ff40]`;
export const primaryButton = `${base} cv-fill bg-[var(--slash)] font-semibold text-[var(--ink)]`;
/** Contorno sobre fundo escuro. */
export const secondaryButtonDark = `${base} border border-white/25 bg-transparent font-medium text-[#f4f2ed] hover:border-white/55`;
export const secondaryButtonDark = `${base} cv-outline-dark border border-white/25 bg-transparent font-medium text-[#f4f2ed]`;
/** Contorno sobre fundo claro. */
export const secondaryButtonLight = `${base} border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)] hover:border-[var(--ink)]`;
export const secondaryButtonLight = `${base} cv-outline-light border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)]`;
const textLinkBase =
"inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 transition-colors motion-reduce:transition-none sm:text-[16px]";
"cv-bar inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 sm:text-[16px]";
/** Link de texto sobre fundo claro. */
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
export const textLink = textLinkBase;
/** Link de texto sobre fundo escuro. */
export const textLinkDark = `${textLinkBase} text-white/80 hover:text-white`;
export const textLinkDark = `${textLinkBase} text-white/80`;
/**
* Primeira seção de uma página interna, no celular: o padding de .ds-section (52 px) é menor que o cabeçalho fixo (60 px),
@@ -0,0 +1,614 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { act, render } from "@testing-library/react";
import type {
InstancedBufferGeometry,
Mesh,
ShaderMaterial,
Texture,
} from "three";
import { stateAt } from "@/lib/blocos";
import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device";
import BlocosScene from "./BlocosScene";
/*
* A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha
* sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o
* JSON dos estados) vêm de dublês também; o JSON é o arquivo real.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
this.options = options;
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const three = jest.requireActual("three");
return {
...three,
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
// o jsdom não carrega imagens: o matcap é uma textura vazia
TextureLoader: jest.fn(() => ({
loadAsync: () => Promise.resolve(new three.Texture()),
})),
};
});
jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => {
const { BoxGeometry, Group, Mesh } = jest.requireActual("three");
return {
GLTFLoader: jest.fn(() => ({
loadAsync: () => {
const scene = new Group();
scene.add(new Mesh(new BoxGeometry(1, 1, 1)));
return Promise.resolve({ scene });
},
})),
};
});
const blocosJson = JSON.parse(
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
);
let mockFetchOk = true;
const originalFetch = global.fetch;
const props = () => ({
progressRef: { current: 0 },
visible: true,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockRendererName = null;
mockFetchOk = true;
global.fetch = jest.fn(() =>
Promise.resolve({
ok: mockFetchOk,
status: mockFetchOk ? 200 : 404,
json: () => Promise.resolve(blocosJson),
}),
) as unknown as typeof fetch;
setDevicePixelRatio(1);
sessionStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.fetch = originalFetch;
sessionStorage.clear();
});
/** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
/** A malha que o último quadro desenhou (a cena tem uma só). */
function drawnMesh() {
const calls = mockRenderers[0].render.mock.calls as unknown as [
{ children: Mesh[] },
][];
return calls.at(-1)?.[0].children[0] as Mesh<
InstancedBufferGeometry,
ShaderMaterial
>;
}
const uniform = (name: string) =>
drawnMesh().material.uniforms[name].value as number;
// precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo
describe("BlocosScene when the assets do not download", () => {
it("tells the page once, creates no context, and lets the next mount try again", async () => {
mockFetchOk = false;
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers).toHaveLength(0);
expect(container.querySelector("canvas")).toBeNull();
unmount();
mockFetchOk = true;
const again = props();
await mount(<BlocosScene {...again} />);
expect(again.onError).not.toHaveBeenCalled();
expect(mockRenderers).toHaveLength(1);
});
});
describe("BlocosScene", () => {
it("creates a transparent, antialiased renderer on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<BlocosScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toEqual({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
});
it.each([
["full", 3, 1.75],
["full", 1.5, 1.5],
["lite", 3, 1.5],
["lite", 0.5, 1],
] as const)(
"caps the pixel ratio on %s (device %p -> %p)",
async (tier, device, expected) => {
setDevicePixelRatio(device);
await mount(<BlocosScene {...props()} tier={tier} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws every block in one instanced call that starts on marca", async () => {
await mount(<BlocosScene {...props()} />);
advance(20);
const mesh = drawnMesh();
expect(mesh.geometry.type).toBe("InstancedBufferGeometry");
expect(mesh.geometry.instanceCount).toBe(238);
expect(uniform("uFrom")).toBe(0);
expect(uniform("uTo")).toBe(0);
});
it("tells the page it is ready on the second frame drawn", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(2);
expect(scene.onReady).toHaveBeenCalledTimes(1);
advance(3000);
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("draws nothing while not visible", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<BlocosScene {...scene} visible={false} />,
);
advance(3000);
expect(frames).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
rerender(<BlocosScene {...scene} visible />);
advance(100);
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<BlocosScene {...props()} />);
advance(1500); // entrada de 1,4 s, no ritmo da tela
const entry = frames.mock.calls.length;
expect(entry).toBeGreaterThan(80);
expect(uniform("uIntro")).toBe(1);
advance(1000);
const pulse = frames.mock.calls.length - entry;
expect(pulse).toBeGreaterThan(20);
expect(pulse).toBeLessThanOrEqual(34);
});
it("follows the dive progress on full", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1500);
scene.progressRef.current = 0.63;
advance(4000);
expect(uniform("uFrom")).toBe(2);
expect(uniform("uTo")).toBe(2);
expect(uniform("uT")).toBe(0);
});
it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => {
const scene = props();
scene.progressRef.current = 0.93; // ignorado no lite
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 3500); // entrada e 3,5 s parado na marca
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]);
advance(1100); // no meio da troca de 1,2 s
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
advance(1000);
expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([
1, 1, 0,
]);
});
/** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */
const hostOf800x600 = () => {
jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600);
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockReturnValue(new DOMRect(0, 0, 800, 600));
};
const fire = (event: Event) =>
act(() => {
window.dispatchEvent(event);
});
it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1000);
expect(uniform("uVida")).toBe(0); // a entrada ainda assenta
advance(1500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
expect(uniform("uEstado")).toBe(0);
const tempo = uniform("uTempo");
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
// no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.23;
advance(1000);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uVida")).toBeLessThan(0.05);
fire(new Event("scroll"));
scene.progressRef.current = 0.35;
advance(3000);
expect(uniform("uEstado")).toBe(1);
expect(uniform("uVida")).toBeGreaterThan(0.9);
});
it("freezes the resting life with the pulse once nobody moves", async () => {
const scene = props();
scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso
await mount(<BlocosScene {...scene} />);
advance(6000); // a batida para 5 s depois do último gesto
const drawn = mockRenderers[0].render.mock.calls.length;
const tempo = uniform("uTempo");
advance(3000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
expect(uniform("uTempo")).toBe(tempo);
fire(new Event("scroll"));
advance(500);
expect(uniform("uTempo")).toBeGreaterThan(tempo);
});
/** O estado que o último quadro desenhou: de, para e t. */
const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")];
it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 2500); // entrada e 2,5 s parado na marca
expect(shown()).toEqual([0, 0, 0]);
advance(1100); // na troca de 1,2 s para a tela
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
// durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
advance(1500);
expect(shown()).toEqual([1, 1, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela
// a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
advance(4200 * 2 + 1000); // integrações, dados
expect(shown()).toEqual([3, 3, 0]);
advance(4200);
expect(shown()).toEqual([0, 0, 0]);
// cada legenda entra com a peça assentada e sai quando a troca seguinte começa
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
null,
2,
null,
3,
null,
0,
]);
});
it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo
fire(new Event("scroll"));
scene.progressRef.current = 0.02;
advance(50); // a próxima batida
// a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava
expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uT")).toBeLessThan(0.1);
expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda
advance(1000);
expect(shown()).toEqual([0, 0, 0]);
// de volta ao topo: 3 s parada na marca antes da troca seguinte
fire(new Event("scroll"));
scene.progressRef.current = 0;
advance(3000);
expect(shown()).toEqual([0, 0, 0]);
advance(1500);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
});
it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(scene.onEstado).toHaveBeenLastCalledWith(1);
fire(new Event("scroll"));
scene.progressRef.current = 0.63;
advance(4000);
expect(shown()).toEqual([2, 2, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
});
it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1400 + 4200 + 4200 + 1000);
expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo
// a rolagem para no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.26;
advance(3000);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6);
});
it("lite: tells the legend every state it builds", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 4000 + 1200 + 500);
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
]);
});
it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} />);
advance(2500);
expect(uniform("uCursorPeso")).toBe(0);
// o centro do hospedeiro cai no centro da peça
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(1000);
expect(uniform("uCursorPeso")).toBeGreaterThan(0.9);
const cursor = drawnMesh().material.uniforms.uCursor.value as {
x: number;
y: number;
};
expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5);
// perto do canto de cima à esquerda, longe da peça, o ponto vai junto
fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 }));
advance(1000);
expect(cursor.x).toBeLessThan(-3);
expect(cursor.y).toBeGreaterThan(2);
fire(new PointerEvent("pointerout", { relatedTarget: null }));
advance(1500);
expect(uniform("uCursorPeso")).toBeLessThan(0.01);
});
it("gives lite the resting life on the pulse, without the cursor", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} tier="lite" />);
advance(2500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
const tempo = uniform("uTempo");
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
expect(uniform("uCursorPeso")).toBe(0);
});
it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => {
const removed = jest.spyOn(window, "removeEventListener");
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
advance(20);
const mesh = drawnMesh();
const disposed = [
jest.spyOn(mesh.geometry, "dispose"),
jest.spyOn(mesh.material, "dispose"),
jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"),
jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"),
];
const canvasRemoved = jest.spyOn(
mockRenderers[0].domElement,
"removeEventListener",
);
unmount();
for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
expect(jest.getTimerCount()).toBe(0);
expect(removed.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining(["resize", "pointermove", "pointerout"]),
);
expect(canvasRemoved).toHaveBeenCalledWith(
"webglcontextlost",
expect.any(Function),
);
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("tells the page once when the WebGL context is lost", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
it("gives up once and remembers it when the entry frames average more than 28 ms", async () => {
// quadros de 40 ms: um aparelho que não segura a cena
jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(
(callback) =>
setTimeout(
() => callback(performance.now()),
40,
) as unknown as number,
);
jest
.spyOn(window, "cancelAnimationFrame")
.mockImplementation((id) => clearTimeout(id));
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(isKnownSlowScene()).toBe(true);
const drawn = mockRenderers[0].render.mock.calls.length;
expect(drawn).toBeLessThanOrEqual(62);
advance(2000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("keeps a device that holds 60 fps, without remembering anything", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSlowScene()).toBe(false);
});
});
describe("BlocosScene: software WebGL", () => {
it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => {
mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
});
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
+919
View File
@@ -0,0 +1,919 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import {
type BufferGeometry,
Euler,
InstancedBufferAttribute,
InstancedBufferGeometry,
MathUtils,
Matrix3,
Matrix4,
Mesh,
PerspectiveCamera,
Plane,
Ray,
Scene,
ShaderMaterial,
SRGBColorSpace,
type Texture,
TextureLoader,
Vector2,
Vector3,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
type Blocos,
caixaDosEstados,
cicloAt,
createFrameBudget,
type Estado,
type FrameVerdict,
framePiece,
papeis,
parseBlocos,
type Rect,
seguir,
stateAt,
} from "@/lib/blocos";
import {
isSoftwareWebGL,
rememberSlowScene,
rememberSoftwareWebGL,
} from "@/lib/device";
import { startFramePulse } from "./FramePulse";
export type BlocosSceneProps = {
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
progressRef: RefObject<number>;
visible: boolean;
onReady: () => void;
onError: () => void;
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
tier?: "full" | "lite";
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
placementRef?: RefObject<Rect | null>;
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
stageRef?: RefObject<Rect | null>;
/**
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
*/
onEstado?: (estado: number | null) => void;
};
/*
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
*/
type Assets = {
bloco: BufferGeometry;
blocos: Blocos;
porcelana: Texture;
lima: Texture;
};
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
const ENTRADA = 1.4;
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
const FICA = 4;
const TROCA = 1.2;
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
const FICA_TOPO = 3;
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
const TOPO = 0.01;
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
const SEGUE = 2;
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
const FRENTE = { yaw: 0.24, pitch: 0.12 };
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
const DISTANCIA = 40;
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
const VERTEX = /* glsl */ `
#define ATRASO 0.35
attribute vec4 aPos0;
attribute vec4 aPos1;
attribute vec4 aPos2;
attribute vec4 aPos3;
attribute vec3 aEsc0;
attribute vec3 aEsc1;
attribute vec3 aEsc2;
attribute vec3 aEsc3;
attribute vec3 aEntrada;
attribute float aSemente;
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
attribute vec4 aPapel;
uniform float uFrom;
uniform float uTo;
uniform float uT;
uniform float uIntro;
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
uniform float uVida;
uniform float uEstado;
uniform float uTempo;
uniform vec3 uBotao;
uniform float uChao;
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
uniform vec2 uCursor;
uniform float uCursorPeso;
varying vec3 vNormal;
varying float vTom;
// posição (xyz) e tom (w) do bloco no estado i
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
float encaixe(float t) {
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
return 1.0 - pow(1.0 - k, 3.0);
}
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
float pulo(float u) {
if (u <= 0.0 || u >= 1.0) return 0.0;
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
}
// a vida em repouso do estado: muda posição, escala e tom do bloco
void vida(inout vec3 p, inout vec3 s, inout float tom) {
if (uEstado < 0.5) {
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
} else if (uEstado < 1.5) {
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
p = uBotao + (p - uBotao) * k;
s *= k;
} else if (uEstado < 2.5) {
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
} else if (aPapel.w >= 0.0) {
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
p.z = uChao + (p.z - uChao) * k;
s.z *= k;
}
}
void main() {
vec4 a = lugar(uFrom);
vec4 b = lugar(uTo);
vec3 ea = escala(uFrom);
vec3 eb = escala(uTo);
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
float e = encaixe(uT);
vec3 p = mix(pa, pb, e);
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
vec3 s = mix(ea, eb, e);
vTom = mix(a.w, b.w, e);
if (uVida > 0.0) vida(p, s, vTom);
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
float ei = encaixe(uIntro);
p += aEntrada * (1.0 - ei);
s *= mix(0.8, 1.0, ei);
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
// escala não uniforme: a normal vai pela inversa da escala
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
gl_Position = projectionMatrix * mv;
}
`;
const FRAGMENT = /* glsl */ `
uniform sampler2D uPorcelana;
uniform sampler2D uLima;
uniform mat3 uLuz;
varying vec3 vNormal;
varying float vTom;
void main() {
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
vec3 n = uLuz * normalize(vNormal);
vec2 uv = n.xy * 0.495 + 0.5;
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
#include <colorspace_fragment>
}
`;
let assets: Promise<Assets> | null = null;
async function loadAssets(): Promise<Assets> {
const textures = new TextureLoader();
const [gltf, blocos, porcelana, lima] = await Promise.all([
new GLTFLoader().loadAsync("/3d/bloco.glb"),
fetch("/3d/blocos.json").then((response) => {
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
return response.json().then(parseBlocos);
}),
textures.loadAsync("/3d/matcap-porcelana.webp"),
textures.loadAsync("/3d/matcap-lima.webp"),
]);
let bloco: BufferGeometry | undefined;
gltf.scene.traverse((object) => {
if (!bloco && object instanceof Mesh) bloco = object.geometry;
});
if (!bloco) throw new Error("bloco.glb sem malha");
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
porcelana.colorSpace = SRGBColorSpace;
lima.colorSpace = SRGBColorSpace;
return { bloco, blocos, porcelana, lima };
}
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
function loadBlocos() {
assets ??= loadAssets().catch((error) => {
assets = null;
throw error;
});
return assets;
}
/**
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
* Nunca rejeita: uma falha aparece ao montar (`onError`).
*/
export function prepareBlocos(): Promise<void> {
return loadBlocos().then(
() => undefined,
() => undefined,
);
}
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
function sorteio(bloco: number, canal: number) {
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
const { n, estados } = blocos;
const geometry = new InstancedBufferGeometry();
geometry.index = bloco.index;
geometry.setAttribute("position", bloco.getAttribute("position"));
geometry.setAttribute("normal", bloco.getAttribute("normal"));
geometry.instanceCount = n;
estados.forEach((dados, e) => {
const lugar = new Float32Array(n * 4);
const escala = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
for (let k = 0; k < 3; k++) {
lugar[i * 4 + k] = dados[i * 7 + k];
escala[i * 3 + k] = dados[i * 7 + 3 + k];
}
lugar[i * 4 + 3] = dados[i * 7 + 6];
}
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
});
const semente = new Float32Array(n);
const entrada = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
semente[i] = sorteio(i, 0);
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
const x = sorteio(i, 1) - 0.5;
const y = sorteio(i, 2) - 0.5;
const z = sorteio(i, 3);
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
entrada[i * 3] = x * k;
entrada[i * 3 + 1] = y * k;
entrada[i * 3 + 2] = z * k;
}
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
const { vida, botao, chao } = papeis(blocos);
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
// a visão desfeita da pose da peça (ver o fragment shader)
const luz = new Matrix3();
const material = new ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms: {
uFrom: { value: 0 },
uTo: { value: 0 },
uT: { value: 0 },
uIntro: { value: 0 },
uVida: { value: 0 },
uEstado: { value: 0 },
uTempo: { value: 0 },
uBotao: { value: new Vector3(...botao) },
uChao: { value: chao },
uCursor: { value: new Vector2() },
uCursorPeso: { value: 0 },
uPorcelana: { value: porcelana },
uLima: { value: lima },
uLuz: { value: luz },
},
});
const mesh = new Mesh(geometry, material);
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
mesh.frustumCulled = false;
const scene = new Scene();
scene.add(mesh);
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
camera.position.set(0, 0, DISTANCIA);
// o raio do ponteiro sai dela antes do primeiro desenho
camera.updateMatrixWorld();
return {
scene,
camera,
mesh,
geometry,
material,
porcelana,
lima,
luz,
pose: new Euler(),
rotation: new Matrix4(),
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
inversa: new Matrix4(),
raio: new Ray(),
mira: new Vector3(),
};
}
type World = ReturnType<typeof buildWorld>;
type Tier = NonNullable<BlocosSceneProps["tier"]>;
type SceneState = {
/** Progresso amortecido (`full`). */
progress: number;
/** Entrada, de 0 a 1. */
intro: number;
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
clock: number;
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
quieto: number;
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
ultimoAlvo: number;
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
mostrado: Estado | null;
fonte: "ciclo" | "rolagem";
preso: boolean;
pointer: Vector2;
tilt: Vector2;
frames: number;
/**
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
* quadro: o balanço não salta quando o desenho volta depois de parado).
*/
vida: number;
vidaEstado: number;
vidaTempo: number;
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
cursor: Vector2;
cursorPeso: number;
};
type StepInput = {
tier: Tier;
target: number;
placement: Rect | null;
stage: Rect | null;
host: { width: number; height: number };
/** Tempo real desde o quadro anterior (ms). */
delta: number;
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
cursor: Vector2 | null;
};
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
function pesoMarca({ from, to, t }: Estado) {
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
}
/**
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
* cursor, e escreve os uniforms.
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
*/
function stepBlocos(world: World, s: SceneState, input: StepInput) {
const { tier, target, placement, stage, host, delta } = input;
const dt = Math.min(delta / 1000, 1 / 20);
s.intro = Math.min(1, s.intro + dt / ENTRADA);
let settling = s.intro < 1;
let wait = 0;
let pedido: Estado;
let fonte: SceneState["fonte"] = "ciclo";
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
const real = Math.min(delta / 1000, FICA + TROCA);
if (tier === "full") {
// rolagem amortecida: a peça segue o dedo com um leve atraso
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
if (Math.abs(target - s.progress) > 4e-4) settling = true;
else s.progress = target;
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
// s) assume; qualquer rolagem devolve o comando à rolagem
const noTopo =
s.intro >= 1 &&
target < TOPO &&
target === s.ultimoAlvo &&
s.progress === target;
s.ultimoAlvo = target;
s.quieto = noTopo ? s.quieto + real : 0;
if (s.quieto > 0) {
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
pedido = ciclo;
if (ciclo.wait === 0) settling = true;
else wait = ciclo.wait;
} else {
pedido = stateAt(s.progress);
fonte = "rolagem";
}
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
} else {
if (s.intro >= 1) s.clock += real;
const lite = cicloAt(s.clock, FICA, TROCA);
pedido = lite;
if (lite.wait === 0) settling = true;
else wait = lite.wait;
}
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
if (fonte !== s.fonte) s.preso = false;
s.fonte = fonte;
let estado = pedido;
if (s.mostrado && !s.preso) {
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
s.preso =
estado.from === pedido.from &&
estado.to === pedido.to &&
estado.t === pedido.t;
if (!s.preso) settling = true;
}
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
const u = world.material.uniforms;
u.uFrom.value = estado.from;
u.uTo.value = estado.to;
u.uT.value = estado.t;
u.uIntro.value = s.intro;
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
const w = pesoMarca(estado);
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
const fora =
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
let area = stage;
if (tier === "full" && placement) {
const topo = caixaDosEstados(placement, host);
const fim = stage ?? { x: 0, y: 0, ...host };
area = {
x: MathUtils.lerp(topo.x, fim.x, fora),
y: MathUtils.lerp(topo.y, fim.y, fora),
width: MathUtils.lerp(topo.width, fim.width, fora),
height: MathUtils.lerp(topo.height, fim.height, fora),
};
}
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
const k =
host.height /
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
const frame = framePiece(host, placement, w, area);
const x = (frame.x - host.width / 2) / k;
const y = (host.height / 2 - frame.y) / k;
world.mesh.position.set(x, y, 0);
world.mesh.scale.setScalar(frame.scale / k);
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
const pose = world.pose.set(
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
0,
);
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
world.mesh.rotation.set(
pose.x - s.tilt.y * 0.06,
pose.y + sway + s.tilt.x * 0.1,
0,
);
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
world.luz
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
.transpose();
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
const parado = estado.from === estado.to && s.intro >= 1;
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
s.vidaTempo += dt;
u.uVida.value = s.vida;
u.uEstado.value = s.vidaEstado;
u.uTempo.value = s.vidaTempo;
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
let mira = 0;
if (input.cursor) {
world.mesh.updateMatrixWorld();
const { raio, camera } = world;
raio.origin.setFromMatrixPosition(camera.matrixWorld);
raio.direction
.set(
(input.cursor.x / host.width) * 2 - 1,
1 - (input.cursor.y / host.height) * 2,
0.5,
)
.unproject(camera)
.sub(raio.origin);
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
}
if (mira) {
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
}
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
u.uCursor.value.copy(s.cursor);
u.uCursorPeso.value = s.cursorPeso * s.vida;
s.frames += 1;
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
const assentada = estado.from === estado.to && estado.t === 0;
const legenda =
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
return { again: settling, wait, ready: s.frames === 2, legenda };
}
/** Solta o contexto WebGL e tira o canvas da página. */
function disposeRenderer(renderer: WebGLRenderer) {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
/**
* O renderizador transparente, com antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
* contexto não abre ou se ele desenha por software (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
*/
function createRenderer(host: HTMLElement, tier: Tier) {
const renderer = new WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
if (isSoftwareWebGL(renderer.getContext())) {
rememberSoftwareWebGL();
disposeRenderer(renderer);
throw new Error("WebGL por software");
}
renderer.setPixelRatio(
Math.min(
tier === "lite" ? 1.5 : 1.75,
Math.max(1, window.devicePixelRatio || 1),
),
);
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
host.append(canvas);
return renderer;
}
type Parts = { renderer: WebGLRenderer; world: World };
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
function disposeParts({ renderer, world }: Parts) {
world.geometry.dispose();
world.material.dispose();
world.porcelana.dispose();
world.lima.dispose();
disposeRenderer(renderer);
}
type Callbacks = Pick<
BlocosSceneProps,
"progressRef" | "placementRef" | "stageRef" | "onEstado"
> & {
onReady: () => void;
onError: () => void;
};
export type BlocosEngine = {
setVisible: (visible: boolean) => void;
dispose: () => void;
};
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
function startBlocos(
host: HTMLElement,
parts: Parts,
{
progressRef,
placementRef,
stageRef,
onReady,
onError,
onEstado,
}: Callbacks,
tier: Tier,
): BlocosEngine {
const { renderer, world } = parts;
const canvas = renderer.domElement;
const s: SceneState = {
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
progress: progressRef.current ?? 0,
intro: 0,
clock: 0,
quieto: 0,
ultimoAlvo: progressRef.current ?? 0,
mostrado: null,
fonte: "rolagem",
preso: true,
pointer: new Vector2(),
tilt: new Vector2(),
frames: 0,
vida: 0,
vidaEstado: 0,
vidaTempo: 0,
cursor: new Vector2(),
cursorPeso: 0,
};
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
let mouse: Vector2 | null = null;
const noHost = new Vector2();
const size = { width: 1, height: 1 };
const budget = createFrameBudget();
let verdict: FrameVerdict = "pending";
let alive = true;
let visible = false;
let raf = 0;
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
let chained = false;
let last = 0;
let wake: ReturnType<typeof setTimeout> | undefined;
let stopPulse: (() => void) | undefined;
/** O último estado avisado para a legenda. */
let legenda: number | null = null;
const request = (fromFrame: boolean) => {
if (raf || !alive || !visible) return;
chained = fromFrame;
raf = requestAnimationFrame(render);
};
const invalidate = () => request(false);
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
const halt = () => {
cancelAnimationFrame(raf);
raf = 0;
clearTimeout(wake);
wake = undefined;
stopPulse?.();
stopPulse = undefined;
last = 0;
};
const fail = () => {
if (!alive) return;
alive = false;
halt();
onError();
};
const draw = (now: number) => {
const delta = last ? now - last : 1000 / 60;
last = now;
if (chained) verdict = budget(delta);
if (verdict === "slow") {
rememberSlowScene();
fail();
return;
}
let cursor: Vector2 | null = null;
if (mouse) {
const r = host.getBoundingClientRect();
noHost.set(mouse.x - r.left, mouse.y - r.top);
if (
noHost.x >= 0 &&
noHost.y >= 0 &&
noHost.x <= r.width &&
noHost.y <= r.height
)
cursor = noHost;
}
const step = stepBlocos(world, s, {
tier,
target: progressRef.current ?? 0,
placement: placementRef?.current ?? null,
stage: stageRef?.current ?? null,
host: size,
delta,
cursor,
});
renderer.render(world.scene, world.camera);
if (step.ready) onReady();
if (step.legenda !== legenda) {
legenda = step.legenda;
onEstado?.(legenda);
}
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
if (step.again || verdict === "pending") request(true);
else if (step.wait) {
clearTimeout(wake);
wake = setTimeout(invalidate, step.wait * 1000);
}
};
const render = (now: number) => {
raf = 0;
if (!alive) return;
try {
draw(now);
} catch {
fail();
}
};
const resize = () => {
size.width = Math.max(1, host.clientWidth);
size.height = Math.max(1, host.clientHeight);
renderer.setSize(size.width, size.height, false);
world.camera.aspect = size.width / size.height;
world.camera.updateProjectionMatrix();
invalidate();
};
const onPointer = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
mouse ??= new Vector2();
mouse.set(event.clientX, event.clientY);
};
// o mouse saiu da janela: os blocos voltam
const onLeave = (event: PointerEvent) => {
if (!event.relatedTarget) mouse = null;
};
const onLost = (event: Event) => {
event.preventDefault();
fail();
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
observer?.observe(host);
if (!observer) window.addEventListener("resize", resize);
if (tier === "full") {
window.addEventListener("pointermove", onPointer, { passive: true });
window.addEventListener("pointerout", onLeave, { passive: true });
}
canvas.addEventListener("webglcontextlost", onLost);
resize();
return {
setVisible(next) {
visible = next;
if (!next || !alive) return halt();
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
stopPulse ??= startFramePulse(invalidate);
},
dispose() {
alive = false;
halt();
observer?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointer);
window.removeEventListener("pointerout", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
disposeParts(parts);
},
};
}
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/**
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
* software ou se o shader não compila.
*/
export async function mountBlocos(
host: HTMLElement,
callbacks: Callbacks,
tier: Tier,
isAlive: () => boolean,
): Promise<BlocosEngine | null> {
const loaded = await loadBlocos();
await nextTask();
if (!isAlive()) return null;
const renderer = createRenderer(host, tier);
let parts: Parts | undefined;
try {
await nextTask();
if (!isAlive()) {
disposeRenderer(renderer);
return null;
}
parts = { renderer, world: buildWorld(loaded) };
await renderer.compileAsync(parts.world.scene, parts.world.camera);
await nextTask();
} catch (error) {
if (parts) disposeParts(parts);
else disposeRenderer(renderer);
throw error;
}
if (!isAlive()) {
disposeParts(parts);
return null;
}
return startBlocos(host, parts, callbacks, tier);
}
export default function BlocosScene({
progressRef,
visible,
onReady,
onError,
tier = "full",
placementRef,
stageRef,
onEstado,
}: BlocosSceneProps) {
const hostRef = useRef<HTMLDivElement>(null);
const engine = useRef<BlocosEngine | null>(null);
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
const callbacks = useRef({ onReady, onError, onEstado });
useEffect(() => {
callbacks.current = { onReady, onError, onEstado };
});
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
const wantsVisible = useRef(visible);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let alive = true;
mountBlocos(
host,
{
progressRef,
placementRef,
stageRef,
onReady: () => callbacks.current.onReady(),
onEstado: (estado) => callbacks.current.onEstado?.(estado),
onError: () => callbacks.current.onError(),
},
tier,
() => alive,
)
.then((created) => {
if (!created) return;
if (!alive) return created.dispose();
engine.current = created;
created.setVisible(wantsVisible.current);
})
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
.catch(() => {
if (alive) callbacks.current.onError();
});
return () => {
alive = false;
engine.current?.dispose();
engine.current = null;
};
}, [progressRef, placementRef, stageRef, tier]);
useEffect(() => {
wantsVisible.current = visible;
engine.current?.setVisible(visible);
}, [visible]);
return (
<div
ref={hostRef}
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
/>
);
}
@@ -41,11 +41,11 @@ describe("CinematicHome: dados estruturados", () => {
expect(container.firstElementChild).toBe(scripts[0]);
});
it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
it("describes only the five offers as Service nodes tied to the person, without repeating what the layout emits", () => {
const [data] = structuredData();
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(3);
expect(data["@graph"]).toHaveLength(5);
for (const offer of data["@graph"]) {
expect(offer["@type"]).toBe("Service");
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
* vem do layout) e a ligação dos hooks.
* As cenas e os hooks são trocados por dublês que registram o que recebem.
*/
@@ -75,59 +75,6 @@ beforeEach(() => {
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
});
describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project (Lavação 047) is in the list with its own domain -> that domain goes to the hero", () => {
render(
<CinematicHome
projects={[
project("sev-modaintima", "https://sevmodaintima.com.br"),
project("lavacao-047", "https://www.lavacao047.com.br/"),
]}
/>,
);
expect(lastHeroProps().workUrl).toEqual({
text: "lavacao047.com.br",
secure: true,
});
});
it("path: the featured project is in the list but has no site -> no address", () => {
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project is not in the list -> no address", () => {
render(
<CinematicHome
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
render(
<CinematicHome
projects={[
project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: empty project list -> no address and the page still renders", () => {
render(<CinematicHome projects={[]} />);
expect(lastHeroProps().workUrl).toBeNull();
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
});
});
describe("CinematicHome: props e estrutura", () => {
it("path: every optional prop given -> each scene receives its own data", () => {
const projects = [project("lavacao-047", "https://a.example.com")];
+1 -13
View File
@@ -8,14 +8,10 @@ import Contact from "../Contact";
import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects";
import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import CinematicMain from "./CinematicMain";
/** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
const HERO_WORK_PROJECT = "lavacao-047";
/**
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
@@ -44,19 +40,11 @@ export default function CinematicHome({
clients?: ClientLink[];
testimonials?: Testimonial[];
}) {
const shownUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
?.demoUrl,
});
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
const heroWorkUrl =
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
return (
<>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<CinematicMain>
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Hero stats={heroStats} clients={clients} />
<Projects projects={projects} />
@@ -24,16 +24,19 @@ function MockScene(props: SceneProps) {
return require("react").createElement("canvas", { "data-testid": "scene" });
}
jest.mock("./HeroFieldScene", () => ({
jest.mock("./BlocosScene", () => ({
__esModule: true,
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
get default() {
if (mockImportFails) throw new Error("chunk indisponível");
return MockScene;
},
get prepareField() {
get prepareBlocos() {
if (mockImportFails) throw new Error("chunk indisponível");
return () => mockPrepare();
return () => {
mockPrepare();
return Promise.resolve();
};
},
}));
@@ -345,7 +348,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
});
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
mockMedia(["(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 4 });
const probe = jest.fn(() => ({ getExtension: () => null }));
setContext(probe);
const addListener = jest.spyOn(window, "addEventListener");
@@ -539,7 +542,7 @@ describe("HeroField: rolagem e visibilidade", () => {
});
describe("HeroField: navegador sem matchMedia", () => {
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
it("path: no matchMedia at all -> the dive is not started, but the scene still mounts", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<Hero />);
+183 -12
View File
@@ -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");
});
});
+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>
);
}
@@ -1,101 +0,0 @@
import { act, render } from "@testing-library/react";
import HeroFieldScene from "./HeroFieldScene";
/**
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
* compila, sucesso) estão em HeroFieldScene.test.tsx.
*/
const mockRenderers: MockRenderer[] = [];
let mockBuildThrows = false;
class MockRenderer {
domElement = document.createElement("canvas");
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = jest.fn();
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
getContext = () => ({ getExtension: () => null });
getPixelRatio = () => 1;
constructor() {
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const actual = jest.requireActual("three");
class FailingCamera extends actual.PerspectiveCamera {
constructor(...args: unknown[]) {
if (mockBuildThrows) throw new Error("sem memória para a cena");
super(...args);
}
}
return {
...actual,
PerspectiveCamera: FailingCamera,
WebGLRenderer: jest.fn(() => new MockRenderer()),
};
});
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockBuildThrows = false;
});
afterEach(() => {
jest.useRealTimers();
});
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
const buildScene = () =>
act(async () => {
await jest.advanceTimersByTimeAsync(10);
});
describe("mountField: erro antes de as peças existirem", () => {
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
mockBuildThrows = true;
const scene = props();
const { container } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
const scene = props();
const { container, unmount } = render(<HeroFieldScene {...scene} />);
await buildScene();
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).not.toBeNull();
expect(scene.onError).not.toHaveBeenCalled();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
});
});
@@ -1,957 +0,0 @@
import {
type BufferGeometry,
type Group,
type PerspectiveCamera,
type Plane,
Ray,
type ShaderMaterial,
type Vector3,
} from "three";
import {
createField,
type FieldEngine,
isSettling,
prepareField,
type Rig,
type Tick,
updatePointer,
updateRig,
} from "./HeroFieldScene";
/**
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
* - perda do contexto WebGL, falha ao desenhar e limpeza.
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
* que o teste escolhe.
*/
const mockMaterials: ShaderMaterial[] = [];
const mockGeometries: BufferGeometry[] = [];
const mockCameras: PerspectiveCamera[] = [];
const mockGroups: Group[] = [];
const mockCounts = { vectors: 0 };
const mockRenderers: MockRenderer[] = [];
let mockRenderThrows = false;
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 2;
render = jest.fn(() => {
if (mockRenderThrows) throw new Error("falha ao desenhar");
});
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = jest.fn();
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
getContext = () => ({ getExtension: () => null });
getPixelRatio = () => this.pixelRatio;
constructor() {
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const actual = jest.requireActual("three");
class CapturingMaterial extends actual.ShaderMaterial {
constructor(parameters: unknown) {
super(parameters);
mockMaterials.push(this as unknown as ShaderMaterial);
}
}
class CapturingGeometry extends actual.BufferGeometry {
constructor() {
super();
mockGeometries.push(this as unknown as BufferGeometry);
}
}
class CapturingCamera extends actual.PerspectiveCamera {
constructor(...args: unknown[]) {
super(...args);
mockCameras.push(this as unknown as PerspectiveCamera);
}
}
class CapturingGroup extends actual.Group {
constructor() {
super();
mockGroups.push(this as unknown as Group);
}
}
class CountingVector3 extends actual.Vector3 {
constructor(x?: number, y?: number, z?: number) {
super(x, y, z);
mockCounts.vectors += 1;
}
}
return {
...actual,
WebGLRenderer: jest.fn(() => new MockRenderer()),
ShaderMaterial: CapturingMaterial,
BufferGeometry: CapturingGeometry,
PerspectiveCamera: CapturingCamera,
Group: CapturingGroup,
Vector3: CountingVector3,
};
});
type Setup = {
width?: number;
height?: number;
/** `null` simula uma ref sem valor (o código trata como 0). */
progress?: number | null;
visible?: boolean;
};
let engines: FieldEngine[] = [];
let frameRequests: jest.SpyInstance;
function setup({
width = 1200,
height = 800,
progress = 0,
visible = true,
}: Setup = {}) {
const host = document.createElement("div");
Object.defineProperty(host, "clientWidth", { value: width });
Object.defineProperty(host, "clientHeight", { value: height });
document.body.append(host);
const progressRef = {
current: progress === null ? undefined : progress,
} as { current: number };
const onReady = jest.fn();
const onError = jest.fn();
const engine = createField(host, { progressRef, onReady, onError });
engines.push(engine);
engine.setVisible(visible);
const renderer = mockRenderers[mockRenderers.length - 1];
const camera = mockCameras[mockCameras.length - 1];
const group = mockGroups[mockGroups.length - 1];
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
let clock = 1000;
/**
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
*/
function frame(deltaMs = 1000 / 60) {
frameRequests.mockClear();
clock += deltaMs;
engine.renderAt(clock);
return frameRequests.mock.calls.length;
}
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
function settle(count: number, deltaMs = 50) {
let asked = 0;
for (let i = 0; i < count; i++) asked = frame(deltaMs);
return asked;
}
return {
host,
engine,
renderer,
camera,
group,
progressRef,
onReady,
onError,
uniforms,
frame,
settle,
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
now: () => clock,
};
}
const originalHardware = Object.getOwnPropertyDescriptor(
navigator,
"hardwareConcurrency",
);
function setCores(value: number | undefined) {
Object.defineProperty(navigator, "hardwareConcurrency", {
configurable: true,
value,
});
}
function setWindowSize(width: number, height = 800) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: height,
});
}
function pointerMove(type: string, x = 600, y = 400) {
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
Object.defineProperty(event, "pointerType", { value: type });
window.dispatchEvent(event);
}
beforeEach(() => {
mockCounts.vectors = 0;
mockMaterials.length = 0;
mockGeometries.length = 0;
mockCameras.length = 0;
mockGroups.length = 0;
mockRenderers.length = 0;
mockRenderThrows = false;
engines = [];
setCores(8);
setWindowSize(1200);
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
jest.spyOn(performance, "now").mockReturnValue(1000);
frameRequests = jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(() => 1 as unknown as number);
});
afterEach(() => {
for (const engine of engines) engine.dispose();
jest.restoreAllMocks();
document.body.innerHTML = "";
if (originalHardware)
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
});
describe("createField: quadro — começo e rolagem", () => {
it("path: onReady fires on the second frame, once", () => {
const scene = setup();
scene.frame();
expect(scene.onReady).not.toHaveBeenCalled();
scene.frame();
expect(scene.onReady).toHaveBeenCalledTimes(1);
scene.frame();
scene.frame();
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
const scene = setup({ progress: 1 });
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
});
it("path: no progress available (undefined) -> treated as the top of the page", () => {
const scene = setup({ progress: null });
scene.settle(5);
expect(scene.uniforms().uScroll.value).toBe(0);
});
it("path: the intro advances every frame and stops at 1", () => {
const scene = setup();
scene.frame(50); // o primeiro quadro: 1/60 de segundo
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
scene.frame(50);
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
scene.settle(80);
expect(scene.uniforms().uIntro.value).toBe(1);
});
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
const scene = setup({ progress: 0 });
scene.settle(10);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 0.9;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBe(1);
scene.progressRef.current = 1;
scene.settle(120);
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
});
it("path: layers spread apart only while they face the viewer", () => {
const scene = setup({ progress: 0 });
scene.settle(5);
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
scene.progressRef.current = 0.5;
scene.settle(150);
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
});
it("path: the clock reaches the shader in seconds since the scene started", () => {
const scene = setup();
scene.frame(0);
scene.frame(2000);
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
});
});
describe("createField: enquadramento e foco", () => {
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
const scene = setup({ width: 1200, height: 800 });
scene.frame();
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
expect(scene.camera.position.z).toBeCloseTo(9, 9);
});
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
const scene = setup({ width: 400, height: 800 });
scene.frame();
expect(scene.group.position.y).toBeLessThan(-0.5);
expect(scene.camera.position.z).toBeGreaterThan(9);
});
it("path: the renderer and the camera follow the size of the host", () => {
const scene = setup({ width: 900, height: 300 });
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
expect(scene.camera.aspect).toBeCloseTo(3, 9);
});
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
const scene = setup({ width: 0, height: 0 });
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
});
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.group.position.y).toBeCloseTo(0, 6);
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
expect(scene.camera.position.z).toBeLessThan(0);
expect(scene.camera.position.z).toBeGreaterThan(-spread);
expect(scene.camera.position.z + spread).toBeLessThan(6);
});
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
const scene = setup({ progress: 0 });
scene.frame();
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
});
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
const scene = setup({ progress: 1 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
expect(scene.uniforms().uFocus.value).toBeCloseTo(
scene.camera.position.z + spread,
6,
);
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
});
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
const scene = setup({ progress: 0.55 });
scene.settle(200);
const spread = scene.uniforms().uSpread.value;
const z = scene.camera.position.z;
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
expect(z).toBeGreaterThan(0.2);
expect(z).toBeLessThan(spread);
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
});
});
describe("createField: mouse", () => {
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
const scene = setup();
pointerMove("touch");
pointerMove("pen");
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
scene.settle(120);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
const scene = setup();
scene.frame(); // o relógio da cena começa
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
});
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
const scene = setup();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
const scene = setup();
(performance.now as jest.Mock).mockReturnValue(0);
pointerMove("mouse");
for (let i = 0; i < 30; i++) scene.frame(50);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
});
it("path: cursor strength fades to 0 once the dive is under way", () => {
const scene = setup({ progress: 1 });
scene.frame();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
for (let i = 0; i < 200; i++) scene.frame(5);
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
});
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
const scene = setup();
jest
.spyOn(Ray.prototype, "intersectPlane")
.mockImplementation(((_plane: Plane, target: Vector3) =>
target.set(1, 0, 2)) as never);
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
scene.group.worldToLocal = ((point: Vector3) => point) as never;
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
const before = scene.uniforms().uMouse.value.clone();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame(50);
const factor = 1 - Math.exp(-(1 / 20) * 10);
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
before.x + (1 - before.x) * factor,
6,
);
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
before.y + (2 - before.y) * factor,
6,
);
});
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
const scene = setup();
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
const before = scene.uniforms().uMouse.value.clone();
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame(50);
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
});
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
const scene = setup();
scene.frame(0);
scene.frame(9000);
const { x, y } = scene.uniforms().uMouse.value;
expect(Math.hypot(x, y)).toBeGreaterThan(0);
});
});
describe("createField: quando pedir o próximo quadro", () => {
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
const scene = setup({ visible: false });
expect(scene.frame()).toBeGreaterThan(0);
});
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
const scene = setup({ visible: true });
scene.settle(200);
expect(scene.frame(50)).toBe(0);
});
it("path: everything settled and the hero is not visible -> no frame", () => {
const scene = setup({ visible: false });
scene.settle(200);
expect(scene.frame(50)).toBe(0);
});
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
const scene = setup({ visible: true });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
const drawn = scene.renderer.render.mock.calls.length;
expect(scene.frame(50)).toBe(0);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
const scene = setup({ visible: false });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
});
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
const scene = setup({ visible: false });
scene.settle(200);
scene.progressRef.current = 0.5;
expect(scene.frame()).toBeGreaterThan(0);
});
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
const scene = setup({ visible: false });
scene.settle(200);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
expect(scene.frame(50)).toBe(0);
frameRequests.mockClear();
scene.engine.setVisible(true);
expect(frameRequests).toHaveBeenCalledTimes(1);
});
it("path: several requests before a frame become ONE animation frame", () => {
const scene = setup({ visible: true });
scene.settle(200);
frameRequests.mockClear();
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
scene.engine.setVisible(false);
scene.engine.setVisible(true);
scene.engine.setVisible(false);
scene.engine.setVisible(true);
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
});
});
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
function rig(): Rig {
const scene = setup();
scene.frame();
const material = mockMaterials[mockMaterials.length - 1];
return {
group: scene.group,
camera: scene.camera,
uniforms: material.uniforms,
layout: {
wide: true,
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
spreadMax: 5.5,
rest: 9,
scale: 1000,
camera: [
[0, 9],
[1, -1],
],
} as unknown as Rig["layout"],
state: {
progress: 0,
pointer: { set() {}, x: 0, y: 0 } as never,
lastMove: -10,
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
tilt: { lerp() {}, x: 0, y: 0 } as never,
frames: 10,
},
helpers: {} as Rig["helpers"],
};
}
const tick = (overrides: Partial<Tick> = {}): Tick => ({
p: 0,
face: 0,
time: 1,
dt: 1 / 60,
cursorActive: false,
...overrides,
});
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
r.uniforms.uIntro.value = 0.5;
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
});
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.uniforms.uMouseAmt.value = 0;
r.state.frames = 1;
r.state.lastMove = 1;
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
});
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
const r = rig();
r.helpers = {
target: { set: () => ({}), x: 0, y: 0 },
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
plane: { set: () => ({ applyMatrix4() {} }) },
up: {},
hit: {},
} as never;
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
});
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
const r = rig();
updateRig(r, tick({ face: 0 }));
const resting = r.group.rotation.x;
updateRig(r, tick({ face: 1 }));
expect(resting).toBeCloseTo(0.42, 9);
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
// de frente, o balanço lateral some
expect(r.group.rotation.y).toBeCloseTo(0, 9);
});
it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
const r = rig();
r.uniforms.uIntro.value = 1;
r.state.progress = 0.5;
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
});
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
const r = rig();
updateRig(r, tick({ p: -0.5 }));
expect(r.camera.position.z).toBe(9);
updateRig(r, tick({ p: 0.5 }));
expect(r.camera.position.z).toBeCloseTo(4, 9);
updateRig(r, tick({ p: 2 }));
expect(r.camera.position.z).toBe(-1);
});
});
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
prepareField();
expect(mockGeometries).toHaveLength(1);
const prepared = mockGeometries[0];
prepareField(); // de novo, sem montar: nada é refeito
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(1);
expect(mockGeometries[0]).toBe(prepared);
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
});
it("path: the preparation is used once; a second scene builds its own points", () => {
prepareField();
setup();
expect(mockGeometries).toHaveLength(1);
setup();
expect(mockGeometries).toHaveLength(2);
});
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
const scene = setup();
scene.frame(0);
(performance.now as jest.Mock).mockReturnValue(scene.now());
pointerMove("mouse");
scene.frame();
const before = mockCounts.vectors;
for (let i = 0; i < 60; i++) scene.frame();
// nenhum Vector3 novo: os auxiliares são criados uma vez só
expect(mockCounts.vectors).toBe(before);
});
});
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
const pointCount = () => {
const geometry = mockGeometries[mockGeometries.length - 1];
return geometry.getAttribute("position").count;
};
it("path: wide window and 4+ cores -> the dense field", () => {
setWindowSize(1200);
setCores(8);
setup();
const dense = pointCount();
mockGeometries.length = 0;
setWindowSize(600);
setup();
expect(dense).toBeGreaterThan(pointCount());
});
it("path: wide window but 2 cores -> the light field", () => {
setWindowSize(1200);
setCores(2);
setup();
const light = pointCount();
mockGeometries.length = 0;
setCores(8);
setup();
expect(pointCount()).toBeGreaterThan(light);
});
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
setWindowSize(1200);
const counts: Record<string, number> = {};
for (const [label, cores] of [
["dense", 8],
["light", 2],
["unknown", undefined],
] as const) {
mockGeometries.length = 0;
setCores(cores);
setup();
counts[label] = pointCount();
}
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
Math.abs(counts.unknown - counts.light),
);
});
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
const scene = setup();
const event = new Event("webglcontextlost", { cancelable: true });
scene.renderer.domElement.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(scene.onError).toHaveBeenCalledTimes(1);
});
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
const scene = setup();
mockRenderThrows = true;
expect(() => scene.frame()).not.toThrow();
expect(scene.onError).toHaveBeenCalled();
});
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
const scene = setup();
const geometry = mockGeometries[mockGeometries.length - 1];
const material = mockMaterials[mockMaterials.length - 1];
const disposeGeometry = jest.spyOn(geometry, "dispose");
const disposeMaterial = jest.spyOn(material, "dispose");
const event = new Event("webglcontextlost", { cancelable: true });
scene.engine.dispose();
scene.renderer.domElement.dispatchEvent(event);
pointerMove("mouse");
expect(disposeGeometry).toHaveBeenCalledTimes(1);
expect(disposeMaterial).toHaveBeenCalledTimes(1);
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
expect(scene.renderer.domElement.isConnected).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(scene.onError).not.toHaveBeenCalled();
});
it("path: a frame after dispose does nothing", () => {
const scene = setup();
scene.engine.dispose();
scene.renderer.render.mockClear();
scene.frame();
expect(scene.renderer.render).not.toHaveBeenCalled();
});
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
const original = globalThis.ResizeObserver;
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
globalThis.ResizeObserver = undefined;
try {
const scene = setup({ width: 600, height: 400 });
scene.renderer.setSize.mockClear();
window.dispatchEvent(new Event("resize"));
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
} finally {
globalThis.ResizeObserver = original;
}
});
});
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
function drawnOn(
scene: ReturnType<typeof setup>,
refreshMs: number,
seconds = 1,
) {
const before = scene.renderer.render.mock.calls.length;
const frames = Math.round((seconds * 1000) / refreshMs);
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
return scene.renderer.render.mock.calls.length - before;
}
/**
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
*/
function pulsedScreen(refreshMs: number) {
let beat = () => {};
jest.spyOn(window, "setInterval").mockImplementation(((
callback: () => void,
) => {
beat = callback;
return 7;
}) as never);
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
let pending: FrameRequestCallback | undefined;
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
pending = callback;
return 1;
}) as never);
const scene = setup({ visible: true });
let clock = 0;
let nextBeat = 33;
const run = (seconds: number) => {
const drawnBefore = scene.renderer.render.mock.calls.length;
frameRequests.mockClear();
const end = clock + seconds * 1000;
for (clock += refreshMs; clock <= end; clock += refreshMs) {
for (; nextBeat <= clock; nextBeat += 33) beat();
const frame = pending;
pending = undefined;
frame?.(1000 + clock);
}
return {
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
requested: frameRequests.mock.calls.length,
};
};
run(6); // a entrada corre a cada quadro da tela e assenta
pointerMove("mouse");
return run;
}
it.each([
[144, 1000 / 144],
[120, 1000 / 120],
[60, 1000 / 60],
])(
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
(_hz, refreshMs) => {
const run = pulsedScreen(refreshMs);
const { drawn, requested } = run(2);
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
expect(drawn).toBeLessThanOrEqual(2 * 31);
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
expect(requested).toBeLessThanOrEqual(drawn + 1);
},
);
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
const run = pulsedScreen(1000 / 144);
const drawn = run(0.5).drawn;
expect(drawn).toBeGreaterThanOrEqual(14);
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
});
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
const scene = setup({ visible: true });
scene.frame(); // a entrada ainda corre
const intro = drawnOn(scene, 1000 / 144, 0.5);
expect(intro).toBe(Math.round(500 / (1000 / 144)));
scene.settle(200); // tudo assentado
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
expect(scrolling).toBe(15);
});
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
const scene = setup({ visible: true });
scene.settle(200);
scene.frame(33);
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
const drawn = scene.renderer.render.mock.calls.length;
scene.frame(33);
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
});
});
describe("createField: a batida de 30 quadros por segundo", () => {
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
const interval = jest
.spyOn(window, "setInterval")
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
const { engine } = setup({ visible: false });
expect(interval).not.toHaveBeenCalled();
engine.setVisible(true);
engine.setVisible(true);
expect(interval).toHaveBeenCalledTimes(1);
engine.setVisible(false);
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
engine.setVisible(true);
expect(interval).toHaveBeenCalledTimes(2);
});
});
@@ -1,471 +0,0 @@
import { act, render } from "@testing-library/react";
import { isKnownSoftwareWebGL } from "@/lib/device";
import HeroFieldScene from "./HeroFieldScene";
/*
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
* existe: o renderizador é um dublê.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
let mockFailToCreate = false;
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => mockCompile());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
if (mockFailToCreate) throw new Error("sem WebGL");
this.options = options;
mockRenderers.push(this);
}
}
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
jest.mock("three", () => ({
...jest.requireActual("three"),
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
}));
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockFailToCreate = false;
mockRendererName = null;
mockCompile = () => Promise.resolve();
setDevicePixelRatio(1);
});
afterEach(() => {
jest.useRealTimers();
});
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
const nextTask = () =>
act(async () => {
await jest.runOnlyPendingTimersAsync();
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
describe("HeroFieldScene (ambient 3D)", () => {
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toMatchObject({
alpha: true,
antialias: false,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
});
it.each([
[0.5, 1],
[1, 1],
[1.25, 1.25],
[2, 1.5],
[3, 1.5],
])(
"caps the pixel ratio at 1.5 (device %s -> %s)",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<HeroFieldScene {...props()} />);
expect(frames).toHaveBeenCalledTimes(1);
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
act(() => {
jest.advanceTimersByTime(5000);
});
const settled = frames.mock.calls.length;
expect(settled).toBeGreaterThan(2);
expect(settled).toBeLessThan(5000 / 16);
// hero fora de cena e tudo assentado: nenhum quadro a mais
act(() => {
jest.advanceTimersByTime(5000);
});
expect(frames.mock.calls.length).toBe(settled);
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
});
it("tells the page it is ready after the second frame", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onReady).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(100);
});
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<HeroFieldScene {...scene} visible={false} />,
);
act(() => {
jest.advanceTimersByTime(6000);
});
const hidden = frames.mock.calls.length;
expect(jest.getTimerCount()).toBe(0);
rerender(<HeroFieldScene {...scene} visible />);
act(() => {
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
});
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
act(() => {
jest.advanceTimersByTime(980);
});
const shown = frames.mock.calls.length - hidden;
expect(shown).toBeGreaterThan(20);
expect(shown).toBeLessThanOrEqual(34);
rerender(<HeroFieldScene {...scene} visible={false} />);
const stopped = frames.mock.calls.length;
act(() => {
jest.advanceTimersByTime(2000);
});
expect(frames.mock.calls.length).toBe(stopped);
expect(jest.getTimerCount()).toBe(0);
});
it("releases the context, the canvas and the timers when it unmounts", async () => {
const scene = props();
const { container, unmount } = await mount(
<HeroFieldScene {...scene} visible />,
);
expect(container.querySelector("canvas")).not.toBeNull();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(jest.getTimerCount()).toBe(0);
const drawn = mockRenderers[0].render.mock.calls.length;
act(() => {
jest.advanceTimersByTime(1000);
});
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
const first = props();
const { rerender } = await mount(<HeroFieldScene {...first} />);
const second = props();
rerender(
<HeroFieldScene
progressRef={first.progressRef}
visible={false}
onReady={second.onReady}
onError={second.onError}
/>,
);
expect(mockRenderers).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(100);
});
expect(first.onReady).not.toHaveBeenCalled();
expect(second.onReady).toHaveBeenCalledTimes(1);
});
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
mockFailToCreate = true;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it.each([
[0, 1], // aparelho que não informa a proporção: 1
[0.5, 1], // nunca abaixo de 1
[1.25, 1.25],
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
])(
"draws at a pixel ratio of %p -> the renderer gets %p",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
describe("built in separate tasks (no single long task)", () => {
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
render(<HeroFieldScene {...scene} />);
// nada acontece dentro do commit do React: nem o contexto WebGL
expect(mockRenderers).toHaveLength(0);
await nextTask();
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(frames).not.toHaveBeenCalled();
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
finishCompile();
await act(async () => {
await Promise.resolve();
});
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
expect(mockRenderers[0].setSize).toHaveBeenCalled();
expect(frames).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].render).not.toHaveBeenCalled();
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("compiles the very scene it is going to draw", async () => {
await mount(<HeroFieldScene {...props()} />);
const [scene, camera] = mockRenderers[0].compileAsync.mock
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
expect(scene.isScene).toBe(true);
expect(camera.isCamera).toBe(true);
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
});
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
const scene = props();
const { unmount } = render(<HeroFieldScene {...scene} />);
unmount();
await buildScene();
expect(mockRenderers).toHaveLength(0);
expect(scene.onError).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
const { container, unmount } = render(<HeroFieldScene {...props()} />);
await nextTask();
expect(mockRenderers).toHaveLength(1);
unmount();
await buildScene();
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
});
it("releases the context if it is unmounted while the shader compiles", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const { container, unmount } = await mount(
<HeroFieldScene {...props()} />,
);
expect(mockRenderers).toHaveLength(1);
unmount();
finishCompile();
await buildScene();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(frames).not.toHaveBeenCalled();
});
it("tells the page, and frees the context, when the shader does not compile", async () => {
mockCompile = () => Promise.reject(new Error("shader inválido"));
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it("applies the visibility the page asked for while it was still being built", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = render(
<HeroFieldScene {...scene} visible={false} />,
);
rerender(<HeroFieldScene {...scene} visible />);
await buildScene();
await act(async () => {
await jest.advanceTimersByTimeAsync(980);
});
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
expect(frames.mock.calls.length).toBeGreaterThan(20);
});
});
});
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
beforeEach(() => sessionStorage.clear());
afterEach(() => sessionStorage.clear());
it.each([
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"llvmpipe (LLVM 15.0.7, 256 bits)",
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
])(
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
async (name) => {
mockRendererName = name;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
},
);
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName =
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
it("does not report an unknown renderer (no debug extension) as software", async () => {
mockRendererName = null;
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
* segue a variável), não o texto do CSS.
*/
jest.mock("./HeroFieldScene", () => ({
jest.mock("./BlocosScene", () => ({
__esModule: true,
default: () => null,
prepareField: () => {},
prepareBlocos: () => Promise.resolve(),
}));
const win = window as unknown as Record<string, unknown>;
+23
View File
@@ -0,0 +1,23 @@
/* Legenda da peça do hero (HeroField): o que a peça está montando. Cada texto novo entra digitado, com o passo e o
cursor do rótulo das seções (.ds-type e as animações ds-type, ds-type-caret e ds-type-blink, em globals.css); ela
some só pela opacidade. Sem movimento reduzido: com ele a cena nem monta, e a legenda não existe. */
.hero-legenda {
opacity: 0;
transition: opacity 0.3s var(--ease-out);
}
.hero-legenda[data-shown] {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
.hero-legenda .ds-type {
animation: ds-type calc(var(--n) * 32ms) steps(var(--n)) forwards;
}
.hero-legenda .ds-type::after {
animation:
ds-type-caret calc(var(--n) * 32ms) steps(var(--n)) forwards,
ds-type-blink 0.9s steps(1) calc(var(--n) * 32ms) forwards;
}
}
+2
View File
@@ -25,7 +25,9 @@ describe("contact-subject", () => {
expect(CONTACT_KINDS).toEqual({
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",
});
});
+5 -1
View File
@@ -15,24 +15,28 @@ import { offerLabels } from "@/lib/offer-labels";
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
/**
* As três ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* As cinco ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
*/
export const CONTACT_SUBJECT_OPTIONS = [
DEFAULT_CONTACT_SUBJECT,
offerLabels.site.name,
offerLabels.sistema.name,
offerLabels.whatsapp.name,
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
offerLabels.revisao.name,
offerLabels.fiscal.name,
] as const;
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
export const CONTACT_KINDS = {
site: offerLabels.site.name,
sistema: offerLabels.sistema.name,
whatsapp: offerLabels.whatsapp.name,
revisao: offerLabels.revisao.name,
fiscal: offerLabels.fiscal.name,
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
export type ContactKind = keyof typeof CONTACT_KINDS;
+69
View File
@@ -0,0 +1,69 @@
import { act } from "@testing-library/react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
describe("isFiscalNoticeActive", () => {
it("shows the notice up to 2027-03-31 in Brasília time and hides it from 2027-04-01 on", () => {
expect(isFiscalNoticeActive(new Date("2026-11-09T12:00:00-03:00"))).toBe(
true,
);
expect(isFiscalNoticeActive(new Date("2027-03-31T23:59:59-03:00"))).toBe(
true,
);
expect(isFiscalNoticeActive(new Date("2027-04-01T00:00:00-03:00"))).toBe(
false,
);
expect(isFiscalNoticeActive(new Date("2027-06-01T12:00:00-03:00"))).toBe(
false,
);
});
});
describe("FiscalNotice", () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("path: HTML built before the end, opened after it -> hydrates with no warning, then the notice goes away", async () => {
const html = renderToString(<FiscalNotice href="/servicos/x" />);
expect(html).toContain("Seu sistema emite nota fiscal?");
const container = document.createElement("div");
container.innerHTML = html;
document.body.append(container);
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
jest.useFakeTimers({ now: new Date("2027-04-02T10:00:00-03:00") });
await act(async () => {
hydrateRoot(container, <FiscalNotice href="/servicos/x" />, {
onRecoverableError: (error) => console.error(error),
});
});
expect(errors).not.toHaveBeenCalled();
expect(container.textContent).toBe("");
container.remove();
});
it("path: hydrated within the period -> the notice stays", async () => {
jest.useFakeTimers({ now: new Date("2027-01-10T10:00:00-03:00") });
const html = renderToString(<FiscalNotice href="/servicos/x" />);
const container = document.createElement("div");
container.innerHTML = html;
document.body.append(container);
const errors = jest.spyOn(console, "error").mockImplementation(() => {});
await act(async () => {
hydrateRoot(container, <FiscalNotice href="/servicos/x" />, {
onRecoverableError: (error) => console.error(error),
});
});
expect(errors).not.toHaveBeenCalled();
expect(container.textContent).toContain("Ver o que muda");
container.remove();
});
});
+7
View File
@@ -0,0 +1,7 @@
/** Fim da faixa da nota fiscal na home: ela aparece até 31/03/2027, no horário de Brasília, e some a partir de 1º/04/2027. */
const FISCAL_NOTICE_END = Date.parse("2027-04-01T00:00:00-03:00");
/** A faixa da nota fiscal ainda vale nesta data? */
export function isFiscalNoticeActive(now: Date) {
return now.getTime() < FISCAL_NOTICE_END;
}
+5 -4
View File
@@ -4,10 +4,11 @@
* (globals.css, `.hero-captions li`) as liga e desliga por esses dois valores: uma rampa de 1/14 de progresso para
* entrar a partir de `from` e para sair até `to`.
*
* Os trechos são os da câmera (cameraFrames em cinematic/HeroFieldScene.tsx: a câmera enquadra a camada 1 entre
* 0,28 e 0,44, a 2 entre 0,55 e 0,69 e a 3 entre 0,78 e 0,89), com folga: cada legenda entra um pouco antes de a
* câmera chegar e sai um pouco depois de ela partir. Assim o intervalo sem texto entre uma e outra é de poucos pixels
* de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
* Os trechos seguem os estados da peça (`stateAt` em lib/blocos.ts: troca para a tela entre 0,16 e 0,30, para as
* integrações entre 0,44 e 0,56 e para os dados entre 0,70 e 0,82, parada entre uma troca e outra): cada legenda entra
* durante a troca para o seu estado (a da tela logo no começo dela, com o texto do hero saindo) e sai no meio da troca
* seguinte. Assim o intervalo sem texto entre uma e outra é
* de poucos pixels de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
*/
export const HERO_CAPTIONS = [
{
+30
View File
@@ -0,0 +1,30 @@
/*
* Os quatro blocos lima do símbolo encaixam quando o link que contém o logo recebe o ponteiro ou o foco do teclado:
* sobem cerca de 6% da altura do símbolo e assentam, um depois do outro. A animação nasce com o :hover, então roda
* uma vez por entrada do ponteiro. Só `translate` anima; em SVG o px da transformação é a unidade do viewBox, e o
* símbolo tem 322 unidades de altura (19 = 6%).
*/
@keyframes logo-encaixe {
40% {
translate: 0 -19px;
}
}
.logo-bloco:nth-child(2) {
--logo-atraso: 60ms;
}
.logo-bloco:nth-child(3) {
--logo-atraso: 120ms;
}
.logo-bloco:nth-child(4) {
--logo-atraso: 180ms;
}
@media (prefers-reduced-motion: no-preference) {
a:hover .logo-bloco,
a:focus-visible .logo-bloco {
animation: logo-encaixe 280ms var(--ease-out) var(--logo-atraso, 0ms);
}
}
+38 -10
View File
@@ -9,13 +9,16 @@ import {
useMemo,
useRef,
useState,
ViewTransition,
} from "react";
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
import { isWebKit } from "@/lib/device";
import {
chapterIndexAtScroll,
chapterScrollFraction,
getScrubSource,
getShowcaseAssets,
projectKicker,
videoFractionAtScroll,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
@@ -550,15 +553,17 @@ function ReelLayer({
<div className="ds-reel-screen">
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
{poster && (
<Image
src={desktop.poster}
alt=""
width={desktop.width}
height={desktop.height}
sizes={LAPTOP_POSTER_SIZES}
loading="eager"
fetchPriority="low"
/>
<ActiveCover id={config.id} active={isActive}>
<Image
src={desktop.poster}
alt=""
width={desktop.width}
height={desktop.height}
sizes={LAPTOP_POSTER_SIZES}
loading="eager"
fetchPriority="low"
/>
</ActiveCover>
)}
<video
ref={videoRef}
@@ -592,6 +597,29 @@ function ReelLayer({
);
}
/**
* Só o projeto em cena leva o nome da capa (`case-<id>`): em "Ver case", o pôster do notebook viaja até o topo do
* case. É o pôster, e não o vídeo por cima dele, porque é a mesma imagem do topo do case (o vídeo está no meio da
* rolagem do site). O nome não troca de lugar numa atualização: o React só esquece um nome quando o
* <ViewTransition> sai da árvore, e um nome trocado no lugar ficava preso ao projeto antigo (aviso de nome
* repetido ao abrir a capa dele depois). Por isso o projeto que entra em cena ganha um <ViewTransition> novo,
* e só a imagem remonta (o vídeo fica).
*/
function ActiveCover({
id,
active,
children,
}: {
id: string;
active: boolean;
children: React.ReactNode;
}) {
if (!active) return children;
return (
<ViewTransition {...coverTransitionProps(id)}>{children}</ViewTransition>
);
}
const pad = (value: number) => String(value).padStart(2, "0");
/**
@@ -643,7 +671,7 @@ function ReelCaption({
return (
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
<p className="ds-kicker">
{project.category} · {project.status}
{projectKicker(project.category, project.status)}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
@@ -14,7 +14,8 @@ jest.mock("./ProjectLightboxLazy", () => ({
/**
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente", o botão de ampliar e o pôster como
* elemento compartilhado da troca de página.
*/
jest.mock("./ProjectLightbox", () => ({
@@ -25,6 +26,11 @@ jest.mock("./ProjectLightbox", () => ({
),
}));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
type Callback = IntersectionObserverCallback;
/** Cada observador criado, com o elemento que observa. */
const observers: Array<{ callback: Callback; target?: Element }> = [];
@@ -114,6 +120,19 @@ describe("ProjectList", () => {
expect(images[0]).toHaveAttribute("sizes", "50vw");
});
it("path: poster near the window -> shared element named after the case (case-<id>), one name per item", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
const names = [...container.querySelectorAll("img")].map((image) =>
image.parentElement?.getAttribute("data-vt-name"),
);
expect(names).toEqual(items.map(({ config }) => `case-${config.id}`));
const transition = container.querySelector("img")?.parentElement;
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
const { container } = renderList({ device: "mobile" });
bringAllNear(container);
+20 -11
View File
@@ -8,8 +8,14 @@ import {
useId,
useRef,
useState,
ViewTransition,
} from "react";
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
import {
type Device,
getShowcaseAssets,
projectKicker,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { ProjectActions } from "./ProjectActions";
@@ -78,17 +84,20 @@ function ListItem({
const ref = useRef<HTMLLIElement>(null);
const near = useNearViewport(ref);
const assets = device ? getShowcaseAssets(config.id, device) : null;
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela. É a mesma capa do topo do
// case: viaja até lá na troca de página (o item tem uma moldura só, então o nome não se repete)
const poster =
assets && near ? (
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
sizes={device === "mobile" ? "160px" : "50vw"}
/>
<ViewTransition {...coverTransitionProps(config.id)}>
<Image
src={assets.poster}
alt={`Captura de tela de ${project.title}`}
width={assets.width}
height={assets.height}
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
sizes={device === "mobile" ? "160px" : "50vw"}
/>
</ViewTransition>
) : null;
return (
@@ -111,7 +120,7 @@ function ListItem({
)}
<div className="ds-reel-caption">
<p className="ds-kicker">
{project.category} · {project.status}
{projectKicker(project.category, project.status)}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
@@ -24,6 +24,11 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
const PIN = 800;
const CONTAINER = 7000;
const SCROLLABLE = CONTAINER - PIN;
@@ -313,6 +318,25 @@ describe("ProjectReel", () => {
);
});
it("gives the cover name (case-<id>) to the laptop poster of the project on screen only, without remounting its video", () => {
renderReel();
const selector = ".ds-reel-layer [data-vt-name]";
expect(document.querySelectorAll(selector)).toHaveLength(1);
expect(
activeLayer().querySelector(".ds-reel-screen [data-vt-name] > img")
?.parentElement,
).toHaveAttribute("data-vt-name", "case-sev-modaintima");
const video = document.querySelectorAll(".ds-reel-layer video")[1];
scrollTo(reelOffset(weights, 1, 0.5));
expect(document.querySelectorAll(selector)).toHaveLength(1);
const named = activeLayer().querySelector(selector);
expect(named).toHaveAttribute("data-vt-name", "case-lavacao-047");
expect(named).toHaveAttribute("data-vt-share", "case-cover");
expect(document.querySelectorAll(".ds-reel-layer video")[1]).toBe(video);
});
it("names the chapter on screen and counts them", () => {
renderReel();
readyVideos();
+26 -8
View File
@@ -25,12 +25,24 @@ html.ds-lb-open {
background: rgba(5, 5, 5, 0.88);
backdrop-filter: blur(8px);
overscroll-behavior: contain;
opacity: 0;
transition:
opacity 180ms var(--ease-in),
overlay 180ms allow-discrete,
display 180ms allow-discrete;
&[open] {
display: flex;
align-items: center;
justify-content: center;
animation: ds-lb-in 0.28s var(--ease-out);
opacity: 1;
transition-duration: 280ms;
transition-timing-function: var(--ease-out);
@starting-style {
/* biome-ignore lint/suspicious/noDuplicateProperties: dentro de @starting-style é o estado inicial da entrada, não uma repetição */
opacity: 0;
}
}
&::backdrop {
@@ -38,15 +50,10 @@ html.ds-lb-open {
}
}
@keyframes ds-lb-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-lb,
.ds-lb[open] {
animation: none;
transition: none;
}
}
@@ -326,12 +333,23 @@ html.ds-lb-open {
gap: var(--sp-2) var(--sp-3);
& .ds-link.ds-link-muted {
--block: rgba(255, 255, 255, 0.12);
padding: 8px 16px;
color: #fff;
border-color: rgba(255, 255, 255, 0.38);
background: transparent;
}
& .ds-link.ds-link-muted:focus-visible {
border-color: rgba(255, 255, 255, 0.55);
}
@media (hover: hover) {
& .ds-link.ds-link-muted:hover {
border-color: rgba(255, 255, 255, 0.55);
}
}
& .ds-link.ds-link-text {
color: rgba(255, 255, 255, 0.84);
}
+28 -3
View File
@@ -87,9 +87,8 @@
gap: 6px 10px;
margin-bottom: var(--sp-5);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--slash);
}
@@ -190,6 +189,32 @@
opacity: 1;
}
/* Projeto fora de cena, sob o ponteiro ou no foco: uma barra verde entra pela esquerda, no lugar onde o projeto em
cena mostra o progresso (.ds-reel-bar). Com movimento reduzido a vitrine fixa nem existe (reel-list.css). */
.ds-reel-pick:not([aria-current="true"])::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--slash);
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
pointer-events: none;
}
.ds-reel-pick:not([aria-current="true"]):focus-visible::before {
transform: none;
}
@media (hover: hover) {
.ds-reel-pick:not([aria-current="true"]):hover::before {
transform: none;
}
}
@media (min-width: 640px) {
.ds-reel-name {
display: inline;
+8 -3
View File
@@ -81,7 +81,8 @@
overflow: hidden;
}
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. */
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. No
ponteiro e no foco, o bloco translúcido dos contornos em fundo escuro (globals.css). */
.ds-reel-actions {
display: flex;
flex-wrap: wrap;
@@ -90,16 +91,20 @@
margin-top: var(--sp-2);
& .ds-link.ds-link-muted {
--block: rgba(255, 255, 255, 0.12);
padding: 8px 16px;
color: #fff;
border-color: rgba(255, 255, 255, 0.38);
background: transparent;
}
& .ds-link.ds-link-muted:focus-visible {
border-color: rgba(255, 255, 255, 0.55);
}
@media (hover: hover) {
& .ds-link.ds-link-muted:hover {
border-color: #fff;
box-shadow: none;
border-color: rgba(255, 255, 255, 0.55);
}
}
+28 -8
View File
@@ -1,4 +1,8 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import { trackCvDownload } from "@/lib/analytics";
import { buttonLabels } from "@/lib/site-content";
import CurriculoPage, { metadata } from "./page";
@@ -46,7 +50,8 @@ it("shows a matching breadcrumb and keeps consulting secondary to hiring", () =>
const hiringLink = screen.getByRole("link", { name: buttonLabels.contact });
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
expect(hiringLink).toHaveAttribute("href", "/#contact");
// "Falar sobre meu projeto" leva sempre ao formulário de contato da home
expect(hiringLink).toHaveAttribute("href", "/#contact-form");
expect(consultingLink).toHaveAttribute("href", "/servicos");
expect(
hiringLink.compareDocumentPosition(consultingLink) &
@@ -70,22 +75,28 @@ it("offers the résumé PDF as a secondary action and measures the download", ()
const pdf = screen.getByRole("link", { name: "Baixar currículo em PDF" });
expect(pdf).toHaveAttribute("href", "/Profile.pdf");
expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
// limão só para o contato
expect(pdf).not.toHaveClass("ds-link-accent");
expect(screen.getByRole("link", { name: buttonLabels.contact })).toHaveClass(
"ds-link-accent",
);
// limão só para o contato: as classes de botão de button-styles.ts
expect(pdf.className).toBe(secondaryButtonDark);
expect(
screen.getByRole("link", { name: buttonLabels.contact }).className,
).toBe(primaryButton);
fireEvent.click(pdf);
expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" });
});
it("opens with a lower one-column strip, the location line and spaced achievement lists", () => {
it("opens with a lower strip, the block piece, the location line, no eyebrow and spaced achievement lists", () => {
const { container } = render(CurriculoPage());
const hero = container.querySelector("section");
expect(hero?.className).toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.querySelector(".ds-eyebrow")).toBeNull();
const image = hero?.querySelector("img");
expect(image?.getAttribute("src")).toContain("estado-integracoes.webp");
expect(image?.closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
expect(hero).toHaveTextContent(
"Garuva e Joinville (SC) · atendimento remoto em todo o Brasil",
);
@@ -95,3 +106,12 @@ it("opens with a lower one-column strip, the location line and spaced achievemen
expect(list).toHaveClass("space-y-sp-2");
}
});
it("keeps the skill tags right under each card title, with no gap pushed in by .ds-tags", () => {
const { container } = render(CurriculoPage());
const tags = container.querySelectorAll("article.ds-card ul.ds-tags");
expect(tags.length).toBeGreaterThan(0);
// .ds-tags (globals.css) tem margin-top: auto, que abria um buraco no cartão esticado pela grade
for (const list of tags) expect(list.className).toMatch(/!mt-sp-/);
});
+45 -36
View File
@@ -1,8 +1,18 @@
import type { Metadata } from "next";
import { compactHero } from "@/app/cases/_components/hero-styles";
import {
compactHero,
heroWithMediaGrid,
} from "@/app/cases/_components/hero-styles";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import {
pageTop,
primaryButton,
secondaryButtonDark,
textLinkDark,
} from "@/app/components/button-styles";
import { CONTACT_FORM_HASH } from "@/app/components/contact-subject";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { PagePiece } from "@/app/components/PagePiece";
import { safeJsonLd } from "@/lib/jsonld";
import { portfolio } from "@/lib/portfolio-data";
import {
@@ -47,39 +57,38 @@ export default function CurriculoPage() {
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Currículo", href: "/curriculo" },
]}
/>
<p className="ds-eyebrow">Currículo profissional</p>
<h1 className="ds-section-title text-white">
Engenheiro da Computação e Desenvolvedor de Software.
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil. Este
currículo reúne experiência profissional, formação e competências
técnicas para avaliação de contratação.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<HoverPrefetchLink
className="ds-link ds-link-accent"
href="/#contact"
>
{buttonLabels.contact}
</HoverPrefetchLink>
<CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF
</CvDownloadLink>
<HoverPrefetchLink
className="ds-link ds-link-text"
href="/servicos"
>
Procurando consultoria? Ver serviços
</HoverPrefetchLink>
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
<div className="min-w-0">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Currículo", href: "/curriculo" },
]}
/>
<h1 className="ds-section-title text-white">
Engenheiro da Computação e Desenvolvedor de Software.
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil.
Este currículo reúne experiência profissional, formação e
competências técnicas para avaliação de contratação.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
className={primaryButton}
href={`/${CONTACT_FORM_HASH}`}
>
{buttonLabels.contact}
</HoverPrefetchLink>
<CvDownloadLink className={secondaryButtonDark}>
Baixar currículo em PDF
</CvDownloadLink>
<HoverPrefetchLink className={textLinkDark} href="/servicos">
Procurando consultoria? Ver serviços
</HoverPrefetchLink>
</div>
</div>
<PagePiece image="/3d/estado-integracoes.webp" />
</div>
</section>
@@ -101,7 +110,7 @@ export default function CurriculoPage() {
<li key={achievement}>{achievement}</li>
))}
</ul>
<ul className="ds-tags mt-sp-6">
<ul className="ds-tags !mt-sp-6">
{experience.technologies.map((technology) => (
<li key={technology} className="ds-tag">
{technology}
@@ -121,7 +130,7 @@ export default function CurriculoPage() {
{portfolio.skills.technical.map((category) => (
<article key={category.category} className="ds-card">
<h3 className="ds-card-title">{category.category}</h3>
<ul className="ds-tags mt-sp-5">
<ul className="ds-tags !mt-sp-5">
{category.skills.map((skill) => (
<li key={skill} className="ds-tag">
{skill}
+516 -115
View File
@@ -32,6 +32,9 @@
--white: #ffffff;
--slash: #ccff00;
--slash-dim: rgba(204, 255, 0, 0.16);
/* verde da marca WhatsApp: só nos controles que abrem o WhatsApp */
--whatsapp: #25d366;
--whatsapp-hover: #54e58b;
--meta: #4a4a4a;
--border: rgba(10, 10, 10, 0.1);
--border-light: rgba(10, 10, 10, 0.06);
@@ -56,8 +59,8 @@
--shell: clamp(1240px, 76vw, 1760px);
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
--fs-lead: min(max(15px, 1.05vw), 19px);
/* Piso do texto corrido em qualquer tela. Só os rótulos mono curtos (.ds-eyebrow, .ds-kicker, .ds-tag,
.ds-metric) descem a 12 px. */
/* Piso do texto corrido em qualquer tela. Os rótulos mono curtos ficam abaixo: .ds-eyebrow e .ds-kicker em 13 px,
.ds-tag e .ds-metric em 12 px. */
--fs-body: 15px;
/* Dois pesos de título, e só: seção (h1 e h2) e cartão (h3 e menores). */
--fw-section: 700;
@@ -312,12 +315,11 @@ textarea {
}
}
/* rótulos curtos em mono: 12 px no mínimo e espaçamento moderado */
/* rótulos curtos em mono: 13 px, sem caixa alta, espaçamento leve */
.ds-eyebrow,
.ds-kicker {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--meta);
}
@@ -433,14 +435,6 @@ textarea {
line-height: 1.25;
letter-spacing: 0;
text-transform: none;
transition:
transform 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
}
.ds-link:hover {
transform: translateY(-1px);
}
.ds-link-accent {
@@ -448,43 +442,12 @@ textarea {
background: var(--slash);
padding: 12px 18px;
font-weight: 600;
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
background 0.25s ease;
}
.ds-link-accent:hover {
background: #ffffff;
box-shadow:
0 0 20px rgba(204, 255, 0, 0.45),
0 0 48px rgba(204, 255, 0, 0.18);
}
.ds-section-dark .ds-link-accent:hover {
box-shadow:
0 0 24px rgba(204, 255, 0, 0.55),
0 0 60px rgba(204, 255, 0, 0.22);
}
/* contorno: ação secundária (sem lima) */
.ds-link-muted {
padding: 12px 18px;
border: 1px solid rgba(10, 10, 10, 0.28);
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.ds-link-muted:hover {
border-color: var(--ink-40);
box-shadow: 0 4px 20px rgba(10, 10, 10, 0.12);
}
.ds-section-dark .ds-link-muted:hover {
border-color: rgba(255, 255, 255, 0.7);
box-shadow: none;
}
/* link de texto: ação terciária, sublinhada, sem caixa */
@@ -495,9 +458,160 @@ textarea {
text-underline-offset: 5px;
}
.ds-link-text:hover {
/*
* Resposta ao ponteiro e ao foco: um tratamento por papel, entrando pela esquerda em 280 ms (DESIGN.md). Só
* `transform` anima; cor e borda trocam na hora. Com movimento reduzido a troca inteira é instantânea.
*
* Bloco (botão verde e de contorno): o ::before cobre o botão da esquerda para a direita, entre o fundo e o texto
* (`isolation` faz o z-index -1 ficar dentro do botão). Verde: bloco cor de papel, texto escuro. Contorno no fundo
* escuro: branco a 12%. Contorno no fundo claro: bloco escuro e o texto inverte.
*/
:is(
.cv-fill,
.cv-outline-dark,
.cv-outline-light,
.ds-link-accent,
.ds-link-muted
) {
position: relative;
isolation: isolate;
}
:is(
.cv-fill,
.cv-outline-dark,
.cv-outline-light,
.ds-link-accent,
.ds-link-muted
)::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--block, var(--paper));
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
pointer-events: none;
}
.cv-outline-light,
.ds-link-muted {
--block: var(--ink);
}
.cv-outline-dark,
.ds-section-dark .ds-link-muted {
--block: rgba(255, 255, 255, 0.12);
}
/* Link de texto e item do menu: uma barra de 2 px cresce da esquerda sob o texto. --bar-x recua as pontas (o padding
do item), --bar-y põe a barra na altura do sublinhado. */
:is(.cv-bar, .ds-link-text) {
position: relative;
}
:is(.cv-bar, .ds-link-text)::after {
content: "";
position: absolute;
left: var(--bar-x, 0);
right: var(--bar-x, 0);
top: var(--bar-y, calc(50% + 0.62em));
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: left;
transition: transform 280ms var(--ease-out);
pointer-events: none;
}
:is(
.cv-fill,
.cv-outline-dark,
.cv-outline-light,
.ds-link-accent,
.ds-link-muted
):focus-visible::before,
:is(.cv-bar, .ds-link-text):focus-visible::after {
transform: none;
text-decoration-thickness: 2px;
}
.cv-outline-light:focus-visible,
.ds-link-muted:focus-visible {
border-color: var(--ink);
color: var(--paper);
}
.cv-outline-dark:focus-visible,
.ds-section-dark .ds-link-muted:focus-visible {
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
/* só com mouse: no toque o :hover fica preso depois do toque */
@media (hover: hover) {
:is(
.cv-fill,
.cv-outline-dark,
.cv-outline-light,
.ds-link-accent,
.ds-link-muted
):hover::before,
:is(.cv-bar, .ds-link-text):hover::after {
transform: none;
}
.cv-outline-light:hover,
.ds-link-muted:hover {
border-color: var(--ink);
color: var(--paper);
}
.cv-outline-dark:hover,
.ds-section-dark .ds-link-muted:hover {
border-color: rgba(255, 255, 255, 0.55);
color: #fff;
}
}
/* Botão verde sobre fundo claro (seções e cartões claros): o bloco cor de papel sumia no branco e o botão parecia
desativado. Ali o bloco é escuro e o texto inverte, como no botão de contorno claro. */
.ds-section:not(.ds-section-dark) :is(.cv-fill, .ds-link-accent) {
--block: var(--ink);
}
.ds-section:not(.ds-section-dark) :is(.cv-fill, .ds-link-accent):focus-visible {
color: var(--paper);
}
@media (hover: hover) {
.ds-section:not(.ds-section-dark) :is(.cv-fill, .ds-link-accent):hover {
color: var(--paper);
}
}
/* botão verde: ao pressionar, encolhe um pouco */
:is(.cv-fill, .ds-link-accent) {
transition: scale 0.15s ease-out;
}
:is(.cv-fill, .ds-link-accent):active {
scale: 0.98;
}
@media (prefers-reduced-motion: reduce) {
:is(
.cv-fill,
.cv-outline-dark,
.cv-outline-light,
.ds-link-accent,
.ds-link-muted
)::before,
:is(.cv-bar, .ds-link-text)::after,
:is(.cv-fill, .ds-link-accent) {
transition: none;
}
}
/* O preflight do Tailwind zera o list-style de ul/ol: o marcador volta aqui. Na cor do texto (o lima sobre o
@@ -651,10 +765,11 @@ ol.ds-list {
}
/* Hero: palco de 100svh; com a cena 3D (data-dive, mais abaixo) ele fica fixo enquanto a rolagem conduz o mergulho.
--hero-p (0 a 1) e --hero-open (0 a 1: a máscara do fundo se abrindo) vêm de cinematic/HeroField.tsx. */
--hero-p (0 a 1) vem de cinematic/HeroField.tsx. */
.hero {
--hero-p: 0;
--hero-open: 0;
/* lado da peça (a marca em blocos) abaixo de 1024 px, onde ela divide a linha com o rótulo */
--hero-piece: clamp(112px, 30vw, 200px);
isolation: isolate;
}
@@ -674,54 +789,127 @@ ol.ds-list {
}
}
/* Máscara dos fundos ambientes do hero (cena 3D e malha estática): transparente no miolo, onde ficam o título
e as molduras da direita, e opaca nas bordas e cantos. Com o mergulho (--hero-open 0 → 1) o miolo some e o
fundo aparece inteiro, quando o texto e a coluna da direita já saíram de cena. */
.hero-glow::after,
.hero-field {
--clear: calc((1 - var(--hero-open)) * 52% - 1%);
--feather: calc((1 - var(--hero-open)) * 30%);
mask-image: radial-gradient(
ellipse at 46% 50%,
transparent var(--clear),
#000 calc(var(--clear) + var(--feather) + 1%)
);
}
/* Sem WebGL, com aparelho que não aguenta a cena, ou antes de ela carregar: malha de pontos estática, leve */
.hero-glow::after {
content: "";
/* Peça do hero: a marca em blocos renderizada no Blender (3d/blocos.py). É o primeiro quadro em qualquer aparelho; onde
a cena 3D roda, o canvas assume no mesmo lugar. Abaixo de 1024 px ela fica no canto, na linha do rótulo (que ganha a
altura dela); de 1024 px em diante fica logo à direita da coluna do texto. `translate`, e não `transform`, porque a
entrada (.hero-in) anima o `transform`. */
.hero-piece {
position: absolute;
inset: 0;
background:
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
0 / 22px 22px;
opacity: 0.6;
transition: opacity 1.2s var(--ease-out);
top: calc(var(--site-header-height) + clamp(8px, 2.5svh, 32px));
right: var(--sp-6);
width: var(--hero-piece);
pointer-events: none;
}
.hero:has(.hero-field[data-ready]) .hero-glow::after {
opacity: 0;
.hero-kicker {
min-height: var(--hero-piece);
padding-right: calc(var(--hero-piece) + var(--sp-3));
}
/* Fundo ambiente: a cena 3D fica com pouca opacidade (sobe para a opacidade cheia só no mergulho). O
desenho entra por animação (e não por transition) para a opacidade poder seguir a rolagem sem atraso. */
@media (min-width: 1024px) {
/* o palco é a referência de largura (cqw) do conteúdo, da coluna do texto e da peça */
.hero-stage {
container-type: inline-size;
}
.hero {
/* A mesma coluna do .ds-section-shell das outras seções: o padding de .ds-section ou a sobra de --shell, o que for
maior. */
--hero-gutter: max(clamp(24px, 6vw, 64px), (100cqw - var(--shell)) / 2);
/* 20% maior que o min(46%, 680px) de antes */
--hero-piece: min(55.2cqw, 816px);
/* folga entre o fim da coluna do texto e o desenho da peça */
--hero-gap: 40px;
/* O texto ocupa o que sobra à esquerda do desenho da peça (65% da largura do pôster), menos a folga. */
--hero-col-w: calc(
100cqw -
2 *
var(--hero-gutter) -
0.65 *
var(--hero-piece) -
var(--hero-gap)
);
}
.hero-content,
.hero-foot {
max-width: none;
padding-inline: var(--hero-gutter);
}
.hero-col {
max-width: var(--hero-col-w);
}
/* O desenho ocupa de 16,3% a 81,3% da largura do pôster (o resto é transparente): a borda direita do desenho fica na
borda direita da coluna das seções, e a sobra transparente passa para a margem (o palco a corta). -49%, e não
-52%: em 1280x720 o topo dos blocos fica a uns 20 px do cabeçalho, em vez de encostar nele. */
.hero-piece {
top: 50%;
right: calc(var(--hero-gutter) - 0.187 * var(--hero-piece));
width: var(--hero-piece);
translate: 0 -49%;
}
/* Telas baixas (1280x720, 1024x768): os espaços entre os blocos do texto encolhem com a altura, para o palco fixo
caber inteiro. Em 900 px de altura ou mais ficam como no celular. */
.hero-kicker {
min-height: 0;
padding-right: 0;
margin-bottom: clamp(8px, 1.6svh, 16px);
}
/* O título acompanha a coluna: a primeira linha, "Sites e sistemas sob medida" (12,4em, e "sob medida" não quebra),
cabe inteira nela, e o título fica em 3 linhas. */
.hero-title {
font-size: clamp(
34px,
min(8.4vw, 5.2svh + 14px, var(--hero-col-w) / 12.6),
84px
);
}
.hero-title + p {
margin-top: clamp(8px, 1.6svh, 16px);
}
.hero-actions {
margin-top: clamp(12px, 2.2svh, 20px);
}
.hero-actions ~ .hero-col {
margin-top: clamp(8px, 1.4svh, 12px);
}
}
/* De 1280 px em diante a linha dos botões pode passar da coluna até 24 px do desenho da peça: assim os dois botões e o
link do WhatsApp ficam numa linha só onde couberem (1440 px sim; 1280 px não, e o link desce como antes). */
@media (min-width: 1280px) {
.hero-actions {
max-width: calc(var(--hero-col-w) + var(--hero-gap) - 24px);
}
}
/* Cena 3D do hero: o canvas cobre o palco (nível full, com o mergulho) ou só a caixa da peça (nível lite, por estilo em
linha de HeroField) e entra com fade por cima do pôster, que some quando a cena está pronta. Se a cena desiste, o
pôster volta. */
.hero-field {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.6s var(--ease-out);
&[data-ready] {
opacity: calc(0.5 + 0.5 * var(--hero-open));
animation: hero-field-in 1.6s var(--ease-out) both;
opacity: 1;
}
}
@keyframes hero-field-in {
from {
opacity: 0;
}
/* O pôster some por `visibility`, depois que o canvas terminou de entrar por baixo dele: mudar a opacidade da imagem a
repintava, e o navegador contava essa repintura como um novo maior elemento da dobra (LCP) aos 2,3 s. */
.hero:has(.hero-field[data-ready]) .hero-piece img {
visibility: hidden;
transition: visibility 0s linear 0.6s;
}
.hero-scrim {
@@ -747,15 +935,16 @@ ol.ds-list {
}
/* Entrada: as palavras do título já pintam no primeiro quadro (sem opacity:0, bom para o LCP) e sobem em cascata
(só deslocamento: desfocar texto grande a cada quadro obriga a redesenhá-lo) */
(só deslocamento: desfocar texto grande a cada quadro obriga a redesenhá-lo). Título, apoio, botões, primeiro passo,
faixa de clientes e rodapé do hero assentam em até 1,5 s depois da carga. */
.hero-title {
font-weight: var(--fw-section);
}
.hero-word {
display: inline-block;
animation: hero-word 1.1s var(--ease-out) both;
animation-delay: calc(0.12s + var(--i) * 45ms);
animation: hero-word 0.85s var(--ease-out) both;
animation-delay: calc(0.06s + var(--i) * 35ms);
}
@keyframes hero-word {
@@ -764,14 +953,39 @@ ol.ds-list {
}
}
/* As palavras em verde entram sob um bloco da mesma cor, que recolhe para a direita; o texto já está pintado
por baixo desde o primeiro quadro. */
@media (prefers-reduced-motion: no-preference) {
.hero-mark {
position: relative;
}
.hero-mark::after {
content: "";
position: absolute;
inset: 0.08em -0.04em 0.02em;
background: var(--slash);
transform: scaleX(0);
transform-origin: right;
animation: hero-mark 0.45s var(--ease-in) both;
animation-delay: calc(0.35s + var(--i) * 60ms);
}
}
@keyframes hero-mark {
from {
transform: scaleX(1);
}
}
.hero-in,
.hero-foot > * {
animation: hero-in 1s var(--ease-out) both;
animation: hero-in 0.8s var(--ease-out) both;
animation-delay: var(--d, 0.05s);
}
.hero-foot > * {
--d: 0.85s;
--d: 0.66s;
}
@keyframes hero-in {
@@ -1005,10 +1219,10 @@ html.lenis {
0 0 1px rgba(204, 255, 0, 0.1);
}
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): a página atual
recua e some, a nova chega de baixo. Sem desfoque e sem sobreposição (a nova só começa quando a antiga acabou de
sumir), e o fundo (html e body) fica escuro (--ink) enquanto dura a troca: cada página some e vem de um fundo só,
nunca de uma mistura de creme com preto. O cabeçalho tem nome próprio e fica parado durante a troca. */
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): cortina de blocos. A página
que sai fica parada por baixo e a que entra aparece por uma máscara em degraus de blocos quadrados, da esquerda
embaixo para a direita em cima (a diagonal do símbolo). O fundo (html e body) fica escuro (--ink) enquanto dura a
troca, e o cabeçalho tem nome próprio e fica parado por cima. */
/* biome-ignore lint/correctness/noUnknownPseudoClass: :active-view-transition é do CSS de transições de visualização (Chrome 134+); Biome ainda não o conhece */
html:active-view-transition {
background: var(--ink);
@@ -1029,43 +1243,119 @@ html:active-view-transition body {
z-index: 100;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-group(page) {
/* --page-wipe vai de 0 (nada da página nova à vista) a 1 (tudo). Registrada, ela anima como número; o valor
inicial 1 deixa a página nova inteira onde a animação não roda */
@property --page-wipe {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
/* A classe "page" vem do `default` do ViewTransition (template.tsx); a página que sai e a que entra têm nomes
próprios, cada uma no lugar em que estava na tela */
::view-transition-old(.page) {
animation: none;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-old(page) {
animation: page-out 240ms cubic-bezier(0.7, 0, 0.84, 0) both;
/* 8 colunas de 12,5% da largura, cada uma crescendo do pé da tela para cima em blocos da largura da coluna
(round(down, …, 12.5vw)), uma coluna um bloco atrás da outra: a borda é uma escada na diagonal. A imagem da
página nova mede a página inteira e começa no topo da tela (o Next a abre rolada para cima), por isso a
altura se conta em vh a partir do pé da tela. Com 100vw + 100vh de caminho, a última coluna passa da altura
da tela em 1. Sem round() ou sem máscara, a declaração não vale e a página nova aparece inteira na hora. Cada
coluna tem 1px a mais de largura para não sobrar fresta entre colunas vizinhas. */
::view-transition-new(.page) {
--page-step: 12.5vw;
--page-run: calc(var(--page-wipe) * (100vh + 100vw));
--c0: max(0px, round(down, var(--page-run), var(--page-step)));
--c1: max(
0px,
round(down, var(--page-run) - var(--page-step), var(--page-step))
);
--c2: max(
0px,
round(down, var(--page-run) - 2 * var(--page-step), var(--page-step))
);
--c3: max(
0px,
round(down, var(--page-run) - 3 * var(--page-step), var(--page-step))
);
--c4: max(
0px,
round(down, var(--page-run) - 4 * var(--page-step), var(--page-step))
);
--c5: max(
0px,
round(down, var(--page-run) - 5 * var(--page-step), var(--page-step))
);
--c6: max(
0px,
round(down, var(--page-run) - 6 * var(--page-step), var(--page-step))
);
--c7: max(
0px,
round(down, var(--page-run) - 7 * var(--page-step), var(--page-step))
);
animation: page-wipe 600ms linear both;
mask-image:
linear-gradient(#000, #000), linear-gradient(#000, #000),
linear-gradient(#000, #000), linear-gradient(#000, #000),
linear-gradient(#000, #000), linear-gradient(#000, #000),
linear-gradient(#000, #000), linear-gradient(#000, #000);
mask-repeat: no-repeat;
mask-size:
calc(12.5% + 1px) var(--c0),
calc(12.5% + 1px) var(--c1),
calc(12.5% + 1px) var(--c2),
calc(12.5% + 1px) var(--c3),
calc(12.5% + 1px) var(--c4),
calc(12.5% + 1px) var(--c5),
calc(12.5% + 1px) var(--c6),
calc(12.5% + 1px) var(--c7);
mask-position:
0% calc(100vh - var(--c0)),
calc(100% / 7) calc(100vh - var(--c1)),
calc(200% / 7) calc(100vh - var(--c2)),
calc(300% / 7) calc(100vh - var(--c3)),
calc(400% / 7) calc(100vh - var(--c4)),
calc(500% / 7) calc(100vh - var(--c5)),
calc(600% / 7) calc(100vh - var(--c6)),
100% calc(100vh - var(--c7));
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-new(page) {
animation: page-in 600ms var(--ease-out) 240ms both;
}
@keyframes page-out {
to {
opacity: 0;
transform: scale(0.97);
}
}
@keyframes page-in {
@keyframes page-wipe {
from {
opacity: 0;
transform: translateY(40px) scale(1.015);
--page-wipe: 0;
}
to {
--page-wipe: 1;
}
}
/* Capa do projeto (lista da home ou de cases -> topo do case): viaja por cima da cortina e por baixo do cabeçalho */
::view-transition-group(.case-cover) {
animation-duration: 500ms;
animation-timing-function: var(--ease-out);
z-index: 50;
}
/* o pôster do celular (alto) e o do computador (largo) preenchem a mesma caixa, presos no topo, sem vazar por baixo */
::view-transition-old(.case-cover),
::view-transition-new(.case-cover) {
height: 100%;
object-fit: cover;
object-position: top;
overflow: clip;
}
@media (prefers-reduced-motion: reduce) {
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-old(page) {
/* nada anima, nem o esmaecer padrão do navegador: a troca é instantânea */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
::view-transition-new(page) {
::view-transition-new(.page) {
animation: none;
}
}
@@ -1124,6 +1414,38 @@ html:active-view-transition body {
transform: none;
}
/* Rótulo digitado: o texto se revela em passos, um por caractere, com um cursor de bloco na ponta, que pisca
três vezes e some. Só clip-path, translate e opacity. */
.ds-type {
position: relative;
width: fit-content;
max-width: 100%;
clip-path: inset(0 100% 0 0);
}
.ds-type::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(currentColor, currentColor) left / 1ch 100% no-repeat;
opacity: 0;
translate: -1ch 0;
}
.ds-section-header[data-shown] .ds-type {
animation: ds-type calc(var(--n) * 32ms) steps(var(--n))
var(--reveal-delay, 0s) forwards;
}
.ds-section-header[data-shown] .ds-type::after {
animation:
ds-type-caret calc(var(--n) * 32ms) steps(var(--n))
var(--reveal-delay, 0s) forwards,
ds-type-blink 0.9s steps(1)
calc(var(--reveal-delay, 0s) + var(--n) * 32ms) forwards;
}
.ds-lazy-reveal {
opacity: 0;
transform: translateY(20px) scale(1.02);
@@ -1138,6 +1460,37 @@ html:active-view-transition body {
}
}
@keyframes ds-type {
to {
clip-path: inset(0);
}
}
@keyframes ds-type-caret {
from {
opacity: 1;
translate: -1ch 0;
}
to {
opacity: 1;
translate: calc(100% - 1ch) 0;
}
}
@keyframes ds-type-blink {
0%,
34%,
67% {
opacity: 1;
}
17%,
50%,
83%,
100% {
opacity: 0;
}
}
/* ScrollProgress: barra do topo e trilho lateral ligados à rolagem da página, sem JavaScript */
@keyframes ds-progress-x {
from {
@@ -1214,3 +1567,51 @@ html:active-view-transition body {
display: none;
}
}
/* Entrada de componentes que o React monta ao abrir (menu do celular, barra de cookies, botão do WhatsApp): o estado
inicial vem de @starting-style, sem JavaScript. A saída só anima onde o elemento continua no DOM ao fechar: o
vídeo ampliado (lightbox.css) e as perguntas frequentes (abaixo). Sem suporte do navegador, a troca é instantânea.
A altura das perguntas frequentes é a única propriedade de leiaute animada no site. */
@media (prefers-reduced-motion: no-preference) {
.ds-enter {
transition:
opacity 280ms var(--ease-out),
translate 280ms var(--ease-out);
@starting-style {
opacity: 0;
translate: 0 var(--enter-y, 12px);
}
}
.ds-enter-pop {
transition:
opacity 280ms var(--ease-out),
scale 280ms var(--ease-out),
translate 200ms var(--ease-out);
@starting-style {
opacity: 0;
scale: 0.85;
}
}
.ds-faq-item {
interpolate-size: allow-keywords;
}
.ds-faq-item::details-content {
block-size: 0;
overflow-y: clip;
opacity: 0;
transition:
block-size 280ms var(--ease-out),
opacity 280ms var(--ease-out),
content-visibility 280ms allow-discrete;
}
.ds-faq-item[open]::details-content {
block-size: auto;
opacity: 1;
}
}
+9 -8
View File
@@ -2,26 +2,27 @@ import manifest from "./manifest";
import { alt as openGraphAlt } from "./opengraph-image";
/**
* A home se apresenta como "Sites e sistemas que trazem pedidos pelo WhatsApp e tiram a operação da planilha".
* O manifesto e a imagem de compartilhamento precisam dizer o mesmo, não o posicionamento antigo ("sistemas sob medida").
* A home se apresenta como "Sites e sistemas sob medida para o jeito que a sua empresa trabalha" (o título do hero).
* O manifesto e a imagem de compartilhamento dizem o mesmo, e não o posicionamento anterior, que falava só de pedido
* pelo WhatsApp e de planilha (os dois casos continuam nos cartões de oferta, não no posicionamento).
*/
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
describe("manifesto do site", () => {
it("fala de sites e sistemas, não do posicionamento antigo", () => {
it("fala de sites e sistemas sob medida, não do posicionamento anterior", () => {
const { name, description } = manifest();
expect(name).toMatch(/Sites e sistemas/);
expect(description).toMatch(/WhatsApp/);
expect(description).toMatch(/planilha/);
expect(`${name} ${description}`).not.toMatch(/sob medida/i);
expect(description).toMatch(
/^Sites e sistemas sob medida para o jeito que a sua empresa trabalha\./,
);
expect(description).not.toMatch(/WhatsApp|planilha/);
});
});
describe("imagem de compartilhamento da home", () => {
it("tem o texto alternativo do novo posicionamento", () => {
it("tem o texto alternativo de sites e sistemas", () => {
expect(openGraphAlt).toMatch(/Sites e sistemas/);
expect(openGraphAlt).not.toMatch(/sob medida/i);
});
});
+95 -8
View File
@@ -1,5 +1,7 @@
import { render, screen, within } from "@testing-library/react";
import { offerLabels } from "@/lib/offer-labels";
import { buttonLabels } from "@/lib/site-content";
import { IA_PAGE_LABEL } from "@/lib/site-navigation";
import AiPage, { metadata } from "./page";
it("uses a short search title: up to 44 characters, and 60 with the layout suffix", () => {
@@ -50,15 +52,15 @@ it("keeps the full AI content, with the cited incidents summarized only here", (
).toHaveAttribute("rel", "noopener noreferrer");
});
it("offers WhatsApp conversations about reviewing an AI-built system", () => {
it("offers WhatsApp conversations about reviewing an AI-built system, each one named 'Chamar no WhatsApp'", () => {
render(<AiPage />);
const links = screen
.getAllByRole("link")
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
expect(links.map((link) => link.textContent)).toEqual([
"Pedir a revisão do meu app",
"Avaliar meu projeto",
buttonLabels.whatsapp,
buttonLabels.whatsapp,
]);
for (const link of links) {
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
@@ -80,9 +82,10 @@ it("has breadcrumb navigation back to the home", () => {
expect(
within(breadcrumb).getByRole("link", { name: "Início" }),
).toHaveAttribute("href", "/");
expect(
within(breadcrumb).getByText(offerLabels.revisao.name),
).toHaveAttribute("aria-current", "page");
expect(within(breadcrumb).getByText(IA_PAGE_LABEL)).toHaveAttribute(
"aria-current",
"page",
);
});
it("presents the review as an offer: what the person receives, with no price or deadline", () => {
@@ -239,7 +242,86 @@ it("keeps the incident news collapsed in a <details>, closed by default", () =>
).toBeNull();
});
it("names the review like the offer on the home: the trail and the structured data use the offer name, not the search title", () => {
it("lists the five incidents from 2025 and 2026, with the new sources opening in a new tab", () => {
render(<AiPage />);
for (const title of [
"Lovable: dados expostos em aplicações criadas na plataforma",
"Replit: um agente apagou dados de uma aplicação",
"Base44: aplicações privadas com falha de acesso",
"Lovable, 2026: conversas e código de projetos públicos ficaram abertos",
"RedAccess, 2026: aplicações internas abertas na internet",
]) {
expect(screen.getByRole("heading", { name: title })).toBeInTheDocument();
}
expect(
screen.getByText(/São episódios de 2025 e 2026, não avaliações/),
).toBeInTheDocument();
for (const [name, href] of [
[
"Relato da Lovable (22/04/2026, em inglês)",
"https://lovable.dev/blog/our-response-to-the-april-2026-incident",
],
[
"Reportagem do Security Boulevard (07/05/2026, em inglês)",
"https://securityboulevard.com/2026/05/thousands-of-vibe-coded-apps-exposing-corporate-personal-data-redaccess/",
],
[
"Reportagem do Axios (em inglês)",
"https://www.axios.com/2026/05/07/loveable-replit-vibe-coding-privacy",
],
]) {
const link = screen.getByRole("link", { name });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toContain("noopener");
}
});
it("adds the incident deadline to the LGPD card and a third card on who answers for what the AI writes", () => {
render(<AiPage />);
const lgpd = screen
.getByRole("heading", { name: "E a LGPD?" })
.closest("article") as HTMLElement;
expect(lgpd).toHaveTextContent(
"a empresa precisa comunicar a ANPD e as pessoas afetadas em três dias úteis depois de saber do fato; agentes de pequeno porte têm o prazo em dobro.",
);
const card = screen
.getByRole("heading", { name: "Quem responde pelo que a IA escreve?" })
.closest("article") as HTMLElement;
expect(card).toHaveClass("ds-card");
expect(card).toHaveTextContent("quem assina responde pelo que entrega.");
// os três cartões dividem a mesma grade
expect(card.parentElement).toBe(lgpd.parentElement);
expect(card.parentElement?.querySelectorAll("article.ds-card")).toHaveLength(
3,
);
expect(card.parentElement).toHaveClass("lg:grid-cols-3");
// alinhados pelo topo: o .ds-copy (flex-grow: 1) não estica o cartão mais curto com um buraco no meio
expect(card.parentElement).toHaveClass("items-start");
for (const [scope, name, href] of [
[
lgpd,
"ANPD: Resolução nº 15/2024, arts. 6º e 9º",
"https://www.in.gov.br/en/web/dou/-/resolucao-cd/anpd-n-15-de-24-de-abril-de-2024-556243024",
],
[
card,
"TST: notícia de 26/03/2026",
"https://www.tst.jus.br/-/empresa-e-advogado-sao-multados-por-uso-de-ia-com-decisoes-falsas",
],
] as const) {
const link = within(scope).getByRole("link", { name });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link.getAttribute("rel")).toContain("noopener");
}
});
it("names the page with its own label in the trail and the structured data (the one in the menu and the footer), and keeps the offer name for the offer", () => {
const { container } = render(<AiPage />);
const script = container.querySelector('script[type="application/ld+json"]');
@@ -249,6 +331,11 @@ it("names the review like the offer on the home: the trail and the structured da
);
expect(
breadcrumb.itemListElement.map(({ name }: { name: string }) => name),
).toEqual(["Início", offerLabels.revisao.name]);
).toEqual(["Início", "Revisão de app com IA"]);
expect(IA_PAGE_LABEL).toBe("Revisão de app com IA");
expect(offerLabels.revisao.name).toBe("Revisão de sistema que já existe");
// sem rótulo sobre o título: a trilha logo acima já nomeia a página
expect(
container.querySelector("section")?.querySelector(".ds-eyebrow"),
).toBeNull();
});
+82 -14
View File
@@ -8,15 +8,16 @@ import {
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { offerLabels } from "@/lib/offer-labels";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
import { buttonLabels, siteUrl } from "@/lib/site-content";
import { IA_PAGE_LABEL } from "@/lib/site-navigation";
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
const pageUrl = `${siteUrl}/ia`;
/**
* Título para busca: até 44 caracteres, e 60 com o " | Evandro.dev.br" que o template de app/layout.tsx acrescenta. Cobre a oferta
* (revisão de sistema) e a busca por app feito com IA. O nome da oferta, o mesmo da home, é o da trilha e dos dados estruturados.
* (revisão de sistema) e a busca por app feito com IA. A trilha e os dados estruturados usam o rótulo da página no menu e no
* rodapé (`IA_PAGE_LABEL`); a oferta, nos cartões e no formulário, continua com o nome dela.
*/
const pageTitle = "Revisão de sistema e de app feito com IA";
const pageDescription =
@@ -121,7 +122,7 @@ const RESPONSIBILITIES = [
},
];
/** Três episódios de 2025, resumidos. Cada um traz o que foi relatado, o que não foi provado e o cuidado técnico. */
/** Episódios de 2025 e 2026, resumidos. Cada um traz o que foi relatado, o que não foi provado e o cuidado técnico. */
const INCIDENTS = [
{
title: "Lovable: dados expostos em aplicações criadas na plataforma",
@@ -163,6 +164,35 @@ const INCIDENTS = [
},
],
},
{
title:
"Lovable, 2026: conversas e código de projetos públicos ficaram abertos",
summary:
"Em abril de 2026, a Lovable informou que, de 3 de fevereiro a 20 de abril, o histórico de conversa e o código-fonte de projetos públicos podiam ser acessados por qualquer usuário da plataforma que tivesse o link do projeto. A causa foi uma alteração interna que desfez uma proteção anterior. Pesquisadores avisaram a partir de 22 de fevereiro e os avisos foram encerrados sem chegar à equipe de segurança. A empresa diz que projetos privados não foram afetados e não informou quantos projetos foram de fato vistos por terceiros.",
care: "não escrever senhas, chaves nem dados de clientes na conversa com a ferramenta e conferir quem pode ver o projeto, que é diferente de quem pode ver o site publicado.",
links: [
{
href: "https://lovable.dev/blog/our-response-to-the-april-2026-incident",
label: "Relato da Lovable (22/04/2026, em inglês)",
},
],
},
{
title: "RedAccess, 2026: aplicações internas abertas na internet",
summary:
"Em maio de 2026, a empresa de segurança RedAccess disse ter encontrado cerca de 380 mil aplicações e outros arquivos públicos criados com Lovable, Base44, Netlify e Replit, dos quais cerca de 5 mil continham informação sensível de empresas. O site Axios conferiu alguns exemplos, entre eles informações financeiras internas de um banco no Brasil e conversas de atendimento de uma loja. As plataformas contestam: dizem que aplicação pública acessível é o comportamento esperado e não prova invasão nem falha de segurança.",
care: "decidir antes de publicar quem precisa entrar em cada ferramenta interna e exigir login, em vez de confiar que ninguém vai achar o endereço.",
links: [
{
href: "https://securityboulevard.com/2026/05/thousands-of-vibe-coded-apps-exposing-corporate-personal-data-redaccess/",
label: "Reportagem do Security Boulevard (07/05/2026, em inglês)",
},
{
href: "https://www.axios.com/2026/05/07/loveable-replit-vibe-coding-privacy",
label: "Reportagem do Axios (em inglês)",
},
],
},
];
export default function AiPage() {
@@ -171,7 +201,7 @@ export default function AiPage() {
"@graph": [
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
[offerLabels.revisao.name, pageUrl],
[IA_PAGE_LABEL, pageUrl],
] as const),
],
};
@@ -185,12 +215,11 @@ export default function AiPage() {
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: offerLabels.revisao.name, href: "/ia" },
{ label: IA_PAGE_LABEL, href: "/ia" },
]}
/>
<div className="grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-sp-12">
<div className="min-w-0">
<p className="ds-eyebrow">IA no desenvolvimento</p>
<h1 className="ds-section-title max-w-[24ch] text-balance">
Seu app feito com IA está pronto para receber clientes? Eu testo
antes deles.
@@ -209,7 +238,7 @@ export default function AiPage() {
service={WHATSAPP_SERVICE}
placement="ia_hero"
>
Pedir a revisão do meu app
{buttonLabels.whatsapp}
</WhatsAppLink>
<a href="#ia-receber" className={textLinkDark}>
Ver como a revisão funciona
@@ -390,10 +419,10 @@ export default function AiPage() {
</summary>
<div className="pb-sp-8">
<p className="ds-copy max-w-[75ch]">
São episódios de 2025, não avaliações atuais das plataformas.
Eles mostram riscos de configuração, permissões e operação; não
provam que todo sistema feito com IA seja inseguro ou que um
profissional elimine qualquer risco.
São episódios de 2025 e 2026, não avaliações atuais das
plataformas. Eles mostram riscos de configuração, permissões e
operação; não provam que todo sistema feito com IA seja inseguro
ou que um profissional elimine qualquer risco.
</p>
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
{INCIDENTS.map((incident) => (
@@ -426,7 +455,7 @@ export default function AiPage() {
</section>
<section className="ds-section" aria-labelledby="ia-lgpd-title">
<div className="ds-section-shell grid gap-sp-6 lg:grid-cols-2">
<div className="ds-section-shell grid items-start gap-sp-6 lg:grid-cols-3">
<article className="ds-card">
<h2 id="ia-lgpd-title" className="ds-card-title !mt-0">
E a LGPD?
@@ -445,6 +474,14 @@ export default function AiPage() {
contratar desenvolvimento não equivale a uma certificação de
adequação.
</p>
<p className="ds-copy">
Se houver um incidente com dados pessoais que possa trazer risco
ou dano relevante, a empresa precisa comunicar a ANPD e as pessoas
afetadas em três dias úteis depois de saber do fato; agentes de
pequeno porte têm o prazo em dobro. Cumprir esse prazo depende de
ter cópia de segurança, registro de acessos e alguém que saiba
onde olhar.
</p>
<a
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
@@ -453,6 +490,37 @@ export default function AiPage() {
>
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
</a>
<a
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
href="https://www.in.gov.br/en/web/dou/-/resolucao-cd/anpd-n-15-de-24-de-abril-de-2024-556243024"
target="_blank"
rel="noopener noreferrer"
>
ANPD: Resolução nº 15/2024, arts. 6º e 9º
</a>
</article>
<article className="ds-card">
<h2 className="ds-card-title !mt-0">
Quem responde pelo que a IA escreve?
</h2>
<p className="ds-copy">
Em março de 2026, a Sexta Turma do Tribunal Superior do Trabalho
multou uma empresa de telecomunicações e o advogado dela por
citarem, em um recurso, decisões que não existiam, atribuídas pelo
tribunal ao uso de inteligência artificial.
</p>
<p className="ds-copy">
O caso é de um processo, não de um sistema. A regra é a mesma:
quem assina responde pelo que entrega.
</p>
<a
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
href="https://www.tst.jus.br/-/empresa-e-advogado-sao-multados-por-uso-de-ia-com-decisoes-falsas"
target="_blank"
rel="noopener noreferrer"
>
TST: notícia de 26/03/2026
</a>
</article>
<article className="ds-card">
<h2 className="ds-card-title !mt-0">
@@ -475,7 +543,7 @@ export default function AiPage() {
service={WHATSAPP_SERVICE}
placement="ia_closing"
>
Avaliar meu projeto
{buttonLabels.whatsapp}
</WhatsAppLink>
</div>
</article>
+2 -1
View File
@@ -77,7 +77,8 @@ describe("metadata", () => {
});
it("describes the offer without inventing numbers or promises", () => {
expect(metadata.description).toMatch(/WhatsApp/);
// o posicionamento do hero: sites e sistemas sob medida (os casos de WhatsApp e planilha ficam nos cartões de oferta)
expect(metadata.description).toMatch(/^Sites e sistemas sob medida/);
expect(metadata.description).not.toMatch(/R\$|garantia|\d+ dias/);
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ const titleSuffix = " | Evandro.dev.br";
/** Título da home: o `title.default` não passa pelo template, então leva o sufixo. Com ele, no máximo 60 caracteres. */
const defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`;
const seoDescription =
"Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
/**
* Para o WhatsApp falar do serviço ou do case da página aberta: só os assuntos e nomes, sem o conteúdo pesado das
+44
View File
@@ -0,0 +1,44 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import manifest from "./manifest";
describe("manifest", () => {
it("has a stable id at the site root", () => {
expect(manifest().id).toBe("/");
});
it("lists the any and maskable icons in 192 and 512, in this order", () => {
expect(manifest().icons).toEqual([
{
src: "/brand/pwa-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/brand/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/brand/pwa-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/brand/pwa-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
]);
});
it("points every icon to a file that exists in public/", () => {
for (const { src } of manifest().icons ?? []) {
expect(existsSync(join(process.cwd(), "public", src))).toBe(true);
}
});
});
+18 -3
View File
@@ -5,7 +5,8 @@ export default function manifest(): MetadataRoute.Manifest {
name: "Evandro.dev.br — Sites e sistemas para empresas",
short_name: "Evandro.dev",
description:
"Sites que trazem pedidos pelo WhatsApp e sistemas que tiram a operação da planilha. Com Evandro Fonseca Junior, de Garuva, SC, para empresas de todo o Brasil.",
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha. Com Evandro Fonseca Junior, de Garuva, SC, para empresas de todo o Brasil.",
id: "/",
start_url: "/",
display: "standalone",
background_color: "#000000",
@@ -16,14 +17,28 @@ export default function manifest(): MetadataRoute.Manifest {
dir: "ltr",
icons: [
{
src: "/brand/icon-192.png",
src: "/brand/pwa-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icon.png",
src: "/brand/pwa-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/brand/pwa-maskable-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/brand/pwa-maskable-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
+33 -2
View File
@@ -1,4 +1,10 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { render, screen } from "@testing-library/react";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import NotFound, { metadata } from "./not-found";
describe("not-found", () => {
@@ -48,9 +54,34 @@ describe("not-found", () => {
);
});
it("uses no lime: the lime is only for the contact action", () => {
it("makes 'Voltar ao início' the primary button and 'Ver os cases' the dark outline one, with no icons", () => {
const { container } = render(<NotFound />);
expect(container.innerHTML).not.toMatch(/slash|ccff00/i);
expect(
screen.getByRole("link", { name: "Voltar ao início" }).className,
).toBe(primaryButton);
expect(screen.getByRole("link", { name: "Ver os cases" }).className).toBe(
secondaryButtonDark,
);
expect(container.querySelectorAll("a svg")).toHaveLength(0);
});
it("shows the 3D piece as decoration: a looping video with the still as poster, inside an aria-hidden box", () => {
const { container } = render(<NotFound />);
const video = container.querySelector("video");
expect(video).toHaveAttribute("poster", "/3d/peca-404.webp");
expect(video?.closest('[aria-hidden="true"]')).not.toBeNull();
expect(
Array.from(container.querySelectorAll("video source")).map((source) =>
source.getAttribute("src"),
),
).toEqual(["/3d/peca-404.webm", "/3d/peca-404.mp4"]);
});
it("has no slate colour left in the source", () => {
const source = readFileSync(join(__dirname, "not-found.tsx"), "utf8");
expect(source).not.toMatch(/slate/);
});
});
+42 -22
View File
@@ -1,5 +1,8 @@
import { ArrowLeft, Code2, Home } from "lucide-react";
import type { Metadata } from "next";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { twitterHandle } from "@/lib/seo";
import { siteName } from "@/lib/site-content";
@@ -33,36 +36,53 @@ export const metadata: Metadata = {
export default function NotFound() {
return (
<main className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<Code2 className="w-24 h-24 text-slate-500 mx-auto mb-4 opacity-60" />
<h1 className="font-[family-name:var(--font-display)] text-6xl md:text-8xl font-extrabold text-white mb-4">
<main className="flex min-h-screen items-center bg-[var(--ink)] px-sp-6 py-sp-16 font-display text-[#f4f2ed] lg:px-sp-12">
<div className="ds-section-shell grid items-center gap-sp-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]">
<div>
<h1 className="text-[clamp(64px,12vw,96px)] font-semibold leading-none tracking-[-0.04em]">
404
</h1>
<h2 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold text-slate-300 mb-4">
<h2 className="mt-sp-4 text-[clamp(22px,3vw,30px)] font-semibold leading-[1.15] tracking-[-0.02em]">
Página não encontrada
</h2>
<p className="font-[family-name:var(--font-body)] text-slate-400 mb-8">
<p className="mt-sp-4 max-w-[34em] text-[17px] leading-[1.55] text-[rgba(244,242,237,.72)]">
A página que você está procurando não existe ou foi movida.
</p>
<div className="mt-sp-8 flex flex-col gap-sp-3 sm:flex-row">
<HoverPrefetchLink href="/" className={primaryButton}>
Voltar ao início
</HoverPrefetchLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver os cases
</HoverPrefetchLink>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<HoverPrefetchLink
href="/"
className="px-6 py-3 border border-slate-600 hover:border-slate-400 bg-transparent text-white rounded-lg font-[family-name:var(--font-body)] text-base font-semibold transition-all flex items-center justify-center gap-2"
<div
aria-hidden="true"
className="order-first mx-auto aspect-[16/10] w-full max-w-[280px] overflow-hidden lg:order-none lg:max-w-none"
>
<video
className="block h-full w-full object-cover motion-reduce:hidden"
autoPlay
muted
loop
playsInline
preload="metadata"
poster="/3d/peca-404.webp"
>
<Home className="w-5 h-5" />
Voltar ao início
</HoverPrefetchLink>
<HoverPrefetchLink
href="/cases"
className="px-6 py-3 font-[family-name:var(--font-body)] text-base text-slate-300 hover:text-white underline underline-offset-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Ver os cases
</HoverPrefetchLink>
<source src="/3d/peca-404.webm" type="video/webm" />
<source src="/3d/peca-404.mp4" type="video/mp4" />
</video>
{/* biome-ignore lint/performance/noImgElement: imagem decorativa e pequena, só para movimento reduzido */}
<img
src="/3d/peca-404.webp"
alt=""
width={640}
height={400}
loading="lazy"
className="hidden h-full w-full object-cover motion-reduce:block"
/>
</div>
</div>
</main>
+2 -2
View File
@@ -9,8 +9,8 @@ export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Sites e sistemas para empresas"
title="Sites e sistemas que trazem pedidos."
description="Pedido pelo WhatsApp e operação fora da planilha. Com Evandro Fonseca Junior, de Garuva, SC."
title="Sites e sistemas sob medida."
description="Para o jeito que a sua empresa trabalha. Com Evandro Fonseca Junior, de Garuva, SC."
/>,
OG_SIZE,
);
+8 -2
View File
@@ -64,12 +64,18 @@ it("uses the same shell as the home, with no narrow max-width", () => {
expect(metadata.title).toBe("Privacidade e consentimento");
});
it("opens with the same lower one-column strip as the other inner pages", () => {
it("opens with the same lower strip as the other inner pages, with the decorative block piece beside the text from 1024 px", () => {
const { container } = render(<PrivacyPage />);
const hero = container.querySelector("section");
expect(hero?.className).toContain("md:!pb-sp-12");
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
// a trilha também tem aria-hidden (as barras): a peça é a dona da imagem
const image = hero?.querySelector("img");
expect(image?.getAttribute("src")).toContain("estado-dados.webp");
expect(image?.closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
});
it("keeps the lime fill for contact actions only", () => {
+23 -16
View File
@@ -1,8 +1,12 @@
import type { Metadata } from "next";
import { compactHero } from "@/app/cases/_components/hero-styles";
import {
compactHero,
heroWithMediaGrid,
} from "@/app/cases/_components/hero-styles";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { PagePiece } from "@/app/components/PagePiece";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteUrl } from "@/lib/site-content";
@@ -41,20 +45,23 @@ export default function PrivacyPage() {
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Privacidade", href: "/privacidade" },
]}
/>
<h1 className="ds-section-title text-white">
Privacidade e consentimento
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
Esta página explica, de forma objetiva, como funcionam o contato e
as escolhas de analytics neste portfólio.
</p>
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
<div className="min-w-0">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Privacidade", href: "/privacidade" },
]}
/>
<h1 className="ds-section-title text-white">
Privacidade e consentimento
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
Esta página explica, de forma objetiva, como funcionam o contato e
as escolhas de analytics neste portfólio.
</p>
</div>
<PagePiece image="/3d/estado-dados.webp" />
</div>
</section>
@@ -68,7 +75,7 @@ export default function PrivacyPage() {
O responsável pelo site é Evandro Fonseca Junior. Para contato
ou solicitações relacionadas a esta página, escreva para{" "}
<a
className="font-medium underline underline-offset-4"
className="py-1 font-medium underline underline-offset-4"
href="mailto:[email protected]"
>
evandrofonsecajunior@gmail.com
+72 -21
View File
@@ -17,6 +17,13 @@ import ServicePage, { generateMetadata, generateStaticParams } from "./page";
beforeEach(() => {
setNavigation({ pathname: "/servicos" });
// a peça em loop do topo (OfferVideo) toca e pausa o vídeo, que o jsdom não implementa
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function renderService(slug: string) {
@@ -43,6 +50,8 @@ if (!service) throw new Error("Missing representative service");
const publicServiceSlugs = [
"criacao-de-sites",
"engenharia-de-software",
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
"consultoria-nextjs",
"tauri-rust",
"integracao-hardware-software-iot",
@@ -67,8 +76,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
it("keeps static params and route metadata canonical", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect(staticSlugs).toHaveLength(8);
expect(new Set(staticSlugs).size).toBe(8);
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
const metadata = await generateMetadata({
@@ -110,7 +119,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
}
const primaryCtas = screen.getAllByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
});
expect(primaryCtas).toHaveLength(2);
for (const cta of primaryCtas) {
@@ -285,22 +294,22 @@ it("keeps hub cards and structured items aligned", () => {
itemListElement: Array<{ position: number; url: string; name: string }>;
};
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
1, 2, 3, 4, 5, 6,
1, 2, 3, 4, 5, 6, 7, 8,
]);
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
expect(structuredUrls).toHaveLength(6);
expect(new Set(structuredUrls).size).toBe(6);
expect(structuredUrls).toHaveLength(8);
expect(new Set(structuredUrls).size).toBe(8);
expect([...structuredUrls].sort()).toEqual(
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
);
// as duas ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
// as quatro ofertas que são serviços abrem pelo cartão da oferta; os outros quatro estão em "Outros serviços"
const offerLinks = serviceOffers
.filter(({ href }) => publicServiceHrefs.includes(href))
.map(({ name, href }) => ({
href,
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
}));
expect(offerLinks).toHaveLength(2);
expect(offerLinks).toHaveLength(4);
for (const { href, link } of offerLinks) {
expect(link).toHaveAttribute("href", href);
}
@@ -312,8 +321,8 @@ it("keeps hub cards and structured items aligned", () => {
...offerLinks.map(({ link }) => link.getAttribute("href")),
...otherLinks.map((link) => link.getAttribute("href")),
];
expect(visibleHrefs).toHaveLength(6);
expect(new Set(visibleHrefs).size).toBe(6);
expect(visibleHrefs).toHaveLength(8);
expect(new Set(visibleHrefs).size).toBe(8);
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
expect(graph[2]).toMatchObject({
"@type": "BreadcrumbList",
@@ -324,12 +333,12 @@ it("keeps hub cards and structured items aligned", () => {
});
});
it("puts the three offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
it("puts the five offers right after the hero, each with its name, deliverables, proof and the right page, and 'Outros serviços' below", () => {
const { container } = render(<ServicesIndexPage />);
const offersHeading = screen.getByRole("heading", {
level: 2,
name: "Três jeitos de começar.",
name: "Cinco jeitos de começar.",
});
const othersHeading = screen.getByRole("heading", {
level: 2,
@@ -342,8 +351,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
const cards = Array.from(
container.querySelectorAll<HTMLElement>("section article"),
).slice(0, 3);
expect(cards).toHaveLength(3);
).slice(0, 5);
expect(cards).toHaveLength(5);
for (const [index, offer] of serviceOffers.entries()) {
const card = cards[index];
expect(
@@ -357,7 +366,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
).toHaveAttribute("href", offer.href);
expect(within(card).getByText(offer.proof.label)).toBeVisible();
}
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão
// a prova: Lavação 047 e SEV na oferta do site, a SEV e a Apolo na do sistema, a página /ia na da revisão; o atendimento
// e a nota fiscal não têm case e só levam à página do serviço
expect(
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
@@ -368,10 +378,18 @@ it("puts the three offers right after the hero, each with its name, deliverables
within(cards[1]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(within(cards[1]).getByText(/Apolo Energia/)).toBeVisible();
expect(within(cards[2]).queryAllByRole("link")).toHaveLength(1);
for (const card of [cards[2], cards[3], cards[4]]) {
expect(within(card).queryAllByRole("link")).toHaveLength(1);
}
expect(
within(cards[2]).getByRole("link", { name: /^Ver o serviço/ }),
within(cards[3]).getByRole("link", { name: /^Ver o serviço/ }),
).toHaveAttribute("href", "/ia");
expect(
within(cards[2]).getByText(/^Ainda não há case público/),
).toBeVisible();
expect(
within(cards[4]).getByText(/^Ainda não há case público/),
).toBeVisible();
});
it("keeps the hub free of case covers, with the SEV only as a proof link in two offers, and without disclaimers", () => {
@@ -606,7 +624,7 @@ it("says the offer name, not the old label, in the breadcrumb, the title and the
).getAllByRole("link");
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
for (const cta of screen.getAllByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
})) {
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
"text",
@@ -914,7 +932,7 @@ it("puts the FAQ before the next step and ends every service page with the same
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
name: buttonLabels.whatsapp,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
@@ -926,8 +944,8 @@ it("puts the FAQ before the next step and ends every service page with the same
// um rótulo só para o contato: sem "no WhatsApp" colado nem texto por serviço
expect(
screen
.getAllByRole("link", { name: buttonLabels.contact })
.every((link) => link.textContent === buttonLabels.contact),
.getAllByRole("link", { name: buttonLabels.whatsapp })
.every((link) => link.textContent === buttonLabels.whatsapp),
).toBe(true);
expect(container.textContent).not.toMatch(/diretamente/i);
unmount();
@@ -963,3 +981,36 @@ it("keeps related case ids resolving to real cases", () => {
expect(caseStudies.some((item) => item.id === id)).toBe(true);
}
});
it("shows the official dates or rules with a link to each source, after 'O que você recebe', only where the service has them", async () => {
for (const slug of [
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
]) {
const page = getServicePage(slug);
if (!page?.official) throw new Error(`Missing official block of ${slug}`);
const { unmount } = await renderService(slug);
const heading = screen.getByRole("heading", {
level: 2,
name: page.official.title,
});
expect(
isBefore(
screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
heading,
),
).toBe(true);
for (const item of page.official.items) {
expect(screen.getByText(item)).toBeVisible();
}
for (const { label, href } of page.official.links) {
const link = screen.getByRole("link", { name: label });
expect(link).toHaveAttribute("href", href);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
unmount();
}
await renderService("consultoria-nextjs");
expect(document.getElementById("fonte-oficial")).toBeNull();
});
+5 -2
View File
@@ -24,6 +24,7 @@ import { ServiceAlsoCases } from "../_components/ServiceAlsoCases";
import { ServiceDeliverables } from "../_components/ServiceDeliverables";
import { ServiceHero } from "../_components/ServiceHero";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceOfficial } from "../_components/ServiceOfficial";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
import {
resolveServiceHero,
@@ -76,7 +77,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
const hero = resolveServiceHero(service);
const { cards, also } = splitServiceCases(service);
// as duas primeiras ofertas têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
// as ofertas, menos a revisão (que abre /ia), têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
const offer = getServiceOffer(service.slug);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
const audienceCard = (
@@ -125,6 +126,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
{offer ? <ServiceDeliverables offer={offer} /> : null}
<ServiceOfficial official={service.official} />
<section className="ds-section">
<div className="ds-section-shell">
{offer ? (
@@ -226,7 +229,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
placement="service_detail_footer"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
<HoverPrefetchLink
href="/servicos"
@@ -12,10 +12,11 @@ import { ServiceHero } from "./ServiceHero";
import type { ServiceHeroData } from "./service-page";
/**
* Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX):
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide (grade de duas colunas), hasCaseCards (botão "Ver projetos
* parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa).
* Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam.
* Testes de caminhos-base de ServiceHero (CC 7, contado à mão: as seis escolhas do JSX):
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide ou heroLoop (grade de duas colunas), hasCaseCards (botão
* "Ver projetos parecidos"), diagram (esquema ou o resto), heroLoop sem coluna ao lado (peça em loop ou capa) e
* heroCase (a frase da capa).
* Os filhos que desenham a coluna ao lado (esquema, peça e capa) são trocados por dublês que mostram o que receberam.
*/
jest.mock("./ServiceDiagram", () => ({
@@ -23,6 +24,11 @@ jest.mock("./ServiceDiagram", () => ({
<div data-testid="diagram">{kind}</div>
),
}));
jest.mock("@/app/components/PagePiece", () => ({
PagePiece: ({ loop }: { loop: string }) => (
<div data-testid="piece">{loop}</div>
),
}));
jest.mock("./ServiceHeroCover", () => ({
ServiceHeroCover: (props: {
caseStudy?: { id: string };
@@ -123,6 +129,36 @@ describe("ServiceHero", () => {
expect(screen.queryByTestId("cover")).toBeNull();
});
it("path: a loop and no side column -> the low strip stays, the grid comes in and the loop piece takes the side column", () => {
const { container } = render(
<ServiceHero
service={service("atendimento-whatsapp-ia")}
hero={noSide}
/>,
);
for (const token of compactHero.split(/\s+/).filter(Boolean))
expect(section(container)).toHaveClass(token);
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
expect(shell(container)).toHaveClass(token);
expect(screen.getByTestId("piece")).toHaveTextContent("whatsapp");
expect(screen.queryByTestId("cover")).toBeNull();
// sem rótulo sobre o título: a trilha já diz o nome do serviço
expect(section(container).querySelector(".ds-eyebrow")).toBeNull();
});
it("path: a loop and a side column -> the side column wins and the loop is not mounted", () => {
render(
<ServiceHero
service={service("nota-fiscal-reforma-tributaria")}
hero={{ ...noSide, diagram: "desktop", hasHeroSide: true }}
/>,
);
expect(screen.getByTestId("diagram")).toBeInTheDocument();
expect(screen.queryByTestId("piece")).toBeNull();
});
it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => {
const lavacao = service();
render(
@@ -174,7 +210,7 @@ describe("ServiceHero", () => {
render(<ServiceHero service={sistemas} hero={noSide} />);
const button = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
name: /chamar no whatsapp/i,
});
const message = new URL(button.getAttribute("href") ?? "").searchParams.get(
"text",
+9 -5
View File
@@ -8,6 +8,7 @@ import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import { PagePiece } from "@/app/components/PagePiece";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
import { ServiceDiagram } from "./ServiceDiagram";
@@ -16,7 +17,8 @@ import type { ServiceHeroData } from "./service-page";
/**
* Topo da página do serviço: trilha, título, resposta, botão de contato e, na coluna ao lado, a capa do case escolhido
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo.
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo; com `heroLoop`, a peça de blocos
* ocupa a coluna ao lado de 1024 px em diante, sem mudar a altura do topo.
*/
export function ServiceHero({
service,
@@ -26,6 +28,7 @@ export function ServiceHero({
hero: ServiceHeroData;
}) {
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
const loop = hasHeroSide ? undefined : service.heroLoop;
return (
<section
@@ -34,9 +37,9 @@ export function ServiceHero({
}`}
>
<div
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
className={`ds-section-shell ${hasHeroSide || loop ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<div className="min-w-0 max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
@@ -44,7 +47,6 @@ export function ServiceHero({
{ label: service.navLabel, href: `/servicos/${service.slug}` },
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[22ch] text-balance text-white">
{service.headline}
</h1>
@@ -57,7 +59,7 @@ export function ServiceHero({
placement="service_detail_hero"
className={primaryButton}
>
{buttonLabels.contact}
{buttonLabels.whatsapp}
</WhatsAppLink>
{hasCaseCards ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
@@ -72,6 +74,8 @@ export function ServiceHero({
</div>
{diagram ? (
<ServiceDiagram kind={diagram} />
) : loop ? (
<PagePiece loop={loop} />
) : (
<ServiceHeroCover
caseStudy={heroCase}
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more