feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
This commit is contained in:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -61,3 +61,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
.omo/
|
.omo/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
/3d/out/
|
||||||
|
/3d/__pycache__/
|
||||||
+619
@@ -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
@@ -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()
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
|||||||
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
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)
|
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
|
||||||
const contacts = screen.getAllByRole("link", {
|
const contacts = screen.getAllByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(contacts).toHaveLength(1);
|
expect(contacts).toHaveLength(1);
|
||||||
expect(contacts[0]).toBeVisible();
|
expect(contacts[0]).toBeVisible();
|
||||||
|
|||||||
@@ -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
|
* 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:
|
* ("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 "Falar sobre meu projeto" seguidos.
|
* `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 }) {
|
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||||
const hrefs = screen
|
const hrefs = screen
|
||||||
@@ -72,7 +72,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
|||||||
expect(hrefs).not.toContain("/#contact");
|
expect(hrefs).not.toContain("/#contact");
|
||||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
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);
|
expect(ctas).toHaveLength(1);
|
||||||
for (const cta of ctas) {
|
for (const cta of ctas) {
|
||||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
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");
|
const capabilitiesSection = sectionHeadings[4].closest("section");
|
||||||
expect(
|
expect(
|
||||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const closingSection = sectionHeadings[5].closest("section");
|
const closingSection = sectionHeadings[5].closest("section");
|
||||||
expect(closingSection).toContainElement(
|
expect(closingSection).toContainElement(
|
||||||
within(closingSection as HTMLElement).getByRole("link", {
|
within(closingSection as HTMLElement).getByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const capability of caseStudy.capabilities ?? []) {
|
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);
|
expect(items.length).toBeLessThanOrEqual(3);
|
||||||
// sem número inventado
|
// sem número inventado
|
||||||
expect(items.join(" ")).not.toMatch(/\d/);
|
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(
|
expect(
|
||||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const closingSection = screen
|
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(capabilitiesSection?.nextElementSibling).toBe(closingSection);
|
||||||
expect(
|
expect(
|
||||||
within(closingSection as HTMLElement).getAllByRole("link", {
|
within(closingSection as HTMLElement).getAllByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
unmount();
|
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:/ }),
|
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
// o botão do fecho (o único) vem antes dos cartões
|
// 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);
|
expect(isBefore(contact, cards[0])).toBe(true);
|
||||||
unmount();
|
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).not.toContain("/#contact");
|
||||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||||
expect(hrefs).toContain("/servicos");
|
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");
|
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||||
|
|
||||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
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
|
* 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).
|
* 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) {
|
for (const item of capabilities) {
|
||||||
expect(screen.getByText(item)).toBeVisible();
|
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(screen.queryByRole("link")).toBeNull();
|
||||||
expect(container.querySelectorAll("a")).toHaveLength(0);
|
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).
|
* 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
|
* 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({
|
export function CaseCapabilitiesSection({
|
||||||
caseStudy,
|
caseStudy,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
|
|||||||
name: "Quer um projeto parecido com este?",
|
name: "Quer um projeto parecido com este?",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).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");
|
expect(cta).toHaveAttribute("target", "_blank");
|
||||||
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||||
expect(messageOf(cta)).toContain(
|
expect(messageOf(cta)).toContain(
|
||||||
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
|
|||||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
// 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
|
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||||
expect(messageOf(cta)).toContain(
|
expect(messageOf(cta)).toContain(
|
||||||
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||||
|
|||||||
@@ -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ê.
|
* 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
|
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
|
||||||
* (e no botão do cabeçalho): `similarProjectSubject`.
|
* `similarProjectSubject`.
|
||||||
*/
|
*/
|
||||||
const contactCopy = {
|
const contactCopy = {
|
||||||
client: { title: "Quer um projeto parecido com este?" },
|
client: { title: "Quer um projeto parecido com este?" },
|
||||||
@@ -47,7 +47,7 @@ export function CaseContactSection({
|
|||||||
placement="case_detail"
|
placement="case_detail"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||||
Ver outros cases
|
Ver outros cases
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { caseStudies } from "@/lib/site-content";
|
||||||
import { CaseCover, coverSizes } from "./CaseCover";
|
import { CaseCover, coverSizes } from "./CaseCover";
|
||||||
import { getCaseMedia } from "./case-media";
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { coverTransitionProps } from "./cover-transition";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
|
* 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"`).
|
* 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() }));
|
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 mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
const props = {
|
const props = {
|
||||||
id: "sev-modaintima",
|
id: "sev-modaintima",
|
||||||
@@ -22,6 +30,23 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CaseCover", () => {
|
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", () => {
|
it("path: no desktop capture -> renders nothing", () => {
|
||||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||||
const { container } = render(<CaseCover {...props} />);
|
const { container } = render(<CaseCover {...props} />);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { ViewTransition } from "react";
|
||||||
import {
|
import {
|
||||||
BrowserFrame,
|
BrowserFrame,
|
||||||
displayUrl,
|
displayUrl,
|
||||||
} from "@/app/components/showcase/BrowserFrame";
|
} from "@/app/components/showcase/BrowserFrame";
|
||||||
import { getCaseMedia } from "./case-media";
|
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
|
* 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 (
|
return (
|
||||||
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||||
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||||
<Image
|
<ViewTransition {...coverTransitionProps(id)}>
|
||||||
src={media.poster}
|
<Image
|
||||||
alt={`Captura de tela do site ${title}`}
|
src={media.poster}
|
||||||
fill
|
alt={`Captura de tela do site ${title}`}
|
||||||
sizes={sizes}
|
fill
|
||||||
{...priorityProps}
|
sizes={sizes}
|
||||||
className="object-cover object-top"
|
{...priorityProps}
|
||||||
/>
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
</ViewTransition>
|
||||||
</div>
|
</div>
|
||||||
</BrowserFrame>
|
</BrowserFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function CaseHero({
|
|||||||
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
{ 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">
|
<p className="ds-eyebrow">
|
||||||
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
{isClient ? "Case de cliente" : "Projeto de laboratório"}
|
||||||
{caseStudy.title}
|
|
||||||
</p>
|
</p>
|
||||||
<h1 className="ds-section-title text-balance text-white">
|
<h1 className="ds-section-title text-balance text-white">
|
||||||
{caseStudy.headline}
|
{caseStudy.headline}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function CaseMediaSection({
|
|||||||
className={secondaryButtonDark}
|
className={secondaryButtonDark}
|
||||||
href={caseStudy.demoUrl}
|
href={caseStudy.demoUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{buttonLabels.site}
|
{buttonLabels.site}
|
||||||
<span className="sr-only"> {caseStudy.title}</span>
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ describe("CasePlayer", () => {
|
|||||||
name: `Reproduzir o vídeo do site ${title} no computador`,
|
name: `Reproduzir o vídeo do site ${title} no computador`,
|
||||||
});
|
});
|
||||||
expect(button).toHaveAttribute("type", "button");
|
expect(button).toHaveAttribute("type", "button");
|
||||||
// só botão de contato é limão: o de play é escuro, com contorno branco
|
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
|
||||||
expect(button.innerHTML).not.toContain("--slash");
|
const circle = button.querySelector("[aria-hidden='true']");
|
||||||
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
|
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", () => {
|
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ export function CasePlayer({
|
|||||||
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
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"
|
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
|
<span
|
||||||
aria-hidden="true"
|
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" />
|
<Play className="ml-1 size-6" fill="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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", () => {
|
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
||||||
const { container } = render(<CasesPage />);
|
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(
|
const decorative = Array.from(
|
||||||
container.querySelectorAll<HTMLImageElement>("img"),
|
container.querySelectorAll<HTMLImageElement>("img"),
|
||||||
).filter((image) => image.getAttribute("alt") === "");
|
).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);
|
const [first, ...others] = coverImages(container);
|
||||||
expect(first).toHaveAttribute("loading", "eager");
|
expect(first).toHaveAttribute("loading", "eager");
|
||||||
|
|||||||
@@ -77,11 +77,27 @@ describe("CasesPage: cases de cliente", () => {
|
|||||||
).toBeNull();
|
).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 />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
screen.getByRole("link", { name: buttonLabels.whatsapp }),
|
||||||
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
||||||
const clientTitles = Array.from(
|
const clientTitles = Array.from(
|
||||||
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
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
|
// um botão de contato só na página: o do fecho
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("link", { name: buttonLabels.contact }),
|
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CasesPage: topo sem mosaico", () => {
|
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 { container } = render(<CasesPage />);
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.querySelector("img")).toBeNull();
|
const images = hero?.querySelectorAll("img") ?? [];
|
||||||
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
|
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");
|
expect(hero?.className).toContain("md:!pb-sp-12");
|
||||||
// o título e o subtítulo não repetem a mesma frase
|
// o título e o subtítulo não repetem a mesma frase
|
||||||
const heading = within(hero as HTMLElement).getByRole("heading", {
|
const heading = within(hero as HTMLElement).getByRole("heading", {
|
||||||
@@ -130,7 +152,7 @@ describe("CasesPage: topo sem mosaico", () => {
|
|||||||
const { container } = render(<CasesPage />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.querySelector("img")).toBeNull();
|
expect(hero?.querySelectorAll("img")).toHaveLength(1);
|
||||||
const firstCover = clientCard(
|
const firstCover = clientCard(
|
||||||
container,
|
container,
|
||||||
clientCasesForListing[0].id,
|
clientCasesForListing[0].id,
|
||||||
|
|||||||
+45
-28
@@ -8,6 +8,7 @@ import {
|
|||||||
textLink,
|
textLink,
|
||||||
} from "@/app/components/button-styles";
|
} from "@/app/components/button-styles";
|
||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { PagePiece } from "@/app/components/PagePiece";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "@/lib/site-content";
|
} from "@/lib/site-content";
|
||||||
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
||||||
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||||
import { compactHero } from "./_components/hero-styles";
|
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
|
||||||
|
|
||||||
const pageUrl = `${siteUrl}/cases`;
|
const pageUrl = `${siteUrl}/cases`;
|
||||||
const pageTitle = "Cases: sites e sistemas para empresas";
|
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({
|
function ClientCaseCard({
|
||||||
caseStudy,
|
caseStudy,
|
||||||
priority = false,
|
priority = false,
|
||||||
|
wide = false,
|
||||||
}: {
|
}: {
|
||||||
caseStudy: CaseStudyContent;
|
caseStudy: CaseStudyContent;
|
||||||
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
wide?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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
|
<CaseCoverLink
|
||||||
id={caseStudy.id}
|
id={caseStudy.id}
|
||||||
title={caseStudy.title}
|
title={caseStudy.title}
|
||||||
demoUrl={caseStudy.demoUrl}
|
demoUrl={caseStudy.demoUrl}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
/>
|
/>
|
||||||
<div className="ds-kicker">{caseStudy.title}</div>
|
<div>
|
||||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
<div className="ds-kicker">{caseStudy.title}</div>
|
||||||
<p className="ds-copy">{caseStudy.summary}</p>
|
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
<p className="ds-copy">{caseStudy.summary}</p>
|
||||||
<HoverPrefetchLink
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
href={`/cases/${caseStudy.id}`}
|
<HoverPrefetchLink
|
||||||
className={secondaryButtonLight}
|
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"
|
|
||||||
>
|
>
|
||||||
{buttonLabels.site}
|
{buttonLabels.case}
|
||||||
<span className="sr-only"> {caseStudy.title}</span>
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
</a>
|
</HoverPrefetchLink>
|
||||||
) : null}
|
{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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -144,15 +160,14 @@ export default function CasesPage() {
|
|||||||
<section
|
<section
|
||||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||||
>
|
>
|
||||||
<div className="ds-section-shell">
|
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
|
||||||
<div className="max-w-[860px]">
|
<div className="min-w-0 max-w-[860px]">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Início", href: "/" },
|
{ label: "Início", href: "/" },
|
||||||
{ label: "Cases", href: "/cases" },
|
{ label: "Cases", href: "/cases" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<p className="ds-eyebrow">Trabalhos entregues</p>
|
|
||||||
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
||||||
Veja o que já fiz para empresas.
|
Veja o que já fiz para empresas.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -160,6 +175,7 @@ export default function CasesPage() {
|
|||||||
{pageLead}
|
{pageLead}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<PagePiece image="/3d/estado-tela.webp" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -169,11 +185,12 @@ export default function CasesPage() {
|
|||||||
Projetos de clientes
|
Projetos de clientes
|
||||||
</h2>
|
</h2>
|
||||||
<div className="ds-grid-2 mt-sp-8 items-start">
|
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||||
{clientCasesForListing.map((caseStudy, index) => (
|
{clientCasesForListing.map((caseStudy, index, all) => (
|
||||||
<ClientCaseCard
|
<ClientCaseCard
|
||||||
key={caseStudy.id}
|
key={caseStudy.id}
|
||||||
caseStudy={caseStudy}
|
caseStudy={caseStudy}
|
||||||
priority={index === 0}
|
priority={index === 0}
|
||||||
|
wide={index === all.length - 1 && all.length % 2 === 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +228,7 @@ export default function CasesPage() {
|
|||||||
placement="cases_hub_footer"
|
placement="cases_hub_footer"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
||||||
{buttonLabels.allServices}
|
{buttonLabels.allServices}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit
|
|||||||
expect(none.container).toBeEmptyDOMElement();
|
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} />);
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
for (const link of screen.getAllByRole("link")) {
|
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");
|
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);
|
||||||
|
});
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
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
|
* 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.
|
* 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
|
* 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
|
* (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) => ({
|
...clients.map((client) => ({
|
||||||
key: client.id,
|
key: client.id,
|
||||||
node: (
|
node: (
|
||||||
<HoverPrefetchLink
|
<span className="flex flex-col">
|
||||||
href={`/cases/${client.id}`}
|
<HoverPrefetchLink
|
||||||
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)]"
|
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.name}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
{client.sector && (
|
||||||
|
<span className="text-[13px] leading-[18px] max-sm:hidden">
|
||||||
|
{client.sector}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
...(years
|
...(years
|
||||||
@@ -57,14 +72,16 @@ export default function ClientStrip({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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>}
|
{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) => (
|
{items.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={item.key}
|
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 && (
|
{index > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Contact: opções do tipo de projeto", () => {
|
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 />);
|
render(<Contact />);
|
||||||
|
|
||||||
const options = within(subjectSelect()).getAllByRole("option");
|
const options = within(subjectSelect()).getAllByRole("option");
|
||||||
@@ -32,10 +32,12 @@ describe("Contact: opções do tipo de projeto", () => {
|
|||||||
"Ainda preciso definir",
|
"Ainda preciso definir",
|
||||||
"Site que traz pedidos pelo WhatsApp",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Atendimento no WhatsApp com IA sob controle",
|
||||||
"Aplicativos",
|
"Aplicativos",
|
||||||
"Integrações e automações",
|
"Integrações e automações",
|
||||||
"Evolução de um sistema existente",
|
"Evolução de um sistema existente",
|
||||||
"Revisão de sistema que já existe",
|
"Revisão de sistema que já existe",
|
||||||
|
"Nota fiscal pronta para a reforma tributária",
|
||||||
]);
|
]);
|
||||||
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||||
});
|
});
|
||||||
@@ -61,7 +63,9 @@ describe("Contact: tipo escolhido pela oferta", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["site", "Site que traz pedidos pelo WhatsApp"],
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
["sistema", "Sistema para tirar a operação da planilha"],
|
["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"],
|
["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) => {
|
])("the offer event with %s selects %s", (kind, option) => {
|
||||||
render(<Contact />);
|
render(<Contact />);
|
||||||
|
|
||||||
@@ -156,7 +160,9 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["site", "Site que traz pedidos pelo WhatsApp"],
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
["sistema", "Sistema para tirar a operação da planilha"],
|
["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"],
|
["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>'",
|
"after the offer %s the form says 'Você escolheu: <name>'",
|
||||||
(kind, name) => {
|
(kind, name) => {
|
||||||
@@ -201,6 +207,14 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
|
|||||||
expect(directMessage()).toContain(
|
expect(directMessage()).toContain(
|
||||||
"Quero conversar sobre a revisão de um sistema que já existe.",
|
"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
|
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
|
||||||
await user.selectOptions(
|
await user.selectOptions(
|
||||||
subjectSelect(),
|
subjectSelect(),
|
||||||
|
|||||||
@@ -265,11 +265,11 @@ export default function Contact() {
|
|||||||
service={directSubject}
|
service={directSubject}
|
||||||
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
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" />
|
<WhatsAppMark className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 font-display">
|
<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?
|
Prefere conversar direto?
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-[15px] text-white/65">
|
<span className="mt-1 block text-[15px] text-white/65">
|
||||||
@@ -277,7 +277,7 @@ export default function Contact() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ArrowUpRight
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</WhatsAppLink>
|
</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"
|
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">
|
<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" />
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -412,7 +412,7 @@ export default function Contact() {
|
|||||||
</Field>
|
</Field>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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" />
|
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||||
Continuar no WhatsApp
|
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">
|
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
||||||
<a
|
<a
|
||||||
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
|
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>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${CONTACT_EMAIL}`}
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Faq({
|
|||||||
{answered.map((item) => (
|
{answered.map((item) => (
|
||||||
<details
|
<details
|
||||||
key={item.question}
|
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">
|
<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}
|
{item.question}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class FakeObserver {
|
|||||||
const hero = () => document.getElementById("hero-contact-cta") as Element;
|
const hero = () => document.getElementById("hero-contact-cta") as Element;
|
||||||
const contact = () => document.getElementById("contact") as Element;
|
const contact = () => document.getElementById("contact") as Element;
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetNavigation();
|
resetNavigation();
|
||||||
@@ -221,7 +221,7 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
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();
|
const user = userEvent.setup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
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" });
|
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(
|
expect(
|
||||||
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
).toBe("72px");
|
).toBe("72px");
|
||||||
expect(floatingLink()?.parentElement?.className).toContain(
|
expect(document.documentElement).toHaveAttribute(
|
||||||
"var(--cookie-bar-height,0px)",
|
"data-cookie-bar",
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
expect(floatingLink()?.parentElement).toHaveClass(
|
||||||
|
"[[data-cookie-bar=open]_&]:hidden",
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(refuse);
|
await user.click(refuse);
|
||||||
@@ -250,6 +254,7 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
|||||||
expect(
|
expect(
|
||||||
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
|
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
|
||||||
expect(floatingLink()).toBeInTheDocument();
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class FakeObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
const messageOf = (link: HTMLElement | null) =>
|
const messageOf = (link: HTMLElement | null) =>
|
||||||
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
|
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function setVisibility(hero: boolean, contact: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.IntersectionObserver =
|
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();
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lifts itself by the height of the open cookie bar", () => {
|
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);
|
|
||||||
|
|
||||||
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", () => {
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
setVisibility(false, false);
|
setVisibility(false, false);
|
||||||
|
|
||||||
const className = floatingLink()?.parentElement?.className ?? "";
|
const className = floatingLink()?.parentElement?.className ?? "";
|
||||||
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
|
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
|
||||||
expect(className).toContain("transition-transform");
|
// não sobe mais pela altura da barra: some enquanto ela está aberta
|
||||||
expect(className).toContain("motion-reduce:transition-none");
|
expect(className).not.toContain("--cookie-bar-height");
|
||||||
// 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(
|
expect(className).toContain(
|
||||||
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
|
|||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
import { WhatsAppMark } from "./WhatsAppMark";
|
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";
|
export const HERO_CTA_ID = "hero-contact-cta";
|
||||||
const CONTACT_SECTION_ID = "contact";
|
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
|
* 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
|
* 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.
|
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
|
||||||
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
|
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
|
||||||
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
|
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
|
||||||
*/
|
*/
|
||||||
export default function FloatingWhatsApp({
|
export default function FloatingWhatsApp({
|
||||||
contexts,
|
contexts,
|
||||||
@@ -61,15 +61,15 @@ export default function FloatingWhatsApp({
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Contato rápido"
|
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
|
<WhatsAppLink
|
||||||
placement="floating"
|
placement="floating"
|
||||||
service={subject}
|
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" />
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
<span className="sr-only">Conversar no WhatsApp</span>
|
<span className="sr-only">Chamar no WhatsApp</span>
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,20 +39,22 @@ describe("Footer contact and navigation", () => {
|
|||||||
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
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 />);
|
render(<Footer />);
|
||||||
|
|
||||||
const contact = screen.getByRole("region", { name: "Contato" });
|
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.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
expect(whatsApp).toHaveAttribute("target", "_blank");
|
expect(whatsApp).toHaveAttribute("target", "_blank");
|
||||||
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
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} />);
|
render(<Footer serviceLinks={serviceHubLinks} />);
|
||||||
|
|
||||||
expect(serviceHubLinks).toHaveLength(6);
|
expect(serviceHubLinks).toHaveLength(8);
|
||||||
const services = screen.getByRole("navigation", { name: "Serviços" });
|
const services = screen.getByRole("navigation", { name: "Serviços" });
|
||||||
const links = within(services).getAllByRole("link");
|
const links = within(services).getAllByRole("link");
|
||||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
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");
|
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 />);
|
render(<Footer />);
|
||||||
|
|
||||||
const more = screen.getByRole("navigation", { name: "Mais" });
|
const more = screen.getByRole("navigation", { name: "Mais" });
|
||||||
@@ -74,7 +76,7 @@ describe("Footer contact and navigation", () => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
within(more).getByRole("link", {
|
within(more).getByRole("link", {
|
||||||
name: "Revisão de sistema que já existe",
|
name: "Revisão de app com IA",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/ia");
|
).toHaveAttribute("href", "/ia");
|
||||||
expect(
|
expect(
|
||||||
@@ -145,11 +147,11 @@ describe("Footer social links", () => {
|
|||||||
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
|
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 />);
|
render(<Footer />);
|
||||||
|
|
||||||
expect(
|
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");
|
).toHaveAttribute("href", "/ia");
|
||||||
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
|
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
trackEmailClick,
|
trackEmailClick,
|
||||||
trackSocialClick,
|
trackSocialClick,
|
||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { offerLabels } from "@/lib/offer-labels";
|
|
||||||
import {
|
import {
|
||||||
CONTACT_EMAIL,
|
CONTACT_EMAIL,
|
||||||
|
IA_PAGE_LABEL,
|
||||||
INSTAGRAM_URL,
|
INSTAGRAM_URL,
|
||||||
LINKEDIN_URL,
|
LINKEDIN_URL,
|
||||||
type ServiceLink,
|
type ServiceLink,
|
||||||
@@ -18,7 +18,7 @@ import { Logo } from "./Logo";
|
|||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
const headingClassName =
|
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
|
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
|
||||||
const linkClassName =
|
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)]";
|
"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"
|
placement="footer"
|
||||||
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
|
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
|
||||||
>
|
>
|
||||||
WhatsApp
|
Chamar no WhatsApp
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -118,7 +118,7 @@ export default function Footer({
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
||||||
{offerLabels.revisao.name}
|
{IA_PAGE_LABEL}
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("GoogleAnalytics consent", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
||||||
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
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(
|
expect(
|
||||||
screen.queryByTestId("google-analytics-script"),
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
|||||||
@@ -231,16 +231,18 @@ function GoogleAnalyticsConsent() {
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
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">
|
<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
|
<p
|
||||||
id="analytics-consent-description"
|
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
|
<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"
|
href="/privacidade"
|
||||||
>
|
>
|
||||||
Política de privacidade
|
Política de privacidade
|
||||||
@@ -257,14 +259,14 @@ function GoogleAnalyticsConsent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={acceptRef}
|
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}
|
onClick={acceptAnalytics}
|
||||||
>
|
>
|
||||||
Aceitar
|
Aceitar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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}
|
onClick={denyAnalytics}
|
||||||
>
|
>
|
||||||
Recusar
|
Recusar
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ describe("Header", () => {
|
|||||||
within(navigation)
|
within(navigation)
|
||||||
.getAllByRole("link")
|
.getAllByRole("link")
|
||||||
.map((link) => link.textContent),
|
.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"
|
// "Projetos" saiu: duplicava "Cases"
|
||||||
expect(
|
expect(
|
||||||
within(navigation).queryByRole("link", { name: "Projetos" }),
|
within(navigation).queryByRole("link", { name: "Projetos" }),
|
||||||
@@ -179,11 +185,15 @@ describe("Header", () => {
|
|||||||
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
|
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
|
||||||
const links = within(mobileNavigation).getAllByRole("link");
|
const links = within(mobileNavigation).getAllByRole("link");
|
||||||
for (const link of links.slice(0, 5)) {
|
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];
|
const contact = links[5];
|
||||||
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
|
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
expect(contact.parentElement).toHaveClass("mt-auto");
|
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", () => {
|
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} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
const contact = screen.getByRole("link", {
|
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.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(contact).toHaveAttribute("target", "_blank");
|
||||||
expect(whatsAppHref(contact)).toContain(sentence);
|
expect(whatsAppHref(contact)).toContain(sentence);
|
||||||
},
|
},
|
||||||
@@ -258,7 +272,7 @@ describe("Header", () => {
|
|||||||
render(<Header whatsAppContexts={contexts} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
const contact = screen.getByRole("link", {
|
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.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
expect(contact).toHaveAttribute("target", "_blank");
|
expect(contact).toHaveAttribute("target", "_blank");
|
||||||
@@ -274,7 +288,7 @@ describe("Header", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
const buttons = screen.getAllByRole("link", {
|
const buttons = screen.getAllByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(buttons).toHaveLength(2);
|
expect(buttons).toHaveLength(2);
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
@@ -295,9 +309,7 @@ describe("Header", () => {
|
|||||||
render(<Header whatsAppContexts={contexts} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
whatsAppHref(
|
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
|
||||||
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
|
||||||
),
|
|
||||||
).toContain("Quero conversar sobre um projeto.");
|
).toContain("Quero conversar sobre um projeto.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,8 @@ import WhatsAppLink from "./WhatsAppLink";
|
|||||||
|
|
||||||
const MENU_ID = "site-navigation-menu";
|
const MENU_ID = "site-navigation-menu";
|
||||||
const CTA_LABEL = "Falar sobre meu projeto";
|
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). */
|
/** 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"];
|
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. */
|
/** 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
|
* 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({
|
function ContactCta({
|
||||||
className,
|
className,
|
||||||
@@ -78,7 +81,7 @@ function ContactCta({
|
|||||||
placement={placement}
|
placement={placement}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
{CTA_LABEL}
|
{WHATSAPP_LABEL}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,7 +221,7 @@ export function Header({
|
|||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href={item.href}
|
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}
|
{item.label}
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
@@ -230,7 +233,7 @@ export function Header({
|
|||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<div className="hidden xl:block" data-menu-trap-skip="">
|
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||||
<ContactCta
|
<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"
|
placement="header"
|
||||||
viaWhatsApp={viaWhatsApp}
|
viaWhatsApp={viaWhatsApp}
|
||||||
subject={subject}
|
subject={subject}
|
||||||
@@ -262,7 +265,7 @@ export function Header({
|
|||||||
{mobileMenuOpen ? (
|
{mobileMenuOpen ? (
|
||||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
// 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.
|
// 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">
|
<div className="ds-section-shell flex flex-1 flex-col">
|
||||||
<nav
|
<nav
|
||||||
id={MENU_ID}
|
id={MENU_ID}
|
||||||
@@ -274,7 +277,7 @@ export function Header({
|
|||||||
<li key={item.href} className="border-b border-white/10">
|
<li key={item.href} className="border-b border-white/10">
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href={item.href}
|
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}
|
onClick={closeMobileMenu}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -284,7 +287,7 @@ export function Header({
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="mt-auto pt-sp-8">
|
<div className="mt-auto pt-sp-8">
|
||||||
<ContactCta
|
<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"
|
placement="header_menu"
|
||||||
viaWhatsApp={viaWhatsApp}
|
viaWhatsApp={viaWhatsApp}
|
||||||
subject={subject}
|
subject={subject}
|
||||||
|
|||||||
@@ -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),
|
* 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", () => ({
|
jest.mock("./cinematic/HeroField", () => ({
|
||||||
@@ -107,25 +107,46 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
|
|||||||
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
|
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
|
||||||
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", () => {
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
|
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
|
||||||
).toHaveAttribute("id", "hero-contact-cta");
|
).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(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -36,49 +36,76 @@ describe("Hero", () => {
|
|||||||
jest.restoreAllMocks();
|
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 />);
|
render(<Hero />);
|
||||||
|
|
||||||
const contactLink = screen.getByRole("link", {
|
const contactLink = screen.getByRole("link", {
|
||||||
name: /falar sobre meu projeto/i,
|
name: /falar sobre meu projeto/i,
|
||||||
});
|
});
|
||||||
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
expect(contactLink.getAttribute("href")).toMatch(
|
expect(contactLink).toHaveAttribute("href", "/#contact-form");
|
||||||
/^https:\/\/wa\.me\/\d+\?/,
|
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).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.className).toContain("bg-[var(--slash)]");
|
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 });
|
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
||||||
expect(projects).toHaveAttribute("href", "/cases");
|
expect(projects).toHaveAttribute("href", "/cases");
|
||||||
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
||||||
|
expect(projects).toHaveClass("cv-outline-dark");
|
||||||
expect(projects).not.toHaveAttribute("target");
|
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", () => {
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
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 />);
|
render(<Hero />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
const highlighted = Array.from(
|
const highlighted = Array.from(
|
||||||
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
|
heading.querySelectorAll<HTMLElement>(".hero-word"),
|
||||||
)
|
).filter((word) => word.className.includes("text-[var(--slash)]"));
|
||||||
.filter((word) => word.className.includes("text-[var(--slash)]"))
|
expect(highlighted.map((word) => word.textContent)).toEqual([
|
||||||
.map((word) => word.textContent);
|
"sob",
|
||||||
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
|
"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", () => {
|
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
|
||||||
@@ -106,11 +133,22 @@ describe("Hero", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
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();
|
).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(
|
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");
|
).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", () => {
|
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
|
||||||
render(<Hero clients={clients} />);
|
const { container } = render(<Hero clients={clients} />);
|
||||||
|
|
||||||
const work = screen.getByTestId("hero-work");
|
const piece = container.querySelector(".hero-piece");
|
||||||
expect(work).toHaveClass("hidden", "lg:block");
|
expect(piece).not.toBeNull();
|
||||||
const images = work.querySelectorAll("img");
|
expect(piece).toHaveAttribute("aria-hidden", "true");
|
||||||
expect(images).toHaveLength(2);
|
const image = piece?.querySelector("img");
|
||||||
for (const image of images) {
|
expect(image?.getAttribute("src")).toContain("hero-poster");
|
||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("alt", "");
|
||||||
}
|
|
||||||
// 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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a wide card to the SEV case below the buttons on small screens", () => {
|
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", () => {
|
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(
|
const label = container.querySelector(".hero-kicker") as HTMLElement;
|
||||||
/Evandro Fonseca Junior · Engenharia de software/,
|
expect(label.textContent).toBe(
|
||||||
|
"Evandro Fonseca Junior · Engenharia de software",
|
||||||
);
|
);
|
||||||
expect(label.className).toContain("font-display");
|
expect(label.className).toContain("font-display");
|
||||||
expect(label.className).not.toContain("font-mono");
|
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)", () => {
|
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
|
||||||
|
|||||||
+121
-45
@@ -1,16 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { type CSSProperties, Fragment } from "react";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||||
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||||
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
import { textLinkDark } from "./button-styles";
|
||||||
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
||||||
import HeroField from "./cinematic/HeroField";
|
import HeroField from "./cinematic/HeroField";
|
||||||
|
import { CONTACT_FORM_HASH } from "./contact-subject";
|
||||||
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
||||||
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
import HeroWork from "./HeroWork";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { HERO_CAPTIONS } from "./hero-captions";
|
import { HERO_CAPTIONS } from "./hero-captions";
|
||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{
|
{
|
||||||
@@ -33,28 +37,58 @@ export type HeroStats = {
|
|||||||
years?: number;
|
years?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
|
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
|
||||||
const ACCENT = new Set([5, 6, 7]);
|
const ACCENT = new Set([3, 4]);
|
||||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
|
||||||
|
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 =
|
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(" ")
|
.split(" ")
|
||||||
.map((word, index) => ({
|
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
|
||||||
word,
|
(runs, word, index) => {
|
||||||
// a mesma palavra se repete ("e"), então a posição entra na chave
|
const accent = ACCENT.has(index);
|
||||||
key: `${word}-${index}`,
|
// a mesma palavra se repete ("e"), então a posição entra na chave
|
||||||
accent: ACCENT.has(index),
|
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({
|
export default function Hero({
|
||||||
stats,
|
stats,
|
||||||
clients,
|
clients,
|
||||||
workUrl,
|
|
||||||
}: {
|
}: {
|
||||||
stats?: HeroStats;
|
stats?: HeroStats;
|
||||||
clients?: ClientLink[];
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -63,8 +97,9 @@ export default function Hero({
|
|||||||
>
|
>
|
||||||
<div className="hero-stage">
|
<div className="hero-stage">
|
||||||
<div className="hero-glow" aria-hidden="true" />
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
<HeroField />
|
|
||||||
<div className="hero-scrim" aria-hidden="true" />
|
<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). */}
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
<ol className="hero-captions">
|
<ol className="hero-captions">
|
||||||
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
||||||
@@ -81,38 +116,48 @@ export default function Hero({
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
|
{/* 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. */}
|
{/* 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)]">
|
{/* 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. */}
|
||||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
<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)]">
|
||||||
Evandro Fonseca Junior · Engenharia de software
|
<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>
|
</p>
|
||||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
{/* 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. */}
|
{/* 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 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%]">
|
<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(({ word, key, accent }, index) => (
|
{TITLE.map(({ key, accent, words }) => (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
<span
|
{accent ? (
|
||||||
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
|
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
|
||||||
style={{ "--i": index } as CSSProperties}
|
<span
|
||||||
>
|
className="hero-mark whitespace-nowrap"
|
||||||
{word}
|
style={{ "--i": words[0].index } as CSSProperties}
|
||||||
</span>{" "}
|
>
|
||||||
|
<TitleWords words={words} accent />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<TitleWords words={words} accent={false} />
|
||||||
|
)}{" "}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</h1>
|
</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%)]">
|
<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 catálogo com pedido no WhatsApp ao painel da sua equipe. Você
|
Do site institucional ao sistema que a sua equipe usa todo dia. Você
|
||||||
fala com quem desenvolve.
|
fala com quem desenvolve.
|
||||||
</p>
|
</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">
|
<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">
|
||||||
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
|
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
|
||||||
<WhatsAppLink
|
<HoverPrefetchLink
|
||||||
id={HERO_CTA_ID}
|
href={`/${CONTACT_FORM_HASH}`}
|
||||||
service={HOME_SUBJECT}
|
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]"
|
||||||
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]"
|
|
||||||
>
|
>
|
||||||
Falar sobre meu projeto
|
Falar sobre meu projeto
|
||||||
<svg
|
<svg
|
||||||
@@ -125,28 +170,59 @@ export default function Hero({
|
|||||||
>
|
>
|
||||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</WhatsAppLink>
|
</HoverPrefetchLink>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href="/cases"
|
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
|
Ver projetos entregues
|
||||||
</HoverPrefetchLink>
|
</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>
|
</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
|
<ClientStrip
|
||||||
clients={clients}
|
clients={clients}
|
||||||
years={stats?.years}
|
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>
|
||||||
|
|
||||||
<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">
|
<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 */}
|
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo; no
|
||||||
<div className="max-lg:hidden">
|
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)]">
|
<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(
|
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
|
||||||
(service) => (
|
(service) => (
|
||||||
@@ -185,7 +261,7 @@ export default function Hero({
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
aria-hidden="true"
|
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
|
Role para mergulhar
|
||||||
<span className="hero-cue-line" />
|
<span className="hero-cue-line" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import HeroWork from "./HeroWork";
|
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 pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
|
||||||
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
|
* 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) =>
|
const original = (image: Element) =>
|
||||||
@@ -12,23 +12,9 @@ const original = (image: Element) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("HeroWork: pôsteres no tamanho certo", () => {
|
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 />);
|
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
|
// 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
|
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
|
||||||
const small = screen
|
const small = screen
|
||||||
@@ -55,23 +41,11 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
|
|||||||
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
|
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", () => {
|
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 { container } = render(<HeroWork />);
|
||||||
|
|
||||||
const images = container.querySelectorAll("img");
|
const images = container.querySelectorAll("img");
|
||||||
expect(images).toHaveLength(3);
|
expect(images).toHaveLength(1);
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
expect(image).toHaveAttribute("fetchpriority", "low");
|
expect(image).toHaveAttribute("fetchpriority", "low");
|
||||||
@@ -82,13 +56,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroWork: cartão do celular", () => {
|
describe("HeroWork: cartão do celular", () => {
|
||||||
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
|
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
|
||||||
render(<HeroWork />);
|
const { container } = render(<HeroWork />);
|
||||||
|
|
||||||
const column = screen.getByTestId("hero-work");
|
|
||||||
expect(column).toHaveClass("hidden", "lg:block");
|
|
||||||
|
|
||||||
const card = screen.getByTestId("hero-work-mobile");
|
const card = screen.getByTestId("hero-work-mobile");
|
||||||
|
expect(container.firstChild).toBe(card);
|
||||||
|
expect(container.childElementCount).toBe(1);
|
||||||
expect(card).toHaveClass("lg:hidden", "w-full");
|
expect(card).toHaveClass("lg:hidden", "w-full");
|
||||||
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
||||||
expect(card.querySelector(".ds-phone")).toBeNull();
|
expect(card.querySelector(".ds-phone")).toBeNull();
|
||||||
@@ -119,21 +92,4 @@ describe("HeroWork: cartão do celular", () => {
|
|||||||
|
|
||||||
expect(screen.getAllByRole("link")).toHaveLength(1);
|
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
@@ -1,16 +1,8 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
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). */
|
/** O cartão mostra o site da SEV e leva ao case. */
|
||||||
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. */
|
|
||||||
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
||||||
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
|
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
|
||||||
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
|
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%]";
|
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
|
* 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
|
||||||
* 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
|
* 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
|
||||||
* 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
|
* 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
|
||||||
* (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
|
* real começa na seção Projetos.
|
||||||
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
|
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
|
||||||
* 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
|
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
|
||||||
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
|
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
export default function HeroWork() {
|
||||||
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
|
||||||
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
|
||||||
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HoverPrefetchLink
|
||||||
<div
|
data-testid="hero-work-mobile"
|
||||||
data-testid="hero-work"
|
href={SMALL_SCREEN_HREF}
|
||||||
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
|
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%]">
|
<Image
|
||||||
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
|
src={smallCard.poster}
|
||||||
<Image
|
alt=""
|
||||||
src={desktop.poster}
|
width={smallCard.width}
|
||||||
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
|
height={smallCard.height}
|
||||||
width={desktop.width}
|
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
|
||||||
height={desktop.height}
|
loading="lazy"
|
||||||
sizes="(min-width: 1400px) 500px, 36vw"
|
fetchPriority="low"
|
||||||
loading="lazy"
|
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
|
||||||
fetchPriority="low"
|
/>
|
||||||
className="block h-auto w-full"
|
</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]">
|
||||||
</BrowserFrame>
|
<span className="flex flex-col">
|
||||||
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
|
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
||||||
<PhoneFrame
|
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
|
||||||
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}`}
|
|
||||||
/>
|
|
||||||
</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]">
|
<svg
|
||||||
<span className="flex flex-col">
|
aria-hidden="true"
|
||||||
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
viewBox="0 0 16 16"
|
||||||
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
|
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||||
</span>
|
fill="none"
|
||||||
<svg
|
stroke="currentColor"
|
||||||
aria-hidden="true"
|
strokeWidth="2"
|
||||||
viewBox="0 0 16 16"
|
>
|
||||||
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
fill="none"
|
</svg>
|
||||||
stroke="currentColor"
|
</span>
|
||||||
strokeWidth="2"
|
</HoverPrefetchLink>
|
||||||
>
|
|
||||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</HoverPrefetchLink>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import "./logo.css";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
theme?: "dark" | "light";
|
theme?: "dark" | "light";
|
||||||
@@ -6,20 +7,56 @@ interface LogoProps {
|
|||||||
loading?: "eager" | "lazy";
|
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({
|
export function Logo({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
className = "w-48",
|
className = "w-48",
|
||||||
loading = "lazy",
|
loading = "lazy",
|
||||||
}: Readonly<LogoProps>) {
|
}: Readonly<LogoProps>) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<span className={`relative block max-w-full ${className}`}>
|
||||||
src={`/brand/wordmark-${theme}.webp`}
|
<svg
|
||||||
alt="Evandro.dev.br"
|
aria-hidden="true"
|
||||||
width={1600}
|
focusable="false"
|
||||||
height={330}
|
viewBox="0 0 360 330"
|
||||||
sizes="240px"
|
preserveAspectRatio="none"
|
||||||
loading={loading}
|
className="absolute top-0 left-0 h-full w-[22.5%] overflow-visible"
|
||||||
className={`block h-auto max-w-full ${className}`}
|
>
|
||||||
/>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -178,7 +178,7 @@ describe("Section", () => {
|
|||||||
"ds-section-description",
|
"ds-section-description",
|
||||||
"ds-reveal",
|
"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
|
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
|
||||||
const side = screen.getByText("lateral").parentElement;
|
const side = screen.getByText("lateral").parentElement;
|
||||||
expect(side?.tagName).toBe("DIV");
|
expect(side?.tagName).toBe("DIV");
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ describe("Section", () => {
|
|||||||
</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-description")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ export const Section = ({
|
|||||||
>
|
>
|
||||||
<div className="ds-section-meta">
|
<div className="ds-section-meta">
|
||||||
{eyebrow && (
|
{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}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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 ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
|
* `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo"
|
||||||
* 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.
|
* 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).
|
* 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 }));
|
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) =>
|
const original = (image: Element) =>
|
||||||
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||||
"url",
|
"url",
|
||||||
@@ -43,20 +56,20 @@ describe("WorkModalities: movimento", () => {
|
|||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||||
expect(reveals).toHaveLength(3);
|
expect(reveals).toHaveLength(4);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
||||||
).toEqual(["20px", "20px", "20px"]);
|
).toEqual(["20px", "20px", "20px", "20px"]);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) =>
|
reveals.map((element) =>
|
||||||
element.style.getPropertyValue("--reveal-duration"),
|
element.style.getPropertyValue("--reveal-duration"),
|
||||||
),
|
),
|
||||||
).toEqual(["0.75s", "0.75s", "0.75s"]);
|
).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) =>
|
reveals.map((element) =>
|
||||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
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
|
// o estado escondido vive no CSS: sem opacidade nem transformação inline
|
||||||
for (const element of reveals) {
|
for (const element of reveals) {
|
||||||
expect(element.style.opacity).toBe("");
|
expect(element.style.opacity).toBe("");
|
||||||
@@ -68,7 +81,7 @@ describe("WorkModalities: movimento", () => {
|
|||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const cards = container.querySelectorAll("article");
|
const cards = container.querySelectorAll("article");
|
||||||
expect(cards).toHaveLength(3);
|
expect(cards).toHaveLength(4);
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
expect(card).not.toHaveAttribute("tabindex");
|
expect(card).not.toHaveAttribute("tabindex");
|
||||||
expect(card.className).not.toContain("outline-none");
|
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", () => {
|
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 />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
@@ -98,7 +111,7 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
expect(articles).toHaveLength(3);
|
expect(articles).toHaveLength(4);
|
||||||
const titles = articles.map(
|
const titles = articles.map(
|
||||||
(article) =>
|
(article) =>
|
||||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||||
@@ -106,10 +119,24 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
expect(titles).toEqual([
|
expect(titles).toEqual([
|
||||||
"Site que traz pedidos pelo WhatsApp",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Atendimento no WhatsApp com IA sob controle",
|
||||||
"Revisão de sistema que já existe",
|
"Revisão de sistema que já existe",
|
||||||
]);
|
]);
|
||||||
// a fonte é uma só, a mesma de /servicos
|
// a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão
|
||||||
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
|
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", () => {
|
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);
|
).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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const [site, system, review] = Array.from(
|
const [site, system, whatsapp, review] = Array.from(
|
||||||
container.querySelectorAll("article"),
|
container.querySelectorAll("article"),
|
||||||
);
|
);
|
||||||
const siteImage = within(site).getByRole("img");
|
const siteImage = within(site).getByRole("img");
|
||||||
@@ -137,55 +164,114 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
);
|
);
|
||||||
expect(siteImage).toHaveAttribute(
|
expect(siteImage).toHaveAttribute(
|
||||||
"sizes",
|
"sizes",
|
||||||
"(min-width: 1024px) 33vw, 100vw",
|
"(min-width: 1024px) 50vw, 100vw",
|
||||||
);
|
);
|
||||||
expect(siteImage).toHaveAttribute("loading", "lazy");
|
expect(siteImage).toHaveAttribute("loading", "lazy");
|
||||||
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
|
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
|
||||||
expect(system.querySelector("img")).toBeNull();
|
for (const card of [system, whatsapp, review]) {
|
||||||
expect(review.querySelector("img")).toBeNull();
|
expect(card.querySelector("img")).toBeNull();
|
||||||
|
}
|
||||||
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
|
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 />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
expect(screen.queryByTestId("offer-icon")).toBeNull();
|
expect(screen.queryByTestId("offer-icon")).toBeNull();
|
||||||
const boxes = ["site", "sistema", "revisao"].map((id) =>
|
const boxes = HOME_OFFERS.map(({ id }) =>
|
||||||
screen.getByTestId(`offer-art-${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);
|
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
|
||||||
for (const box of boxes) {
|
const frames = boxes.map((box) => box.firstElementChild as HTMLElement);
|
||||||
expect(box.className).toContain("aspect-[16/10]");
|
expect(new Set(frames.map((frame) => frame.className)).size).toBe(1);
|
||||||
expect(box.className).toContain("md:aspect-[21/9]");
|
for (const frame of frames) {
|
||||||
expect(box.className).toContain("lg:aspect-[16/10]");
|
expect(frame.className).toContain("aspect-[16/10]");
|
||||||
expect(box.firstElementChild).not.toBeNull();
|
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'", () => {
|
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 />);
|
render(<WorkModalities />);
|
||||||
|
|
||||||
@@ -206,26 +292,21 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
for (const severity of ["Alta", "Média"]) {
|
for (const severity of ["Alta", "Média"]) {
|
||||||
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
|
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\]/);
|
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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const [, system, review] = Array.from(
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
container.querySelectorAll("article"),
|
HOME_OFFERS.forEach((offer, index) => {
|
||||||
);
|
expect(
|
||||||
expect(
|
within(articles[index])
|
||||||
within(system)
|
.getAllByRole("listitem")
|
||||||
.getAllByRole("listitem")
|
.map((item) => item.textContent),
|
||||||
.map((item) => item.textContent),
|
).toEqual(offer.deliverables);
|
||||||
).toEqual(serviceOffers[1].deliverables);
|
});
|
||||||
expect(
|
|
||||||
within(review)
|
|
||||||
.getAllByRole("listitem")
|
|
||||||
.map((item) => item.textContent),
|
|
||||||
).toEqual(serviceOffers[2].deliverables);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: every card says what you receive, with three or four items, once", () => {
|
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 />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
for (const card of container.querySelectorAll("article")) {
|
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);
|
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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
const buttons = [
|
const buttons = [
|
||||||
["Começar pelo site", "/?tipo=site#contact-form"],
|
["Começar pelo site", "/?tipo=site#contact-form"],
|
||||||
["Começar pelo sistema", "/?tipo=sistema#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"],
|
["Começar pela revisão", "/?tipo=revisao#contact-form"],
|
||||||
] as const;
|
] as const;
|
||||||
buttons.forEach(([name, href], index) => {
|
buttons.forEach(([name, href], index) => {
|
||||||
@@ -292,9 +374,19 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
expect(
|
expect(
|
||||||
within(articles[2]).getByRole("link", {
|
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",
|
name: "Ver como funciona a revisão",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/ia");
|
).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", () => {
|
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",
|
name: "Ver todos os serviços",
|
||||||
});
|
});
|
||||||
expect(services).toHaveAttribute("href", "/servicos");
|
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
|
// o link fica fora dos cartões, depois da grade
|
||||||
expect(services.closest("article")).toBeNull();
|
expect(services.closest("article")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getAllByRole("link")
|
.getAllByRole("link")
|
||||||
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
|
.filter((link) => link.getAttribute("href") === "/servicos"),
|
||||||
).toEqual([services]);
|
).toEqual([services]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkModalities: um só estilo de cartão", () => {
|
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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,28 +7,35 @@ import {
|
|||||||
} from "@/lib/site-content";
|
} from "@/lib/site-content";
|
||||||
import { primaryButton, textLink } from "./button-styles";
|
import { primaryButton, textLink } from "./button-styles";
|
||||||
import ContactOfferLink from "./ContactOfferLink";
|
import ContactOfferLink from "./ContactOfferLink";
|
||||||
|
import { FiscalNotice } from "./FiscalNotice";
|
||||||
|
import { isFiscalNoticeActive } from "./fiscal-notice";
|
||||||
import "./conversion.css";
|
import "./conversion.css";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { OfferVideo } from "./OfferVideo";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
import { Section } from "./Section";
|
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. */
|
/** 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 {
|
interface OfferExtras {
|
||||||
description: string;
|
description: string;
|
||||||
ctaLabel: 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 };
|
poster?: { id: string; title: string };
|
||||||
moreLabel: string;
|
moreLabel: string;
|
||||||
moreHref: 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
|
* 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
|
||||||
* 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
|
* 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
|
||||||
* 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
|
* 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
|
||||||
* 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.
|
* 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: {
|
site: {
|
||||||
description:
|
description:
|
||||||
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
|
"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",
|
moreLabel: "Ver case SEV Moda Íntima",
|
||||||
moreHref: "/cases/sev-modaintima",
|
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: {
|
revisao: {
|
||||||
description:
|
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.",
|
"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 = [
|
const PROCESS = [
|
||||||
{
|
{
|
||||||
title: "Entender a operação",
|
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
|
* 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.
|
* 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. */
|
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
|
||||||
const SEVERITY_BADGE =
|
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
|
* 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
|
||||||
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
|
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
|
||||||
* numa linha só no cartão mais estreito (três colunas em 1024 px).
|
* 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() {
|
function ReviewArt() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
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"
|
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
|
<ul
|
||||||
aria-hidden="true"
|
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 }) => (
|
{REPORT_EXCERPT.map(({ finding, severity }) => (
|
||||||
<li
|
<li
|
||||||
key={finding}
|
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 className="min-w-0">{finding}</span>
|
||||||
<span
|
<span
|
||||||
@@ -243,7 +147,7 @@ function ReviewArt() {
|
|||||||
</ul>
|
</ul>
|
||||||
<p
|
<p
|
||||||
aria-hidden="true"
|
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
|
Exemplo ilustrativo
|
||||||
</p>
|
</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({
|
function OfferArt({
|
||||||
offer,
|
offer,
|
||||||
extras,
|
extras,
|
||||||
@@ -262,21 +170,40 @@ function OfferArt({
|
|||||||
|
|
||||||
if (poster && extras.poster) {
|
if (poster && extras.poster) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<div className={MEDIA_FRAME}>
|
||||||
src={poster.poster}
|
<Image
|
||||||
alt={`Site ${extras.poster.title} aberto no computador`}
|
src={poster.poster}
|
||||||
width={poster.width}
|
alt={`Site ${extras.poster.title} aberto no computador`}
|
||||||
height={poster.height}
|
width={poster.width}
|
||||||
sizes="(min-width: 1024px) 33vw, 100vw"
|
height={poster.height}
|
||||||
loading="lazy"
|
sizes="(min-width: 1024px) 50vw, 100vw"
|
||||||
className="block h-full w-full object-cover object-top"
|
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({
|
function OfferCard({
|
||||||
offer,
|
offer,
|
||||||
extras,
|
extras,
|
||||||
@@ -285,7 +212,7 @@ function OfferCard({
|
|||||||
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
data-testid={`offer-art-${offer.id}`}
|
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} />
|
<OfferArt offer={offer} extras={extras} />
|
||||||
</div>
|
</div>
|
||||||
@@ -346,16 +273,22 @@ function OfferCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkModalities() {
|
export function WorkModalities() {
|
||||||
|
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="modalities"
|
id="modalities"
|
||||||
eyebrow="Soluções"
|
eyebrow="Soluções"
|
||||||
title="Escolha por onde começar"
|
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
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
|
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
|
||||||
{serviceOffers.map((offer, index) => (
|
{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
|
<Reveal
|
||||||
key={offer.id}
|
key={offer.id}
|
||||||
y={20}
|
y={20}
|
||||||
@@ -363,12 +296,12 @@ export function WorkModalities() {
|
|||||||
delay={0.08 * (index + 1)}
|
delay={0.08 * (index + 1)}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
|
<OfferCard offer={offer} extras={extras} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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)]">
|
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
|
||||||
Primeiro passo
|
Primeiro passo
|
||||||
|
|||||||
@@ -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;
|
* 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.
|
* 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 =
|
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. */
|
/** 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. */
|
/** 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 =
|
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. */
|
/** Link de texto sobre fundo claro. */
|
||||||
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
|
export const textLink = textLinkBase;
|
||||||
|
|
||||||
/** Link de texto sobre fundo escuro. */
|
/** 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),
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
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();
|
const [data] = structuredData();
|
||||||
|
|
||||||
expect(data["@context"]).toBe("https://schema.org");
|
expect(data["@context"]).toBe("https://schema.org");
|
||||||
expect(data["@graph"]).toHaveLength(3);
|
expect(data["@graph"]).toHaveLength(5);
|
||||||
for (const offer of data["@graph"]) {
|
for (const offer of data["@graph"]) {
|
||||||
expect(offer["@type"]).toBe("Service");
|
expect(offer["@type"]).toBe("Service");
|
||||||
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
|
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
|
|||||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
|
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
|
||||||
* 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.
|
* 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.
|
* 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);
|
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", () => {
|
describe("CinematicHome: props e estrutura", () => {
|
||||||
it("path: every optional prop given -> each scene receives its own data", () => {
|
it("path: every optional prop given -> each scene receives its own data", () => {
|
||||||
const projects = [project("lavacao-047", "https://a.example.com")];
|
const projects = [project("lavacao-047", "https://a.example.com")];
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import Contact from "../Contact";
|
|||||||
import Hero, { type HeroStats } from "../Hero";
|
import Hero, { type HeroStats } from "../Hero";
|
||||||
import HomeFaq from "../HomeFaq";
|
import HomeFaq from "../HomeFaq";
|
||||||
import Projects from "../Projects";
|
import Projects from "../Projects";
|
||||||
import { displayUrl } from "../showcase/BrowserFrame";
|
|
||||||
import Testimonials from "../Testimonials";
|
import Testimonials from "../Testimonials";
|
||||||
import { WorkModalities } from "../WorkModalities";
|
import { WorkModalities } from "../WorkModalities";
|
||||||
import CinematicMain from "./CinematicMain";
|
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:
|
* 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
|
* 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[];
|
clients?: ClientLink[];
|
||||||
testimonials?: Testimonial[];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
<CinematicMain>
|
<CinematicMain>
|
||||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
<Hero stats={heroStats} clients={clients} />
|
||||||
|
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,19 @@ function MockScene(props: SceneProps) {
|
|||||||
return require("react").createElement("canvas", { "data-testid": "scene" });
|
return require("react").createElement("canvas", { "data-testid": "scene" });
|
||||||
}
|
}
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
||||||
get default() {
|
get default() {
|
||||||
if (mockImportFails) throw new Error("chunk indisponível");
|
if (mockImportFails) throw new Error("chunk indisponível");
|
||||||
return MockScene;
|
return MockScene;
|
||||||
},
|
},
|
||||||
get prepareField() {
|
get prepareBlocos() {
|
||||||
if (mockImportFails) throw new Error("chunk indisponível");
|
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 () => {
|
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 }));
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||||
setContext(probe);
|
setContext(probe);
|
||||||
const addListener = jest.spyOn(window, "addEventListener");
|
const addListener = jest.spyOn(window, "addEventListener");
|
||||||
@@ -539,7 +542,7 @@ describe("HeroField: rolagem e visibilidade", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroField: navegador sem matchMedia", () => {
|
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;
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { act, render, screen } from "@testing-library/react";
|
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";
|
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||||
|
|
||||||
type SceneProps = {
|
type SceneProps = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
progressRef: { current: number };
|
progressRef: { current: number };
|
||||||
|
placementRef?: { current: unknown };
|
||||||
|
stageRef?: { current: unknown };
|
||||||
|
tier?: string;
|
||||||
labelsRef?: unknown;
|
labelsRef?: unknown;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
|
onEstado?: (estado: number | null) => void;
|
||||||
};
|
};
|
||||||
const mockScenes: SceneProps[] = [];
|
const mockScenes: SceneProps[] = [];
|
||||||
const mockPrepare = jest.fn();
|
const mockPrepare = jest.fn();
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: function MockScene(props: SceneProps) {
|
default: function MockScene(props: SceneProps) {
|
||||||
mockScenes.push(props);
|
mockScenes.push(props);
|
||||||
@@ -20,7 +24,10 @@ jest.mock("./HeroFieldScene", () => ({
|
|||||||
"data-testid": "scene",
|
"data-testid": "scene",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
prepareField: () => mockPrepare(),
|
prepareBlocos: () => {
|
||||||
|
mockPrepare();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const original = {
|
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`. */
|
/** 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;
|
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() {
|
function Hero() {
|
||||||
return (
|
return (
|
||||||
<section className="hero" style={HERO_TAIL}>
|
<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>
|
</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;
|
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. */
|
/** 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(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
mockLayout();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
mockScenes.length = 0;
|
mockScenes.length = 0;
|
||||||
mockPrepare.mockClear();
|
mockPrepare.mockClear();
|
||||||
@@ -101,6 +136,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
window.matchMedia = original.matchMedia;
|
window.matchMedia = original.matchMedia;
|
||||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||||
@@ -108,7 +144,7 @@ afterEach(() => {
|
|||||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
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 () => {
|
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
expect(screen.queryByTestId("scene")).toBeNull();
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
@@ -175,7 +211,13 @@ describe("HeroField (ambient background)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each<[string, () => void]>([
|
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)"])],
|
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||||
@@ -187,13 +229,14 @@ describe("HeroField (ambient background)", () => {
|
|||||||
undefined;
|
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",
|
"a software WebGL found earlier in the session",
|
||||||
() => rememberSoftwareWebGL(),
|
() => 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) => {
|
async (_label, arrange) => {
|
||||||
arrange();
|
arrange();
|
||||||
const { container } = render(<Hero />);
|
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 { container } = render(<Hero />);
|
||||||
const hero = container.querySelector(".hero") as HTMLElement;
|
const hero = container.querySelector(".hero") as HTMLElement;
|
||||||
expect(hero).toHaveAttribute("data-dive");
|
expect(hero).toHaveAttribute("data-dive");
|
||||||
@@ -237,15 +280,13 @@ describe("HeroField (ambient background)", () => {
|
|||||||
|
|
||||||
scroll(700); // meio do mergulho (1400 px de trilho)
|
scroll(700); // meio do mergulho (1400 px de trilho)
|
||||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
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);
|
scroll(1400);
|
||||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
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([]);
|
mockMedia([]);
|
||||||
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
@@ -277,4 +318,134 @@ describe("HeroField (ambient background)", () => {
|
|||||||
expect(section()).not.toHaveAttribute("data-dive");
|
expect(section()).not.toHaveAttribute("data-dive");
|
||||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
|
import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos";
|
||||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
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. */
|
/** 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 =
|
export const DIVE_QUERY =
|
||||||
@@ -28,6 +36,13 @@ function heroTailScreens(section: HTMLElement) {
|
|||||||
const IDLE_SLACK_MS = 20;
|
const IDLE_SLACK_MS = 20;
|
||||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||||
const IDLE_PATIENCE_MS = 4000;
|
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. */
|
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
|
||||||
const FIRST_ACTIVITY = [
|
const FIRST_ACTIVITY = [
|
||||||
"pointerdown",
|
"pointerdown",
|
||||||
@@ -38,15 +53,10 @@ const FIRST_ACTIVITY = [
|
|||||||
"scroll",
|
"scroll",
|
||||||
] as const;
|
] 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
|
* 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,
|
* 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.
|
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
||||||
*/
|
*/
|
||||||
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
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
|
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, os ativos (bloco, estados e
|
||||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
|
* 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) {
|
async function loadScene(isAlive: () => boolean) {
|
||||||
const scene = await import("./HeroFieldScene");
|
const scene = await import("./BlocosScene");
|
||||||
await nextIdle();
|
await nextIdle();
|
||||||
if (!isAlive()) return null;
|
if (!isAlive()) return null;
|
||||||
scene.prepareField();
|
await scene.prepareBlocos();
|
||||||
await nextIdle();
|
await nextIdle();
|
||||||
return isAlive() ? scene.default : null;
|
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
|
* 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
|
||||||
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
|
* (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.
|
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
|
||||||
*/
|
*/
|
||||||
function trackDive(
|
function trackDive(
|
||||||
@@ -129,8 +204,6 @@ function trackDive(
|
|||||||
if (p === progress.current) return;
|
if (p === progress.current) return;
|
||||||
progress.current = p;
|
progress.current = p;
|
||||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
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);
|
onDone(p >= 0.999);
|
||||||
};
|
};
|
||||||
const queue = () => {
|
const queue = () => {
|
||||||
@@ -145,7 +218,6 @@ function trackDive(
|
|||||||
window.removeEventListener("resize", queue);
|
window.removeEventListener("resize", queue);
|
||||||
delete section.dataset.dive;
|
delete section.dataset.dive;
|
||||||
section.style.removeProperty("--hero-p");
|
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,
|
* A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o
|
||||||
* 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
|
* primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho
|
||||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
* por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do
|
||||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
* Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote,
|
||||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
* ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS):
|
||||||
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
|
* - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do
|
||||||
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
|
* hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das
|
||||||
* é das molduras.
|
* 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() {
|
export default function HeroField() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const progressRef = useRef(0);
|
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 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,
|
null,
|
||||||
);
|
);
|
||||||
|
const [legendaOn, setLegendaOn] = useState(false);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [failed, setFailed] = 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);
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLowPowerDevice()) return;
|
const tier = sceneTier();
|
||||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
if (tier === "static") return;
|
||||||
if (!section) return;
|
const root = rootRef.current;
|
||||||
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
|
const section = root?.closest<HTMLElement>(".hero");
|
||||||
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
|
if (!root || !section) return;
|
||||||
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
|
// sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, já na
|
||||||
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
|
// 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;
|
let alive = true;
|
||||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
const stopDive =
|
||||||
? trackDive(section, progressRef, setDone)
|
tier === "full" && window.matchMedia?.(DIVE_QUERY).matches
|
||||||
: undefined;
|
? trackDive(section, progressRef, setDone)
|
||||||
|
: undefined;
|
||||||
const stopVisibility = trackVisibility(section, setVisible);
|
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 = () => {
|
giveUp.current = () => {
|
||||||
stopDive?.();
|
stopDive?.();
|
||||||
if (alive) setFailed(true);
|
if (alive) setFailed(true);
|
||||||
@@ -213,7 +313,7 @@ export default function HeroField() {
|
|||||||
loadScene(() => alive)
|
loadScene(() => alive)
|
||||||
.then((component) => {
|
.then((component) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (component) setScene(() => component);
|
if (component) setMounted({ Scene: component, tier });
|
||||||
else giveUp.current();
|
else giveUp.current();
|
||||||
})
|
})
|
||||||
.catch(() => giveUp.current());
|
.catch(() => giveUp.current());
|
||||||
@@ -222,28 +322,76 @@ export default function HeroField() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
stopWaiting();
|
stopWaiting();
|
||||||
stopVisibility();
|
stopVisibility();
|
||||||
|
stopPiece();
|
||||||
stopDive?.();
|
stopDive?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReady = useCallback(() => setReady(true), []);
|
const handleReady = useCallback(() => setReady(true), []);
|
||||||
const handleError = useCallback(() => giveUp.current(), []);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="hero-field"
|
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}
|
progressRef={progressRef}
|
||||||
|
placementRef={lite ? undefined : placementRef}
|
||||||
|
stageRef={lite ? undefined : stageRef}
|
||||||
|
tier={scene.tier}
|
||||||
visible={visible && !done}
|
visible={visible && !done}
|
||||||
onReady={handleReady}
|
onReady={handleReady}
|
||||||
onError={handleError}
|
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>
|
</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
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
|
|||||||
* segue a variável), não o texto do CSS.
|
* segue a variável), não o texto do CSS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => null,
|
default: () => null,
|
||||||
prepareField: () => {},
|
prepareBlocos: () => Promise.resolve(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const win = window as unknown as Record<string, unknown>;
|
const win = window as unknown as Record<string, unknown>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ describe("contact-subject", () => {
|
|||||||
expect(CONTACT_KINDS).toEqual({
|
expect(CONTACT_KINDS).toEqual({
|
||||||
site: "Site que traz pedidos pelo WhatsApp",
|
site: "Site que traz pedidos pelo WhatsApp",
|
||||||
sistema: "Sistema para tirar a operação da planilha",
|
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",
|
revisao: "Revisão de sistema que já existe",
|
||||||
|
fiscal: "Nota fiscal pronta para a reforma tributária",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,24 +15,28 @@ import { offerLabels } from "@/lib/offer-labels";
|
|||||||
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
|
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.
|
* 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 = [
|
export const CONTACT_SUBJECT_OPTIONS = [
|
||||||
DEFAULT_CONTACT_SUBJECT,
|
DEFAULT_CONTACT_SUBJECT,
|
||||||
offerLabels.site.name,
|
offerLabels.site.name,
|
||||||
offerLabels.sistema.name,
|
offerLabels.sistema.name,
|
||||||
|
offerLabels.whatsapp.name,
|
||||||
"Aplicativos",
|
"Aplicativos",
|
||||||
"Integrações e automações",
|
"Integrações e automações",
|
||||||
"Evolução de um sistema existente",
|
"Evolução de um sistema existente",
|
||||||
offerLabels.revisao.name,
|
offerLabels.revisao.name,
|
||||||
|
offerLabels.fiscal.name,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
|
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
|
||||||
export const CONTACT_KINDS = {
|
export const CONTACT_KINDS = {
|
||||||
site: offerLabels.site.name,
|
site: offerLabels.site.name,
|
||||||
sistema: offerLabels.sistema.name,
|
sistema: offerLabels.sistema.name,
|
||||||
|
whatsapp: offerLabels.whatsapp.name,
|
||||||
revisao: offerLabels.revisao.name,
|
revisao: offerLabels.revisao.name,
|
||||||
|
fiscal: offerLabels.fiscal.name,
|
||||||
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
|
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
|
||||||
|
|
||||||
export type ContactKind = keyof typeof CONTACT_KINDS;
|
export type ContactKind = keyof typeof CONTACT_KINDS;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
* (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`.
|
* 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
|
* 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
|
||||||
* 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
|
* 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
|
||||||
* câmera chegar e sai um pouco depois de ela partir. Assim o intervalo sem texto entre uma e outra é de poucos pixels
|
* 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
|
||||||
* de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
|
* 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 = [
|
export const HERO_CAPTIONS = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
ViewTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { coverTransitionProps } from "@/app/cases/_components/cover-transition";
|
||||||
import { isWebKit } from "@/lib/device";
|
import { isWebKit } from "@/lib/device";
|
||||||
import {
|
import {
|
||||||
chapterIndexAtScroll,
|
chapterIndexAtScroll,
|
||||||
chapterScrollFraction,
|
chapterScrollFraction,
|
||||||
getScrubSource,
|
getScrubSource,
|
||||||
getShowcaseAssets,
|
getShowcaseAssets,
|
||||||
|
projectKicker,
|
||||||
videoFractionAtScroll,
|
videoFractionAtScroll,
|
||||||
} from "@/lib/project-showcase";
|
} from "@/lib/project-showcase";
|
||||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
@@ -550,15 +553,17 @@ function ReelLayer({
|
|||||||
<div className="ds-reel-screen">
|
<div className="ds-reel-screen">
|
||||||
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
|
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
|
||||||
{poster && (
|
{poster && (
|
||||||
<Image
|
<ActiveCover id={config.id} active={isActive}>
|
||||||
src={desktop.poster}
|
<Image
|
||||||
alt=""
|
src={desktop.poster}
|
||||||
width={desktop.width}
|
alt=""
|
||||||
height={desktop.height}
|
width={desktop.width}
|
||||||
sizes={LAPTOP_POSTER_SIZES}
|
height={desktop.height}
|
||||||
loading="eager"
|
sizes={LAPTOP_POSTER_SIZES}
|
||||||
fetchPriority="low"
|
loading="eager"
|
||||||
/>
|
fetchPriority="low"
|
||||||
|
/>
|
||||||
|
</ActiveCover>
|
||||||
)}
|
)}
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
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");
|
const pad = (value: number) => String(value).padStart(2, "0");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -643,7 +671,7 @@ function ReelCaption({
|
|||||||
return (
|
return (
|
||||||
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
||||||
<p className="ds-kicker">
|
<p className="ds-kicker">
|
||||||
{project.category} · {project.status}
|
{projectKicker(project.category, project.status)}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="ds-reel-title">{project.title}</h3>
|
<h3 className="ds-reel-title">{project.title}</h3>
|
||||||
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
{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,
|
* 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", () => ({
|
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;
|
type Callback = IntersectionObserverCallback;
|
||||||
/** Cada observador criado, com o elemento que observa. */
|
/** Cada observador criado, com o elemento que observa. */
|
||||||
const observers: Array<{ callback: Callback; target?: Element }> = [];
|
const observers: Array<{ callback: Callback; target?: Element }> = [];
|
||||||
@@ -114,6 +120,19 @@ describe("ProjectList", () => {
|
|||||||
expect(images[0]).toHaveAttribute("sizes", "50vw");
|
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", () => {
|
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
|
||||||
const { container } = renderList({ device: "mobile" });
|
const { container } = renderList({ device: "mobile" });
|
||||||
bringAllNear(container);
|
bringAllNear(container);
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ import {
|
|||||||
useId,
|
useId,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
ViewTransition,
|
||||||
} from "react";
|
} 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 { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||||
import { PhoneFrame } from "./PhoneFrame";
|
import { PhoneFrame } from "./PhoneFrame";
|
||||||
import { ProjectActions } from "./ProjectActions";
|
import { ProjectActions } from "./ProjectActions";
|
||||||
@@ -78,17 +84,20 @@ function ListItem({
|
|||||||
const ref = useRef<HTMLLIElement>(null);
|
const ref = useRef<HTMLLIElement>(null);
|
||||||
const near = useNearViewport(ref);
|
const near = useNearViewport(ref);
|
||||||
const assets = device ? getShowcaseAssets(config.id, device) : null;
|
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 =
|
const poster =
|
||||||
assets && near ? (
|
assets && near ? (
|
||||||
<Image
|
<ViewTransition {...coverTransitionProps(config.id)}>
|
||||||
src={assets.poster}
|
<Image
|
||||||
alt={`Captura de tela de ${project.title}`}
|
src={assets.poster}
|
||||||
width={assets.width}
|
alt={`Captura de tela de ${project.title}`}
|
||||||
height={assets.height}
|
width={assets.width}
|
||||||
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
|
height={assets.height}
|
||||||
sizes={device === "mobile" ? "160px" : "50vw"}
|
// 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;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +120,7 @@ function ListItem({
|
|||||||
)}
|
)}
|
||||||
<div className="ds-reel-caption">
|
<div className="ds-reel-caption">
|
||||||
<p className="ds-kicker">
|
<p className="ds-kicker">
|
||||||
{project.category} · {project.status}
|
{projectKicker(project.category, project.status)}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="ds-reel-title">{project.title}</h3>
|
<h3 className="ds-reel-title">{project.title}</h3>
|
||||||
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
|
|||||||
useReducedMotion: () => reducedMotion,
|
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 PIN = 800;
|
||||||
const CONTAINER = 7000;
|
const CONTAINER = 7000;
|
||||||
const SCROLLABLE = CONTAINER - PIN;
|
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", () => {
|
it("names the chapter on screen and counts them", () => {
|
||||||
renderReel();
|
renderReel();
|
||||||
readyVideos();
|
readyVideos();
|
||||||
|
|||||||
@@ -25,12 +25,24 @@ html.ds-lb-open {
|
|||||||
background: rgba(5, 5, 5, 0.88);
|
background: rgba(5, 5, 5, 0.88);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 180ms var(--ease-in),
|
||||||
|
overlay 180ms allow-discrete,
|
||||||
|
display 180ms allow-discrete;
|
||||||
|
|
||||||
&[open] {
|
&[open] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
&::backdrop {
|
||||||
@@ -38,15 +50,10 @@ html.ds-lb-open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes ds-lb-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ds-lb,
|
||||||
.ds-lb[open] {
|
.ds-lb[open] {
|
||||||
animation: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,12 +333,23 @@ html.ds-lb-open {
|
|||||||
gap: var(--sp-2) var(--sp-3);
|
gap: var(--sp-2) var(--sp-3);
|
||||||
|
|
||||||
& .ds-link.ds-link-muted {
|
& .ds-link.ds-link-muted {
|
||||||
|
--block: rgba(255, 255, 255, 0.12);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.38);
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
background: transparent;
|
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 {
|
& .ds-link.ds-link-text {
|
||||||
color: rgba(255, 255, 255, 0.84);
|
color: rgba(255, 255, 255, 0.84);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,8 @@
|
|||||||
gap: 6px 10px;
|
gap: 6px 10px;
|
||||||
margin-bottom: var(--sp-5);
|
margin-bottom: var(--sp-5);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--slash);
|
color: var(--slash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +189,32 @@
|
|||||||
opacity: 1;
|
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) {
|
@media (min-width: 640px) {
|
||||||
.ds-reel-name {
|
.ds-reel-name {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|||||||
@@ -81,7 +81,8 @@
|
|||||||
overflow: hidden;
|
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 {
|
.ds-reel-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -90,16 +91,20 @@
|
|||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
|
|
||||||
& .ds-link.ds-link-muted {
|
& .ds-link.ds-link-muted {
|
||||||
|
--block: rgba(255, 255, 255, 0.12);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: rgba(255, 255, 255, 0.38);
|
border-color: rgba(255, 255, 255, 0.38);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .ds-link.ds-link-muted:focus-visible {
|
||||||
|
border-color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
& .ds-link.ds-link-muted:hover {
|
& .ds-link.ds-link-muted:hover {
|
||||||
border-color: #fff;
|
border-color: rgba(255, 255, 255, 0.55);
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
import { trackCvDownload } from "@/lib/analytics";
|
import { trackCvDownload } from "@/lib/analytics";
|
||||||
import { buttonLabels } from "@/lib/site-content";
|
import { buttonLabels } from "@/lib/site-content";
|
||||||
import CurriculoPage, { metadata } from "./page";
|
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 hiringLink = screen.getByRole("link", { name: buttonLabels.contact });
|
||||||
const consultingLink = screen.getByRole("link", { name: /consultoria/i });
|
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(consultingLink).toHaveAttribute("href", "/servicos");
|
||||||
expect(
|
expect(
|
||||||
hiringLink.compareDocumentPosition(consultingLink) &
|
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" });
|
const pdf = screen.getByRole("link", { name: "Baixar currículo em PDF" });
|
||||||
expect(pdf).toHaveAttribute("href", "/Profile.pdf");
|
expect(pdf).toHaveAttribute("href", "/Profile.pdf");
|
||||||
expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
|
expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
|
||||||
// limão só para o contato
|
// limão só para o contato: as classes de botão de button-styles.ts
|
||||||
expect(pdf).not.toHaveClass("ds-link-accent");
|
expect(pdf.className).toBe(secondaryButtonDark);
|
||||||
expect(screen.getByRole("link", { name: buttonLabels.contact })).toHaveClass(
|
expect(
|
||||||
"ds-link-accent",
|
screen.getByRole("link", { name: buttonLabels.contact }).className,
|
||||||
);
|
).toBe(primaryButton);
|
||||||
|
|
||||||
fireEvent.click(pdf);
|
fireEvent.click(pdf);
|
||||||
expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" });
|
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 { container } = render(CurriculoPage());
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.className).toContain("md:!pb-sp-12");
|
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(
|
expect(hero).toHaveTextContent(
|
||||||
"Garuva e Joinville (SC) · atendimento remoto em todo o Brasil",
|
"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");
|
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
@@ -1,8 +1,18 @@
|
|||||||
import type { Metadata } from "next";
|
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 { 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 HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { PagePiece } from "@/app/components/PagePiece";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { portfolio } from "@/lib/portfolio-data";
|
import { portfolio } from "@/lib/portfolio-data";
|
||||||
import {
|
import {
|
||||||
@@ -47,39 +57,38 @@ export default function CurriculoPage() {
|
|||||||
<section
|
<section
|
||||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||||
>
|
>
|
||||||
<div className="ds-section-shell">
|
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
|
||||||
<Breadcrumb
|
<div className="min-w-0">
|
||||||
items={[
|
<Breadcrumb
|
||||||
{ label: "Início", href: "/" },
|
items={[
|
||||||
{ label: "Currículo", href: "/curriculo" },
|
{ label: "Início", href: "/" },
|
||||||
]}
|
{ label: "Currículo", href: "/curriculo" },
|
||||||
/>
|
]}
|
||||||
<p className="ds-eyebrow">Currículo profissional</p>
|
/>
|
||||||
<h1 className="ds-section-title text-white">
|
<h1 className="ds-section-title text-white">
|
||||||
Engenheiro da Computação e Desenvolvedor de Software.
|
Engenheiro da Computação e Desenvolvedor de Software.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
||||||
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil. Este
|
Garuva e Joinville (SC) · atendimento remoto em todo o Brasil.
|
||||||
currículo reúne experiência profissional, formação e competências
|
Este currículo reúne experiência profissional, formação e
|
||||||
técnicas para avaliação de contratação.
|
competências técnicas para avaliação de contratação.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
className="ds-link ds-link-accent"
|
className={primaryButton}
|
||||||
href="/#contact"
|
href={`/${CONTACT_FORM_HASH}`}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.contact}
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
<CvDownloadLink className="ds-link ds-link-muted">
|
<CvDownloadLink className={secondaryButtonDark}>
|
||||||
Baixar currículo em PDF
|
Baixar currículo em PDF
|
||||||
</CvDownloadLink>
|
</CvDownloadLink>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink className={textLinkDark} href="/servicos">
|
||||||
className="ds-link ds-link-text"
|
Procurando consultoria? Ver serviços
|
||||||
href="/servicos"
|
</HoverPrefetchLink>
|
||||||
>
|
</div>
|
||||||
Procurando consultoria? Ver serviços
|
|
||||||
</HoverPrefetchLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
<PagePiece image="/3d/estado-integracoes.webp" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -101,7 +110,7 @@ export default function CurriculoPage() {
|
|||||||
<li key={achievement}>{achievement}</li>
|
<li key={achievement}>{achievement}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags !mt-sp-6">
|
||||||
{experience.technologies.map((technology) => (
|
{experience.technologies.map((technology) => (
|
||||||
<li key={technology} className="ds-tag">
|
<li key={technology} className="ds-tag">
|
||||||
{technology}
|
{technology}
|
||||||
@@ -121,7 +130,7 @@ export default function CurriculoPage() {
|
|||||||
{portfolio.skills.technical.map((category) => (
|
{portfolio.skills.technical.map((category) => (
|
||||||
<article key={category.category} className="ds-card">
|
<article key={category.category} className="ds-card">
|
||||||
<h3 className="ds-card-title">{category.category}</h3>
|
<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) => (
|
{category.skills.map((skill) => (
|
||||||
<li key={skill} className="ds-tag">
|
<li key={skill} className="ds-tag">
|
||||||
{skill}
|
{skill}
|
||||||
|
|||||||
+516
-115
@@ -32,6 +32,9 @@
|
|||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--slash: #ccff00;
|
--slash: #ccff00;
|
||||||
--slash-dim: rgba(204, 255, 0, 0.16);
|
--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;
|
--meta: #4a4a4a;
|
||||||
--border: rgba(10, 10, 10, 0.1);
|
--border: rgba(10, 10, 10, 0.1);
|
||||||
--border-light: rgba(10, 10, 10, 0.06);
|
--border-light: rgba(10, 10, 10, 0.06);
|
||||||
@@ -56,8 +59,8 @@
|
|||||||
--shell: clamp(1240px, 76vw, 1760px);
|
--shell: clamp(1240px, 76vw, 1760px);
|
||||||
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
|
--fs-section-title: min(max(clamp(24px, 4vw, 44px), 3.05vw), 68px);
|
||||||
--fs-lead: min(max(15px, 1.05vw), 19px);
|
--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,
|
/* Piso do texto corrido em qualquer tela. Os rótulos mono curtos ficam abaixo: .ds-eyebrow e .ds-kicker em 13 px,
|
||||||
.ds-metric) descem a 12 px. */
|
.ds-tag e .ds-metric em 12 px. */
|
||||||
--fs-body: 15px;
|
--fs-body: 15px;
|
||||||
/* Dois pesos de título, e só: seção (h1 e h2) e cartão (h3 e menores). */
|
/* Dois pesos de título, e só: seção (h1 e h2) e cartão (h3 e menores). */
|
||||||
--fw-section: 700;
|
--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-eyebrow,
|
||||||
.ds-kicker {
|
.ds-kicker {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--meta);
|
color: var(--meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,14 +435,6 @@ textarea {
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-transform: none;
|
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 {
|
.ds-link-accent {
|
||||||
@@ -448,43 +442,12 @@ textarea {
|
|||||||
background: var(--slash);
|
background: var(--slash);
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
font-weight: 600;
|
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) */
|
/* contorno: ação secundária (sem lima) */
|
||||||
.ds-link-muted {
|
.ds-link-muted {
|
||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
border: 1px solid rgba(10, 10, 10, 0.28);
|
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 */
|
/* link de texto: ação terciária, sublinhada, sem caixa */
|
||||||
@@ -495,9 +458,160 @@ textarea {
|
|||||||
text-underline-offset: 5px;
|
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;
|
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
|
/* 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: 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 {
|
||||||
--hero-p: 0;
|
--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;
|
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
|
/* Peça do hero: a marca em blocos renderizada no Blender (3d/blocos.py). É o primeiro quadro em qualquer aparelho; onde
|
||||||
e as molduras da direita, e opaca nas bordas e cantos. Com o mergulho (--hero-open 0 → 1) o miolo some e o
|
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
|
||||||
fundo aparece inteiro, quando o texto e a coluna da direita já saíram de cena. */
|
altura dela); de 1024 px em diante fica logo à direita da coluna do texto. `translate`, e não `transform`, porque a
|
||||||
.hero-glow::after,
|
entrada (.hero-in) anima o `transform`. */
|
||||||
.hero-field {
|
.hero-piece {
|
||||||
--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: "";
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: calc(var(--site-header-height) + clamp(8px, 2.5svh, 32px));
|
||||||
background:
|
right: var(--sp-6);
|
||||||
radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0
|
width: var(--hero-piece);
|
||||||
0 / 22px 22px;
|
pointer-events: none;
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 1.2s var(--ease-out);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero:has(.hero-field[data-ready]) .hero-glow::after {
|
.hero-kicker {
|
||||||
opacity: 0;
|
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
|
@media (min-width: 1024px) {
|
||||||
desenho entra por animação (e não por transition) para a opacidade poder seguir a rolagem sem atraso. */
|
/* 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 {
|
.hero-field {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition: opacity 0.6s var(--ease-out);
|
||||||
|
|
||||||
&[data-ready] {
|
&[data-ready] {
|
||||||
opacity: calc(0.5 + 0.5 * var(--hero-open));
|
opacity: 1;
|
||||||
animation: hero-field-in 1.6s var(--ease-out) both;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hero-field-in {
|
/* O pôster some por `visibility`, depois que o canvas terminou de entrar por baixo dele: mudar a opacidade da imagem a
|
||||||
from {
|
repintava, e o navegador contava essa repintura como um novo maior elemento da dobra (LCP) aos 2,3 s. */
|
||||||
opacity: 0;
|
.hero:has(.hero-field[data-ready]) .hero-piece img {
|
||||||
}
|
visibility: hidden;
|
||||||
|
transition: visibility 0s linear 0.6s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-scrim {
|
.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
|
/* 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 {
|
.hero-title {
|
||||||
font-weight: var(--fw-section);
|
font-weight: var(--fw-section);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-word {
|
.hero-word {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: hero-word 1.1s var(--ease-out) both;
|
animation: hero-word 0.85s var(--ease-out) both;
|
||||||
animation-delay: calc(0.12s + var(--i) * 45ms);
|
animation-delay: calc(0.06s + var(--i) * 35ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hero-word {
|
@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-in,
|
||||||
.hero-foot > * {
|
.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);
|
animation-delay: var(--d, 0.05s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-foot > * {
|
.hero-foot > * {
|
||||||
--d: 0.85s;
|
--d: 0.66s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hero-in {
|
@keyframes hero-in {
|
||||||
@@ -1005,10 +1219,10 @@ html.lenis {
|
|||||||
0 0 1px rgba(204, 255, 0, 0.1);
|
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
|
/* Navegação entre páginas (app/template.tsx liga as transições de visualização do React): cortina de blocos. A página
|
||||||
recua e some, a nova chega de baixo. Sem desfoque e sem sobreposição (a nova só começa quando a antiga acabou de
|
que sai fica parada por baixo e a que entra aparece por uma máscara em degraus de blocos quadrados, da esquerda
|
||||||
sumir), e o fundo (html e body) fica escuro (--ink) enquanto dura a troca: cada página some e vem de um fundo só,
|
embaixo para a direita em cima (a diagonal do símbolo). O fundo (html e body) fica escuro (--ink) enquanto dura a
|
||||||
nunca de uma mistura de creme com preto. O cabeçalho tem nome próprio e fica parado durante a troca. */
|
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 */
|
/* 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 {
|
html:active-view-transition {
|
||||||
background: var(--ink);
|
background: var(--ink);
|
||||||
@@ -1029,43 +1243,119 @@ html:active-view-transition body {
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
/* --page-wipe vai de 0 (nada da página nova à vista) a 1 (tudo). Registrada, ela anima como número; o valor
|
||||||
::view-transition-group(page) {
|
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;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
/* 8 colunas de 12,5% da largura, cada uma crescendo do pé da tela para cima em blocos da largura da coluna
|
||||||
::view-transition-old(page) {
|
(round(down, …, 12.5vw)), uma coluna um bloco atrás da outra: a borda é uma escada na diagonal. A imagem da
|
||||||
animation: page-out 240ms cubic-bezier(0.7, 0, 0.84, 0) both;
|
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 */
|
@keyframes page-wipe {
|
||||||
::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 {
|
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
--page-wipe: 0;
|
||||||
transform: translateY(40px) scale(1.015);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */
|
/* nada anima, nem o esmaecer padrão do navegador: a troca é instantânea */
|
||||||
::view-transition-old(page) {
|
::view-transition-group(*),
|
||||||
|
::view-transition-old(*),
|
||||||
|
::view-transition-new(*) {
|
||||||
animation: none;
|
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;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1124,6 +1414,38 @@ html:active-view-transition body {
|
|||||||
transform: none;
|
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 {
|
.ds-lazy-reveal {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px) scale(1.02);
|
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 */
|
/* ScrollProgress: barra do topo e trilho lateral ligados à rolagem da página, sem JavaScript */
|
||||||
@keyframes ds-progress-x {
|
@keyframes ds-progress-x {
|
||||||
from {
|
from {
|
||||||
@@ -1214,3 +1567,51 @@ html:active-view-transition body {
|
|||||||
display: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,27 @@ import manifest from "./manifest";
|
|||||||
import { alt as openGraphAlt } from "./opengraph-image";
|
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".
|
* 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 precisam dizer o mesmo, não o posicionamento antigo ("sistemas sob medida").
|
* 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() }));
|
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
|
||||||
|
|
||||||
describe("manifesto do site", () => {
|
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();
|
const { name, description } = manifest();
|
||||||
|
|
||||||
expect(name).toMatch(/Sites e sistemas/);
|
expect(name).toMatch(/Sites e sistemas/);
|
||||||
expect(description).toMatch(/WhatsApp/);
|
expect(description).toMatch(
|
||||||
expect(description).toMatch(/planilha/);
|
/^Sites e sistemas sob medida para o jeito que a sua empresa trabalha\./,
|
||||||
expect(`${name} ${description}`).not.toMatch(/sob medida/i);
|
);
|
||||||
|
expect(description).not.toMatch(/WhatsApp|planilha/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("imagem de compartilhamento da home", () => {
|
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).toMatch(/Sites e sistemas/);
|
||||||
expect(openGraphAlt).not.toMatch(/sob medida/i);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+95
-8
@@ -1,5 +1,7 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { offerLabels } from "@/lib/offer-labels";
|
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";
|
import AiPage, { metadata } from "./page";
|
||||||
|
|
||||||
it("uses a short search title: up to 44 characters, and 60 with the layout suffix", () => {
|
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");
|
).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 />);
|
render(<AiPage />);
|
||||||
|
|
||||||
const links = screen
|
const links = screen
|
||||||
.getAllByRole("link")
|
.getAllByRole("link")
|
||||||
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
|
.filter((link) => link.getAttribute("href")?.startsWith("https://wa.me/"));
|
||||||
expect(links.map((link) => link.textContent)).toEqual([
|
expect(links.map((link) => link.textContent)).toEqual([
|
||||||
"Pedir a revisão do meu app",
|
buttonLabels.whatsapp,
|
||||||
"Avaliar meu projeto",
|
buttonLabels.whatsapp,
|
||||||
]);
|
]);
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
|
const text = new URL(link.getAttribute("href") ?? "").searchParams.get(
|
||||||
@@ -80,9 +82,10 @@ it("has breadcrumb navigation back to the home", () => {
|
|||||||
expect(
|
expect(
|
||||||
within(breadcrumb).getByRole("link", { name: "Início" }),
|
within(breadcrumb).getByRole("link", { name: "Início" }),
|
||||||
).toHaveAttribute("href", "/");
|
).toHaveAttribute("href", "/");
|
||||||
expect(
|
expect(within(breadcrumb).getByText(IA_PAGE_LABEL)).toHaveAttribute(
|
||||||
within(breadcrumb).getByText(offerLabels.revisao.name),
|
"aria-current",
|
||||||
).toHaveAttribute("aria-current", "page");
|
"page",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presents the review as an offer: what the person receives, with no price or deadline", () => {
|
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();
|
).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 { container } = render(<AiPage />);
|
||||||
|
|
||||||
const script = container.querySelector('script[type="application/ld+json"]');
|
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(
|
expect(
|
||||||
breadcrumb.itemListElement.map(({ name }: { name: string }) => name),
|
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");
|
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
@@ -8,15 +8,16 @@ import {
|
|||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { offerLabels } from "@/lib/offer-labels";
|
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
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";
|
import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
|
||||||
const pageUrl = `${siteUrl}/ia`;
|
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
|
* 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 pageTitle = "Revisão de sistema e de app feito com IA";
|
||||||
const pageDescription =
|
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 = [
|
const INCIDENTS = [
|
||||||
{
|
{
|
||||||
title: "Lovable: dados expostos em aplicações criadas na plataforma",
|
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() {
|
export default function AiPage() {
|
||||||
@@ -171,7 +201,7 @@ export default function AiPage() {
|
|||||||
"@graph": [
|
"@graph": [
|
||||||
buildBreadcrumbList(pageUrl, [
|
buildBreadcrumbList(pageUrl, [
|
||||||
["Início", siteUrl],
|
["Início", siteUrl],
|
||||||
[offerLabels.revisao.name, pageUrl],
|
[IA_PAGE_LABEL, pageUrl],
|
||||||
] as const),
|
] as const),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -185,12 +215,11 @@ export default function AiPage() {
|
|||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Início", href: "/" },
|
{ 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="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">
|
<div className="min-w-0">
|
||||||
<p className="ds-eyebrow">IA no desenvolvimento</p>
|
|
||||||
<h1 className="ds-section-title max-w-[24ch] text-balance">
|
<h1 className="ds-section-title max-w-[24ch] text-balance">
|
||||||
Seu app feito com IA está pronto para receber clientes? Eu testo
|
Seu app feito com IA está pronto para receber clientes? Eu testo
|
||||||
antes deles.
|
antes deles.
|
||||||
@@ -209,7 +238,7 @@ export default function AiPage() {
|
|||||||
service={WHATSAPP_SERVICE}
|
service={WHATSAPP_SERVICE}
|
||||||
placement="ia_hero"
|
placement="ia_hero"
|
||||||
>
|
>
|
||||||
Pedir a revisão do meu app
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<a href="#ia-receber" className={textLinkDark}>
|
<a href="#ia-receber" className={textLinkDark}>
|
||||||
Ver como a revisão funciona
|
Ver como a revisão funciona
|
||||||
@@ -390,10 +419,10 @@ export default function AiPage() {
|
|||||||
</summary>
|
</summary>
|
||||||
<div className="pb-sp-8">
|
<div className="pb-sp-8">
|
||||||
<p className="ds-copy max-w-[75ch]">
|
<p className="ds-copy max-w-[75ch]">
|
||||||
São episódios de 2025, não avaliações atuais das plataformas.
|
São episódios de 2025 e 2026, não avaliações atuais das
|
||||||
Eles mostram riscos de configuração, permissões e operação; não
|
plataformas. Eles mostram riscos de configuração, permissões e
|
||||||
provam que todo sistema feito com IA seja inseguro ou que um
|
operação; não provam que todo sistema feito com IA seja inseguro
|
||||||
profissional elimine qualquer risco.
|
ou que um profissional elimine qualquer risco.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
|
<div className="mt-sp-6 grid gap-sp-6 lg:grid-cols-3">
|
||||||
{INCIDENTS.map((incident) => (
|
{INCIDENTS.map((incident) => (
|
||||||
@@ -426,7 +455,7 @@ export default function AiPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="ds-section" aria-labelledby="ia-lgpd-title">
|
<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">
|
<article className="ds-card">
|
||||||
<h2 id="ia-lgpd-title" className="ds-card-title !mt-0">
|
<h2 id="ia-lgpd-title" className="ds-card-title !mt-0">
|
||||||
E a LGPD?
|
E a LGPD?
|
||||||
@@ -445,6 +474,14 @@ export default function AiPage() {
|
|||||||
contratar desenvolvimento não equivale a uma certificação de
|
contratar desenvolvimento não equivale a uma certificação de
|
||||||
adequação.
|
adequação.
|
||||||
</p>
|
</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
|
<a
|
||||||
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
|
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"
|
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
|
ANPD: Resolução nº 2/2022, arts. 6º, 12 e 13
|
||||||
</a>
|
</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>
|
||||||
<article className="ds-card">
|
<article className="ds-card">
|
||||||
<h2 className="ds-card-title !mt-0">
|
<h2 className="ds-card-title !mt-0">
|
||||||
@@ -475,7 +543,7 @@ export default function AiPage() {
|
|||||||
service={WHATSAPP_SERVICE}
|
service={WHATSAPP_SERVICE}
|
||||||
placement="ia_closing"
|
placement="ia_closing"
|
||||||
>
|
>
|
||||||
Avaliar meu projeto
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
+2
-1
@@ -77,7 +77,8 @@ describe("metadata", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("describes the offer without inventing numbers or promises", () => {
|
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/);
|
expect(metadata.description).not.toMatch(/R\$|garantia|\d+ dias/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -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. */
|
/** 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 defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`;
|
||||||
const seoDescription =
|
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
|
* 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
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,8 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
name: "Evandro.dev.br — Sites e sistemas para empresas",
|
name: "Evandro.dev.br — Sites e sistemas para empresas",
|
||||||
short_name: "Evandro.dev",
|
short_name: "Evandro.dev",
|
||||||
description:
|
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: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#000000",
|
background_color: "#000000",
|
||||||
@@ -16,14 +17,28 @@ export default function manifest(): MetadataRoute.Manifest {
|
|||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: "/brand/icon-192.png",
|
src: "/brand/pwa-192.png",
|
||||||
sizes: "192x192",
|
sizes: "192x192",
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/icon.png",
|
src: "/brand/pwa-512.png",
|
||||||
sizes: "512x512",
|
sizes: "512x512",
|
||||||
type: "image/png",
|
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
@@ -1,4 +1,10 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
import NotFound, { metadata } from "./not-found";
|
import NotFound, { metadata } from "./not-found";
|
||||||
|
|
||||||
describe("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 />);
|
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
@@ -1,5 +1,8 @@
|
|||||||
import { ArrowLeft, Code2, Home } from "lucide-react";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import {
|
||||||
|
primaryButton,
|
||||||
|
secondaryButtonDark,
|
||||||
|
} from "@/app/components/button-styles";
|
||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
import { twitterHandle } from "@/lib/seo";
|
import { twitterHandle } from "@/lib/seo";
|
||||||
import { siteName } from "@/lib/site-content";
|
import { siteName } from "@/lib/site-content";
|
||||||
@@ -33,36 +36,53 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
|
<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="max-w-2xl mx-auto text-center">
|
<div className="ds-section-shell grid items-center gap-sp-10 lg:grid-cols-[minmax(0,1fr)_minmax(0,520px)]">
|
||||||
<div className="mb-8">
|
<div>
|
||||||
<Code2 className="w-24 h-24 text-slate-500 mx-auto mb-4 opacity-60" />
|
<h1 className="text-[clamp(64px,12vw,96px)] font-semibold leading-none tracking-[-0.04em]">
|
||||||
<h1 className="font-[family-name:var(--font-display)] text-6xl md:text-8xl font-extrabold text-white mb-4">
|
|
||||||
404
|
404
|
||||||
</h1>
|
</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
|
Página não encontrada
|
||||||
</h2>
|
</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.
|
A página que você está procurando não existe ou foi movida.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
<div
|
||||||
<HoverPrefetchLink
|
aria-hidden="true"
|
||||||
href="/"
|
className="order-first mx-auto aspect-[16/10] w-full max-w-[280px] overflow-hidden lg:order-none lg:max-w-none"
|
||||||
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"
|
>
|
||||||
|
<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" />
|
<source src="/3d/peca-404.webm" type="video/webm" />
|
||||||
Voltar ao início
|
<source src="/3d/peca-404.mp4" type="video/mp4" />
|
||||||
</HoverPrefetchLink>
|
</video>
|
||||||
<HoverPrefetchLink
|
{/* biome-ignore lint/performance/noImgElement: imagem decorativa e pequena, só para movimento reduzido */}
|
||||||
href="/cases"
|
<img
|
||||||
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"
|
src="/3d/peca-404.webp"
|
||||||
>
|
alt=""
|
||||||
<ArrowLeft className="w-5 h-5" />
|
width={640}
|
||||||
Ver os cases
|
height={400}
|
||||||
</HoverPrefetchLink>
|
loading="lazy"
|
||||||
|
className="hidden h-full w-full object-cover motion-reduce:block"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export default async function Image() {
|
|||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<OgCard
|
<OgCard
|
||||||
eyebrow="Sites e sistemas para empresas"
|
eyebrow="Sites e sistemas para empresas"
|
||||||
title="Sites e sistemas que trazem pedidos."
|
title="Sites e sistemas sob medida."
|
||||||
description="Pedido pelo WhatsApp e operação fora da planilha. Com Evandro Fonseca Junior, de Garuva, SC."
|
description="Para o jeito que a sua empresa trabalha. Com Evandro Fonseca Junior, de Garuva, SC."
|
||||||
/>,
|
/>,
|
||||||
OG_SIZE,
|
OG_SIZE,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,12 +64,18 @@ it("uses the same shell as the home, with no narrow max-width", () => {
|
|||||||
expect(metadata.title).toBe("Privacidade e consentimento");
|
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 { container } = render(<PrivacyPage />);
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.className).toContain("md:!pb-sp-12");
|
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", () => {
|
it("keeps the lime fill for contact actions only", () => {
|
||||||
|
|||||||
+23
-16
@@ -1,8 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
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 { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||||
import { pageTop } from "@/app/components/button-styles";
|
import { pageTop } from "@/app/components/button-styles";
|
||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { PagePiece } from "@/app/components/PagePiece";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||||
import { siteUrl } from "@/lib/site-content";
|
import { siteUrl } from "@/lib/site-content";
|
||||||
@@ -41,20 +45,23 @@ export default function PrivacyPage() {
|
|||||||
<section
|
<section
|
||||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||||
>
|
>
|
||||||
<div className="ds-section-shell">
|
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
|
||||||
<Breadcrumb
|
<div className="min-w-0">
|
||||||
items={[
|
<Breadcrumb
|
||||||
{ label: "Início", href: "/" },
|
items={[
|
||||||
{ label: "Privacidade", href: "/privacidade" },
|
{ label: "Início", href: "/" },
|
||||||
]}
|
{ label: "Privacidade", href: "/privacidade" },
|
||||||
/>
|
]}
|
||||||
<h1 className="ds-section-title text-white">
|
/>
|
||||||
Privacidade e consentimento
|
<h1 className="ds-section-title text-white">
|
||||||
</h1>
|
Privacidade e consentimento
|
||||||
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
</h1>
|
||||||
Esta página explica, de forma objetiva, como funcionam o contato e
|
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
|
||||||
as escolhas de analytics neste portfólio.
|
Esta página explica, de forma objetiva, como funcionam o contato e
|
||||||
</p>
|
as escolhas de analytics neste portfólio.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PagePiece image="/3d/estado-dados.webp" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ export default function PrivacyPage() {
|
|||||||
O responsável pelo site é Evandro Fonseca Junior. Para contato
|
O responsável pelo site é Evandro Fonseca Junior. Para contato
|
||||||
ou solicitações relacionadas a esta página, escreva para{" "}
|
ou solicitações relacionadas a esta página, escreva para{" "}
|
||||||
<a
|
<a
|
||||||
className="font-medium underline underline-offset-4"
|
className="py-1 font-medium underline underline-offset-4"
|
||||||
href="mailto:[email protected]"
|
href="mailto:[email protected]"
|
||||||
>
|
>
|
||||||
evandrofonsecajunior@gmail.com
|
evandrofonsecajunior@gmail.com
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import ServicePage, { generateMetadata, generateStaticParams } from "./page";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setNavigation({ pathname: "/servicos" });
|
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) {
|
async function renderService(slug: string) {
|
||||||
@@ -43,6 +50,8 @@ if (!service) throw new Error("Missing representative service");
|
|||||||
const publicServiceSlugs = [
|
const publicServiceSlugs = [
|
||||||
"criacao-de-sites",
|
"criacao-de-sites",
|
||||||
"engenharia-de-software",
|
"engenharia-de-software",
|
||||||
|
"atendimento-whatsapp-ia",
|
||||||
|
"nota-fiscal-reforma-tributaria",
|
||||||
"consultoria-nextjs",
|
"consultoria-nextjs",
|
||||||
"tauri-rust",
|
"tauri-rust",
|
||||||
"integracao-hardware-software-iot",
|
"integracao-hardware-software-iot",
|
||||||
@@ -67,8 +76,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
|||||||
|
|
||||||
it("keeps static params and route metadata canonical", async () => {
|
it("keeps static params and route metadata canonical", async () => {
|
||||||
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
||||||
expect(staticSlugs).toHaveLength(6);
|
expect(staticSlugs).toHaveLength(8);
|
||||||
expect(new Set(staticSlugs).size).toBe(6);
|
expect(new Set(staticSlugs).size).toBe(8);
|
||||||
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
|
expect([...staticSlugs].sort()).toEqual([...publicServiceSlugs].sort());
|
||||||
|
|
||||||
const metadata = await generateMetadata({
|
const metadata = await generateMetadata({
|
||||||
@@ -110,7 +119,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primaryCtas = screen.getAllByRole("link", {
|
const primaryCtas = screen.getAllByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
});
|
});
|
||||||
expect(primaryCtas).toHaveLength(2);
|
expect(primaryCtas).toHaveLength(2);
|
||||||
for (const cta of primaryCtas) {
|
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 }>;
|
itemListElement: Array<{ position: number; url: string; name: string }>;
|
||||||
};
|
};
|
||||||
expect(itemList.itemListElement.map(({ position }) => position)).toEqual([
|
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);
|
const structuredUrls = itemList.itemListElement.map(({ url }) => url);
|
||||||
expect(structuredUrls).toHaveLength(6);
|
expect(structuredUrls).toHaveLength(8);
|
||||||
expect(new Set(structuredUrls).size).toBe(6);
|
expect(new Set(structuredUrls).size).toBe(8);
|
||||||
expect([...structuredUrls].sort()).toEqual(
|
expect([...structuredUrls].sort()).toEqual(
|
||||||
publicServiceHrefs.map((href) => `${siteUrl}${href}`).sort(),
|
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
|
const offerLinks = serviceOffers
|
||||||
.filter(({ href }) => publicServiceHrefs.includes(href))
|
.filter(({ href }) => publicServiceHrefs.includes(href))
|
||||||
.map(({ name, href }) => ({
|
.map(({ name, href }) => ({
|
||||||
href,
|
href,
|
||||||
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
|
link: screen.getByRole("link", { name: `Ver o serviço ${name}` }),
|
||||||
}));
|
}));
|
||||||
expect(offerLinks).toHaveLength(2);
|
expect(offerLinks).toHaveLength(4);
|
||||||
for (const { href, link } of offerLinks) {
|
for (const { href, link } of offerLinks) {
|
||||||
expect(link).toHaveAttribute("href", href);
|
expect(link).toHaveAttribute("href", href);
|
||||||
}
|
}
|
||||||
@@ -312,8 +321,8 @@ it("keeps hub cards and structured items aligned", () => {
|
|||||||
...offerLinks.map(({ link }) => link.getAttribute("href")),
|
...offerLinks.map(({ link }) => link.getAttribute("href")),
|
||||||
...otherLinks.map((link) => link.getAttribute("href")),
|
...otherLinks.map((link) => link.getAttribute("href")),
|
||||||
];
|
];
|
||||||
expect(visibleHrefs).toHaveLength(6);
|
expect(visibleHrefs).toHaveLength(8);
|
||||||
expect(new Set(visibleHrefs).size).toBe(6);
|
expect(new Set(visibleHrefs).size).toBe(8);
|
||||||
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
|
expect([...visibleHrefs].sort()).toEqual([...publicServiceHrefs].sort());
|
||||||
expect(graph[2]).toMatchObject({
|
expect(graph[2]).toMatchObject({
|
||||||
"@type": "BreadcrumbList",
|
"@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 { container } = render(<ServicesIndexPage />);
|
||||||
|
|
||||||
const offersHeading = screen.getByRole("heading", {
|
const offersHeading = screen.getByRole("heading", {
|
||||||
level: 2,
|
level: 2,
|
||||||
name: "Três jeitos de começar.",
|
name: "Cinco jeitos de começar.",
|
||||||
});
|
});
|
||||||
const othersHeading = screen.getByRole("heading", {
|
const othersHeading = screen.getByRole("heading", {
|
||||||
level: 2,
|
level: 2,
|
||||||
@@ -342,8 +351,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
|
|||||||
|
|
||||||
const cards = Array.from(
|
const cards = Array.from(
|
||||||
container.querySelectorAll<HTMLElement>("section article"),
|
container.querySelectorAll<HTMLElement>("section article"),
|
||||||
).slice(0, 3);
|
).slice(0, 5);
|
||||||
expect(cards).toHaveLength(3);
|
expect(cards).toHaveLength(5);
|
||||||
for (const [index, offer] of serviceOffers.entries()) {
|
for (const [index, offer] of serviceOffers.entries()) {
|
||||||
const card = cards[index];
|
const card = cards[index];
|
||||||
expect(
|
expect(
|
||||||
@@ -357,7 +366,8 @@ it("puts the three offers right after the hero, each with its name, deliverables
|
|||||||
).toHaveAttribute("href", offer.href);
|
).toHaveAttribute("href", offer.href);
|
||||||
expect(within(card).getByText(offer.proof.label)).toBeVisible();
|
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(
|
expect(
|
||||||
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
|
within(cards[0]).getByRole("link", { name: "Ver case Lavação 047" }),
|
||||||
).toHaveAttribute("href", "/cases/lavacao-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" }),
|
within(cards[1]).getByRole("link", { name: "Ver case SEV Moda Íntima" }),
|
||||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
expect(within(cards[1]).getByText(/Apolo Energia/)).toBeVisible();
|
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(
|
expect(
|
||||||
within(cards[2]).getByRole("link", { name: /^Ver o serviço/ }),
|
within(cards[3]).getByRole("link", { name: /^Ver o serviço/ }),
|
||||||
).toHaveAttribute("href", "/ia");
|
).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", () => {
|
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");
|
).getAllByRole("link");
|
||||||
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
|
expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
|
||||||
for (const cta of screen.getAllByRole("link", {
|
for (const cta of screen.getAllByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
})) {
|
})) {
|
||||||
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
|
const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
|
||||||
"text",
|
"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;
|
const last = container.querySelector("main")?.lastElementChild;
|
||||||
expect(last).toContainElement(nextStep);
|
expect(last).toContainElement(nextStep);
|
||||||
const cta = within(last as HTMLElement).getByRole("link", {
|
const cta = within(last as HTMLElement).getByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
});
|
});
|
||||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
|
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
|
||||||
"https://wa.me",
|
"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
|
// um rótulo só para o contato: sem "no WhatsApp" colado nem texto por serviço
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getAllByRole("link", { name: buttonLabels.contact })
|
.getAllByRole("link", { name: buttonLabels.whatsapp })
|
||||||
.every((link) => link.textContent === buttonLabels.contact),
|
.every((link) => link.textContent === buttonLabels.whatsapp),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(container.textContent).not.toMatch(/diretamente/i);
|
expect(container.textContent).not.toMatch(/diretamente/i);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -963,3 +981,36 @@ it("keeps related case ids resolving to real cases", () => {
|
|||||||
expect(caseStudies.some((item) => item.id === id)).toBe(true);
|
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();
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import { ServiceAlsoCases } from "../_components/ServiceAlsoCases";
|
|||||||
import { ServiceDeliverables } from "../_components/ServiceDeliverables";
|
import { ServiceDeliverables } from "../_components/ServiceDeliverables";
|
||||||
import { ServiceHero } from "../_components/ServiceHero";
|
import { ServiceHero } from "../_components/ServiceHero";
|
||||||
import { ServiceOffer } from "../_components/ServiceOffer";
|
import { ServiceOffer } from "../_components/ServiceOffer";
|
||||||
|
import { ServiceOfficial } from "../_components/ServiceOfficial";
|
||||||
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
|
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
|
||||||
import {
|
import {
|
||||||
resolveServiceHero,
|
resolveServiceHero,
|
||||||
@@ -76,7 +77,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
|
|
||||||
const hero = resolveServiceHero(service);
|
const hero = resolveServiceHero(service);
|
||||||
const { cards, also } = splitServiceCases(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 offer = getServiceOffer(service.slug);
|
||||||
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
|
||||||
const audienceCard = (
|
const audienceCard = (
|
||||||
@@ -125,6 +126,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
|
|
||||||
{offer ? <ServiceDeliverables offer={offer} /> : null}
|
{offer ? <ServiceDeliverables offer={offer} /> : null}
|
||||||
|
|
||||||
|
<ServiceOfficial official={service.official} />
|
||||||
|
|
||||||
<section className="ds-section">
|
<section className="ds-section">
|
||||||
<div className="ds-section-shell">
|
<div className="ds-section-shell">
|
||||||
{offer ? (
|
{offer ? (
|
||||||
@@ -226,7 +229,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
placement="service_detail_footer"
|
placement="service_detail_footer"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href="/servicos"
|
href="/servicos"
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import { ServiceHero } from "./ServiceHero";
|
|||||||
import type { ServiceHeroData } from "./service-page";
|
import type { ServiceHeroData } from "./service-page";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX):
|
* 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 (grade de duas colunas), hasCaseCards (botão "Ver projetos
|
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide ou heroLoop (grade de duas colunas), hasCaseCards (botão
|
||||||
* parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa).
|
* "Ver projetos parecidos"), diagram (esquema ou o resto), heroLoop sem coluna ao lado (peça em loop ou capa) e
|
||||||
* Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam.
|
* 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", () => ({
|
jest.mock("./ServiceDiagram", () => ({
|
||||||
@@ -23,6 +24,11 @@ jest.mock("./ServiceDiagram", () => ({
|
|||||||
<div data-testid="diagram">{kind}</div>
|
<div data-testid="diagram">{kind}</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
jest.mock("@/app/components/PagePiece", () => ({
|
||||||
|
PagePiece: ({ loop }: { loop: string }) => (
|
||||||
|
<div data-testid="piece">{loop}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
jest.mock("./ServiceHeroCover", () => ({
|
jest.mock("./ServiceHeroCover", () => ({
|
||||||
ServiceHeroCover: (props: {
|
ServiceHeroCover: (props: {
|
||||||
caseStudy?: { id: string };
|
caseStudy?: { id: string };
|
||||||
@@ -123,6 +129,36 @@ describe("ServiceHero", () => {
|
|||||||
expect(screen.queryByTestId("cover")).toBeNull();
|
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", () => {
|
it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => {
|
||||||
const lavacao = service();
|
const lavacao = service();
|
||||||
render(
|
render(
|
||||||
@@ -174,7 +210,7 @@ describe("ServiceHero", () => {
|
|||||||
render(<ServiceHero service={sistemas} hero={noSide} />);
|
render(<ServiceHero service={sistemas} hero={noSide} />);
|
||||||
|
|
||||||
const button = screen.getByRole("link", {
|
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(
|
const message = new URL(button.getAttribute("href") ?? "").searchParams.get(
|
||||||
"text",
|
"text",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
primaryButton,
|
primaryButton,
|
||||||
secondaryButtonDark,
|
secondaryButtonDark,
|
||||||
} from "@/app/components/button-styles";
|
} from "@/app/components/button-styles";
|
||||||
|
import { PagePiece } from "@/app/components/PagePiece";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
|
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
|
||||||
import { ServiceDiagram } from "./ServiceDiagram";
|
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
|
* 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({
|
export function ServiceHero({
|
||||||
service,
|
service,
|
||||||
@@ -26,6 +28,7 @@ export function ServiceHero({
|
|||||||
hero: ServiceHeroData;
|
hero: ServiceHeroData;
|
||||||
}) {
|
}) {
|
||||||
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
|
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
|
||||||
|
const loop = hasHeroSide ? undefined : service.heroLoop;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -34,9 +37,9 @@ export function ServiceHero({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Início", href: "/" },
|
{ label: "Início", href: "/" },
|
||||||
@@ -44,7 +47,6 @@ export function ServiceHero({
|
|||||||
{ label: service.navLabel, href: `/servicos/${service.slug}` },
|
{ 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">
|
<h1 className="ds-section-title max-w-[22ch] text-balance text-white">
|
||||||
{service.headline}
|
{service.headline}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -57,7 +59,7 @@ export function ServiceHero({
|
|||||||
placement="service_detail_hero"
|
placement="service_detail_hero"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
{hasCaseCards ? (
|
{hasCaseCards ? (
|
||||||
<a href="#casos-relacionados" className={secondaryButtonDark}>
|
<a href="#casos-relacionados" className={secondaryButtonDark}>
|
||||||
@@ -72,6 +74,8 @@ export function ServiceHero({
|
|||||||
</div>
|
</div>
|
||||||
{diagram ? (
|
{diagram ? (
|
||||||
<ServiceDiagram kind={diagram} />
|
<ServiceDiagram kind={diagram} />
|
||||||
|
) : loop ? (
|
||||||
|
<PagePiece loop={loop} />
|
||||||
) : (
|
) : (
|
||||||
<ServiceHeroCover
|
<ServiceHeroCover
|
||||||
caseStudy={heroCase}
|
caseStudy={heroCase}
|
||||||
|
|||||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user