Compare commits

..
Author SHA1 Message Date
Evandro Fonseca Junior 6afa0b5fd7 wip: snapshot das alterações não commitadas (2026-10-20)
CI / audit (push) Successful in 10m1s
CI / verify (push) Successful in 11m23s
CI / deploy (push) Successful in 57s
CI / e2e (push) Successful in 15m33s
Origem: D:/Documents/Github/portifolio-evandrodevbr
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
2026-10-20 00:49:51 -03:00
303 changed files with 4065 additions and 25036 deletions

No files matched your search

-1
View File
@@ -29,7 +29,6 @@ jobs:
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm test:jogo
- run: pnpm build
audit:
+26
View File
@@ -0,0 +1,26 @@
name: CI
"on":
push:
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --runInBand
- run: pnpm build
- run: pnpm audit --prod --audit-level=high
- run: pnpm audit --dev --audit-level=high
-5
View File
@@ -61,8 +61,3 @@ next-env.d.ts
.omo/
.playwright-mcp/
/3d/out/
/3d/__pycache__/
/3d/fontes/
/3d/ferramentas/
-30
View File
@@ -1,30 +0,0 @@
# Ferramentas do jogo
Programas baixados ficam em `3d/ferramentas/`, fora do git (`/3d/ferramentas/` no `.gitignore`), instalados sem sudo e sem login.
| programa | versão | licença (frase lida e arquivo) | como baixar | comando sem janela |
| --- | --- | --- | --- | --- |
| Tiled (com `tmxrasterizer`) | 1.12.2 | GPL-2.0 (editor) e BSD-2-Clause (`libtiled`, `tmxrasterizer`). Em `COPYING` do pacote: "Tiled src/tiled GPL … tmxrasterizer src/tmxrasterizer BSD 2-clause license … The full text of each license is provided in the files LICENSE.GPL and LICENSE.BSD." Arquivo: `/app/share/licenses/org.mapeditor.Tiled/tiled/COPYING` no Flatpak (o mesmo texto em `3d/ferramentas/tiled/squashfs-root/COPYING` no AppImage). | `flatpak install --user -y flathub org.mapeditor.Tiled`. O AppImage (`3d/ferramentas/tiled/`, das [releases](https://github.com/mapeditor/tiled/releases/tag/v1.12.2)) roda, mas só traz o plugin Qt `xcb` e falha com `QT_QPA_PLATFORM=offscreen`; `xvfb-run` não existe aqui. | `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tiled org.mapeditor.Tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json"` e `env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command=tmxrasterizer org.mapeditor.Tiled "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png"` |
| Blender | 5.2.2 LTS (`blender --version`) | GPL. Em `/usr/share/doc/blender/readme.html`: "Blender can be used freely for any purpose, including commercial use and distribution. It's free and open-source software, released under the GNU GPL licence." Bibliotecas de terceiros em `/usr/share/doc/blender/license/license.md`. | Já instalado pelo sistema (pacote `blender` 5.2.2-2 do Manjaro). | `blender -b -P script.py` |
| MPFB (MakeHuman para Blender, extensão) | 2.0.18 (`blender_manifest.toml`; build 20261010) | Código GPL-3.0-or-later, recursos CC0. Em `LICENSE.md` do [repositório](https://github.com/makehumancommunity/mpfb2/blob/master/LICENSE.md): "The MPFB source (as defined per the above) is released under GPLv3." e "These assets have been released under CC0 1.0 Universal."; sobre a saída: "As the assets have been released under CC0, there is no limitation on what you can do with this combined output." O zip da extensão não traz arquivo de licença; o manifesto diz `license = ["SPDX:GPL-3.0-or-later"]`. | `3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` de [extensions.blender.org](https://extensions.blender.org/add-ons/mpfb/) (sha256 `c075b3e9…2a2dad`), instalado só na pasta separada: `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b --factory-startup --command extension install-file -r user_default -e 3d/ferramentas/baixados/add-on-mpfb-v2.0.18.zip` | `BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py` (instala os pacotes abaixo na pasta de dados do MPFB se faltarem) |
## Como mudar o mapa
1. Abrir `3d/mapa/escritorio.tmx` no Tiled: `flatpak run org.mapeditor.Tiled 3d/mapa/escritorio.tmx`.
2. Editar as camadas `planta`, `moveis` e `marcas` e salvar.
3. Rodar `bash 3d/mapa.sh`: exporta `3d/mapa/escritorio.json` e `escritorio.png`, escreve `lib/jogo/mapas/escritorio.ts` e monta `public/jogo/mapa.glb` no Blender.
4. Rodar `pnpm test -- lib/jogo/mapas`.
O `.tmx` é a fonte. `bash 3d/mapa.sh --gerar` refaz o `.tmx` pelo `3d/mapa.py` e apaga o que foi editado à mão.
## Pacotes de recursos do MPFB
Baixados de `https://files.makehumancommunity.org/asset_packs/<pacote>/<pacote>_cc0.zip` (o link `files2` da [página](https://static.makehumancommunity.org/assets/assetpacks/index.html) dá o mesmo arquivo, mas a ~10 KB/s) para `3d/ferramentas/baixados/` e extraídos em `3d/ferramentas/blender-user/extensions/.user/user_default/mpfb/data/`. Nenhum zip traz arquivo de licença: a licença lida é o campo `"license"` de cada recurso em `packs/<pacote>.json` (dentro do zip) e o cabeçalho dos arquivos usados. Há cabeçalhos AGPL em alguns arquivos de pacotes marcados CC0 (ex.: `cortu_cargo_pants/cargo_pants.obj`, `blindsaypatten_uniform_skin_texture.mhmat`); esses ficaram de fora.
| pacote | sha256 | recursos usados em `3d/humano.py` | licença lida |
| --- | --- | --- | --- |
| makehuman_system_assets | `b542127a…0a0107` | pele `young_caucasian_male`, olhos `low-poly`, sobrancelha `eyebrow001`, cabelo `short02` (a malha base e o esqueleto `mixamo` vêm dentro da extensão, recursos CC0 pelo `LICENSE.md` acima) | CC0 nos 94 itens de `packs/makehuman_system_assets.json`; cabeçalho dos `.mhmat`/`.mhclo`: "This asset was explicitly released as CC0 in september 2020." |
| shirts01 | `a5a723b0…3bcd3` | `namuhekam_male_polo_shirt` (autor namuhekam) | `"license": "CC0"` em `packs/shirts01.json`; CC0 no cabeçalho do `.mhclo`, `.obj` e `.mhmat` |
| pants01 | `e4e0ec60…ad9` | `toigo_wool_pants` (autora MargaretToigo) | `"license": "CC0"` em `packs/pants01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` |
| shoes01 | `ded3f704…5dba9` | `toigo_ankle_boots_male` (autora MargaretToigo) | `"license": "CC0"` em `packs/shoes01.json`; CC0 no cabeçalho do `.mhclo` e `.obj` |
| skins01, hair01 | `7495ab99…4ad9`, `49445d69…40c9` | baixados, não usados | CC0 no `packs/*.json`, mas com cabeçalhos AGPL em 2 e 10 arquivos |
-619
View File
@@ -1,619 +0,0 @@
"""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()
-333
View File
@@ -1,333 +0,0 @@
"""Prova do MPFB2 (MakeHuman para Blender) por linha de comando: homem adulto magro, vestido, com esqueleto "mixamo".
Mede triângulos e ossos, passa os 6 clipes do zumbi atual (3d/fontes/zumbi, ossos mixamorig:*) para o esqueleto
novo, exporta 3d/out/jogo/humano.glb e fotografa com a luz de zumbi-frente.png e zumbi-perto.png (3d/jogo.py).
Rodar SEMPRE com a pasta de usuário separada (a extensão e os pacotes ficam lá, fora do Blender do Evandro):
BLENDER_USER_RESOURCES=$PWD/3d/ferramentas/blender-user blender -b -P 3d/humano.py
A extensão entra uma vez com (ver 3d/FERRAMENTAS.md):
BLENDER_USER_RESOURCES=… blender -b --factory-startup --command extension install-file -r user_default -e <zip>
"""
import hashlib
import os
import sys
import urllib.request
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from blocos import RAIZ, SAIDA, falhar, ligar_cycles, sair # noqa: E402
try:
from bl_ext.user_default.mpfb.services import AssetService, HumanService, LocationService
except ImportError:
falhar("MPFB não carregou: rode com BLENDER_USER_RESOURCES=3d/ferramentas/blender-user e instale a extensão")
BAIXADOS = os.path.join(RAIZ, "3d", "ferramentas", "baixados")
CLIPES_FBX = os.path.join(RAIZ, "3d", "fontes", "zumbi") # baixados e extraídos por 3d/jogo.py
RENDERS = os.path.join(SAIDA, "jogo")
GLB = os.path.join(RENDERS, "humano.glb")
# pacote -> sha256 do zip (só pacotes CC0; https://static.makehumancommunity.org/assets/assetpacks/index.html)
PACOTES = {
"makehuman_system_assets": "b542127a8e25547c7c29c19f2d1d2adb9a664c80396ecd694095dbc8028a0107",
"shirts01": "a5a723b0e84a109bb190fcfeac7f1de4138d875da3e30fe5b3340eac9f38bcd3",
"pants01": "e4e0ec60db34f279be291a83cfd7b342a7c5cf09bb7676682a5f39f4f6ac4ad9",
"shoes01": "ded3f70428505eabbf1f6d7b5f61196a7366ef20757103d276ad0ed336c35ada",
}
# files2 (o link da página) entrega a ~10 KB/s; files é o mesmo conteúdo (mesmo tamanho e sha256)
URL_PACOTE = "https://files.makehumancommunity.org/asset_packs/{0}/{0}_cc0.zip"
# homem adulto (25 anos), magro, altura média; a raça fica no padrão do MPFB (1/3 de cada)
CORPO = {"gender": 1.0, "age": 0.5, "muscle": 0.5, "weight": 0.3, "proportions": 0.5, "height": 0.5,
"cupsize": 0.5, "firmness": 0.5, "race": {"asian": 0.33, "caucasian": 0.33, "african": 0.33}}
PELE = "skins/young_caucasian_male/young_caucasian_male.mhmat"
# (tipo do MPFB, arquivo na pasta de dados); escolhidos com cabeçalho CC0 nos .mhclo e .obj (cargo_pants.obj é AGPL)
PECAS = [
("Eyes", "eyes/low-poly/low-poly.mhclo"),
("Eyebrows", "eyebrows/eyebrow001/eyebrow001.mhclo"),
("Hair", "hair/short02/short02.mhclo"),
("Clothes", "clothes/namuhekam_male_polo_shirt/namuhekam_male_polo_shirt.mhclo"),
("Clothes", "clothes/toigo_wool_pants/toigo_wool_pants.mhclo"),
("Clothes", "clothes/toigo_ankle_boots_male/toigo_ankle_boots_male.mhclo"), # o mj_cloth_shoes é sapatilha
]
ESQUELETO = "mixamo"
MAX_TRIS = 12_000
QUADRIL = "mixamorig:Hips"
PERNA = ["UpLeg", "Leg", "Foot"]
CLIPES = {
"parado": "Zombie Idle.fbx",
"andar": "Zombie Walk.fbx",
"atacar": "Zombie Attack.fbx",
"morrer": "Zombie Dying.fbx",
"correr": "Zombie Running.fbx",
"reacao": "Zombie Reaction Hit.fbx",
}
def sha256(caminho):
h = hashlib.sha256()
with open(caminho, "rb") as f:
for bloco in iter(lambda: f.read(1 << 20), b""):
h.update(bloco)
return h.hexdigest()
def instalar_pacotes():
dados = LocationService.get_user_data()
for nome, soma in PACOTES.items():
if os.path.exists(os.path.join(dados, "packs", nome + ".json")):
continue
zip_ = os.path.join(BAIXADOS, nome + "_cc0.zip")
if not os.path.exists(zip_):
os.makedirs(BAIXADOS, exist_ok=True)
urllib.request.urlretrieve(URL_PACOTE.format(nome), zip_)
if sha256(zip_) != soma:
falhar(f"{zip_}: sha256 não confere")
erro = AssetService.fix_and_extract_asset_pack_zip(zip_, dados)
if erro:
falhar(f"{nome}: {erro}")
print(f"pacote {nome} instalado em {dados}", flush=True)
AssetService.update_all_asset_lists()
return dados
def triangulos(obj):
malha = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
malha.calc_loop_triangles()
n = len(malha.loop_triangles)
obj.to_mesh_clear()
return n
def criar(dados):
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj)
corpo = HumanService.create_human(macro_detail_dict=CORPO)
esqueleto = HumanService.add_builtin_rig(corpo, ESQUELETO)
HumanService.set_character_skin(os.path.join(dados, PELE), corpo, skin_type="MAKESKIN", material_instances=False)
pecas = []
for tipo, arquivo in PECAS:
caminho = os.path.join(dados, arquivo)
if not os.path.exists(caminho):
falhar(f"falta {caminho}")
pecas.append(HumanService.add_mhclo_asset(caminho, corpo, asset_type=tipo, subdiv_levels=0))
return esqueleto, corpo, pecas
def aplicar_modificadores(obj):
"""Grava na malha as máscaras (ajudantes e faces do corpo sob a roupa) e tira subdivisões; só fica o Armature."""
bpy.context.view_layer.objects.active = obj
if obj.data.shape_keys:
obj.shape_key_add(name="_mistura", from_mix=True)
for chave in list(obj.data.shape_keys.key_blocks)[:-1]:
obj.shape_key_remove(chave)
obj.shape_key_remove(obj.data.shape_keys.key_blocks[0])
for mod in list(obj.modifiers):
if mod.type == "ARMATURE":
continue
if mod.type == "SUBSURF":
obj.modifiers.remove(mod)
continue
with bpy.context.temp_override(object=obj, active_object=obj):
bpy.ops.object.modifier_apply(modifier=mod.name)
def reduzir(malhas, alvo):
"""Colapso de arestas com a mesma razão em todas as malhas (antes do Armature: pesos interpolados)."""
total = sum(triangulos(m) for m in malhas)
razao = alvo / total
for _ in range(4):
for m in malhas:
mod = m.modifiers.new("reduzir", "DECIMATE")
mod.ratio = min(1.0, razao)
mod.use_collapse_triangulate = True
m.modifiers.move(len(m.modifiers) - 1, 0)
with bpy.context.temp_override(object=m, active_object=m):
bpy.ops.object.modifier_apply(modifier=mod.name)
total = sum(triangulos(m) for m in malhas)
if total <= MAX_TRIS:
return total
razao = alvo / total
falhar(f"redução parou em {total} triângulos")
def comprimento_perna(esqueleto):
ossos = esqueleto.pose.bones
juntas = [esqueleto.matrix_world @ ossos["mixamorig:Left" + n].head for n in PERNA]
return sum((b - a).length for a, b in zip(juntas, juntas[1:]))
def contato(esqueleto):
ossos = esqueleto.pose.bones
pes = [f"mixamorig:{lado}{osso}" for lado in ("Left", "Right") for osso in ("Foot", "ToeBase")]
return min((esqueleto.matrix_world @ ossos[n].head).z - ossos[n].bone.head_local.z for n in pes)
def quadro_final(acao):
fim = int(acao.frame_range[0])
for curva in acao.layers[0].strips[0].channelbags[0].fcurves:
pontos = curva.keyframe_points
for anterior, ponto in zip(pontos, pontos[1:]):
if abs(ponto.co[1] - anterior.co[1]) > 1e-4:
fim = max(fim, round(ponto.co[0]))
return fim
def alinhar_rolagem(esqueleto, fonte):
"""Gira cada osso do MPFB no próprio eixo até o Z dele apontar como o do osso do Mixamo no repouso.
A cópia de rotação é em espaço de mundo: com a rolagem igual, o osso novo fica com a direção e a torção do
osso do clipe, mesmo com o repouso diferente (MPFB em A, Mixamo em T). A malha não muda: só o eixo do osso gira.
"""
bpy.context.view_layer.objects.active = esqueleto
bpy.ops.object.mode_set(mode="EDIT")
para_local = esqueleto.matrix_world.inverted().to_3x3()
for osso in esqueleto.data.edit_bones:
if osso.name in fonte.data.bones:
z = (fonte.matrix_world.to_3x3() @ fonte.data.bones[osso.name].matrix_local.to_3x3()).col[2]
osso.align_roll(para_local @ z)
bpy.ops.object.mode_set(mode="OBJECT")
def transferir(esqueleto, nome_clipe, arquivo, alinhar):
"""Como em 3d/jogo.py: rotação de mundo de todos os ossos e posição só do quadril, escalada pela perna e com o pé
de apoio no chão no primeiro quadro; assa a ação."""
antes = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=os.path.join(CLIPES_FBX, arquivo))
novos = [o for o in bpy.data.objects if o not in antes]
fonte = next(o for o in novos if o.type == "ARMATURE")
if alinhar:
alinhar_rolagem(esqueleto, fonte)
acao_fonte = fonte.animation_data.action
inicio, fim = int(acao_fonte.frame_range[0]), quadro_final(acao_fonte)
bpy.context.scene.frame_set(inicio)
fonte.scale *= comprimento_perna(esqueleto) / comprimento_perna(fonte)
sem_par = []
for pb in esqueleto.pose.bones:
if pb.name not in fonte.pose.bones:
sem_par.append(pb.name)
continue
c = pb.constraints.new("COPY_ROTATION")
c.target, c.subtarget = fonte, pb.name
if pb.name == QUADRIL:
c = pb.constraints.new("COPY_LOCATION")
c.target, c.subtarget = fonte, pb.name
if sem_par:
falhar(f"ossos do MPFB sem par no clipe: {sem_par}")
bpy.context.scene.frame_set(inicio)
fonte.location.z -= contato(esqueleto)
bpy.context.view_layer.objects.active = esqueleto
bpy.ops.object.mode_set(mode="POSE")
bpy.ops.pose.select_all(action="SELECT")
bpy.ops.nla.bake(frame_start=inicio, frame_end=fim, only_selected=True, visual_keying=True,
clear_constraints=True, use_current_action=False, bake_types={"POSE"},
channel_types={"LOCATION", "ROTATION"})
bpy.ops.object.mode_set(mode="OBJECT")
acao = esqueleto.animation_data.action
acao.name = nome_clipe
acao.use_fake_user = True
curvas = acao.layers[0].strips[0].channelbags[0].fcurves
for c in [c for c in curvas if c.data_path.endswith(".location") and f'["{QUADRIL}"]' not in c.data_path]:
curvas.remove(c)
ossos_fonte = fonte.data.bones.keys()
for o in novos:
bpy.data.objects.remove(o)
bpy.data.actions.remove(acao_fonte)
return ossos_fonte, inicio, fim
def exportar(esqueleto, malhas):
os.makedirs(RENDERS, exist_ok=True)
bpy.ops.object.select_all(action="DESELECT")
for o in [esqueleto, *malhas]:
o.select_set(True)
esqueleto.animation_data.action = None
for acao in [a for a in bpy.data.actions if a.name not in CLIPES]:
bpy.data.actions.remove(acao)
bpy.context.scene.frame_set(1)
bpy.ops.export_scene.gltf(filepath=GLB, export_format="GLB", use_selection=True, export_yup=True,
export_apply=False, export_cameras=False, export_lights=False, export_morph=False,
export_tangents=False, export_image_format="JPEG", export_jpeg_quality=80,
export_animations=True, export_animation_mode="ACTIONS",
export_anim_slide_to_zero=True, export_force_sampling=False,
export_optimize_animation_size=True)
def renderizar():
"""Reabre o GLB e fotografa com a luz de renderizar() em 3d/jogo.py: parado no quadro 30 (frente e perto) e
andando no meio do clipe."""
bpy.ops.wm.read_homefile(use_empty=True)
cena = bpy.context.scene
bpy.ops.import_scene.gltf(filepath=GLB)
esqueleto = next(o for o in cena.objects if o.type == "ARMATURE")
malhas = [o for o in cena.objects if o.type == "MESH" and o.parent == esqueleto] # sem a esfera do importador
pontos = [o.matrix_world @ v.co for o in malhas for v in o.data.vertices]
pe, topo = min(p.z for p in pontos), max(p.z for p in pontos)
mundo = bpy.data.worlds.new("neutro")
mundo.use_nodes = True
mundo.node_tree.nodes["Background"].inputs["Color"].default_value = (0.5, 0.5, 0.5, 1)
mundo.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.6
cena.world = mundo
luz = bpy.data.objects.new("principal", bpy.data.lights.new("principal", "AREA"))
luz.data.energy, luz.data.size = 300, 2
luz.location = (1.5, -3, 3)
luz.rotation_euler = (Vector((0, 0, 1.2)) - luz.location).to_track_quat("-Z", "Y").to_euler()
cena.collection.objects.link(luz)
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
cena.collection.objects.link(camera)
cena.camera = camera
ligar_cycles(cena)
acoes = {a.name.split("_")[0]: a for a in bpy.data.actions}
for nome, clipe, quadro, alvo, distancia in (("frente", "parado", 30, 0.9, 3.2), ("perto", "parado", 30, 1.4, 1.5),
("andando", "andar", None, 0.9, 3.2)):
acao = next(a for n, a in acoes.items() if n.startswith(clipe))
esqueleto.animation_data.action = acao
cena.frame_set(quadro or int(sum(acao.frame_range) / 2))
camera.location = (0.3 * distancia / 4, -distancia, alvo + 0.1)
camera.rotation_euler = (Vector((0, 0, alvo)) - camera.location).to_track_quat("-Z", "Y").to_euler()
sair(cena, os.path.join(RENDERS, f"humano-{nome}.png"), (800, 800), "PNG", "RGB", 64, False)
return pe, topo
def main():
dados = instalar_pacotes()
esqueleto, corpo, pecas = criar(dados)
roupas = [p for p, (tipo, _) in zip(pecas, PECAS) if tipo == "Clothes"]
t_corpo_visivel = triangulos(corpo)
for mod in corpo.modifiers: # mede o corpo sem as máscaras de roupa ("Delete" do MPFB)
if mod.type == "MASK" and mod.vertex_group != "body":
mod.show_viewport = False
t_corpo = triangulos(corpo)
for mod in corpo.modifiers:
mod.show_viewport = True
t_roupa = sum(triangulos(r) for r in roupas)
t_resto = sum(triangulos(p) for p in pecas if p not in roupas)
malhas = [corpo, *pecas]
t_total = t_corpo + t_roupa + t_resto
print(f"triângulos: corpo {t_corpo} (sem as faces sob a roupa: {t_corpo_visivel}), roupa {t_roupa}, "
f"olhos+sobrancelha+cabelo {t_resto}, total {t_total}", flush=True)
for m in malhas:
aplicar_modificadores(m)
t_escondido = sum(triangulos(m) for m in malhas)
tris = reduzir(malhas, MAX_TRIS * 0.97)
print(f"triângulos: total sem as faces escondidas {t_escondido}, reduzido {tris}", flush=True)
for m in malhas:
print(f" {m.name}: {triangulos(m)}", flush=True)
ossos = [b.name for b in esqueleto.data.bones]
print(f"ossos ({len(ossos)}): {' '.join(ossos)}", flush=True)
for i, (nome, arquivo) in enumerate(CLIPES.items()):
ossos_clipe, inicio, fim = transferir(esqueleto, nome, arquivo, alinhar=i == 0)
print(f"clipe {nome} <- {arquivo} quadros {inicio}-{fim}; ossos do clipe sem par no MPFB: "
f"{sorted(set(ossos_clipe) - set(ossos))}", flush=True)
exportar(esqueleto, malhas)
print(f"humano.glb {os.path.getsize(GLB)} bytes", flush=True)
pe, topo = renderizar()
print(f"repouso: pés em {pe:.3f} m, altura {topo - pe:.3f} m", flush=True)
print(f"OK humano tris={tris} ossos={len(ossos)}", flush=True)
if __name__ == "__main__":
main()
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Refaz public/jogo/itens.webp (atlas 2x2 de 512x512) a partir de 3d/itens/*.png.
set -euo pipefail
cd "$(dirname "$0")/.."
FUZZ="${FUZZ:-6%}"
Q="${Q:-90}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
OUT=public/jogo/itens.webp
CONF=/home/evandro/tmp/jogo
mkdir -p "$CONF"
for n in municao kit cafe bateria; do
# fundo por preenchimento a partir dos 4 cantos (sem limiar global)
magick "3d/itens/$n.png" -alpha set -fuzz "$FUZZ" -fill none \
-draw "alpha 0,0 floodfill" -draw "alpha 1023,0 floodfill" \
-draw "alpha 0,1023 floodfill" -draw "alpha 1023,1023 floodfill" \
-channel A -morphology Erode Disk:1 -blur 0x0.7 +channel \
-trim +repage \
-bordercolor none -background none -gravity center \
-resize 215x215 \
-extent 256x256 "$TMP/$n.png"
done
magick -size 512x512 xc:none \
"$TMP/municao.png" -geometry +0+0 -composite \
"$TMP/kit.png" -geometry +256+0 -composite \
"$TMP/cafe.png" -geometry +0+256 -composite \
"$TMP/bateria.png" -geometry +256+256 -composite \
-define webp:alpha-quality=100 -define webp:method=6 -quality "$Q" "$OUT"
magick -size 512x512 xc:"#808080" "$OUT" -composite "$CONF/itens-atlas-cinza.png"
magick -size 512x512 xc:"#d00000" "$OUT" -composite "$CONF/itens-atlas-vermelho.png"
ls -l "$OUT"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

-1094
View File
File diff suppressed because it is too large. Load diff
-355
View File
@@ -1,355 +0,0 @@
"""Mapa do escritório do jogo em /jogo: gera a primeira versão do .tmx do Tiled e converte o JSON exportado para a grade da lógica.
A fonte do mapa é o 3d/mapa/escritorio.tmx, editado à mão no Tiled. Rodar da raiz do repositório (o 3d/mapa.sh faz a cadeia toda):
python3 3d/mapa.py gerar sobrescreve 3d/mapa/escritorio.tmx e 3d/mapa/ladrilhos.png pela semente, apagando as edições
feitas no Tiled; só roda sob `bash 3d/mapa.sh --gerar`
python3 3d/mapa.py converter lê 3d/mapa/escritorio.json (exportado pelo Tiled) e escreve lib/jogo/mapas/escritorio.ts
Coordenadas em células de 1 m: x cresce para a direita (leste), z para baixo (sul) no desenho; retângulos inclusivos.
"""
import json
import random
import struct
import sys
import zlib
from pathlib import Path
from xml.sax.saxutils import escape, quoteattr
SEMENTE = 20261110
LARGURA, ALTURA, LADO = 44, 32, 32
RAIZ = Path(__file__).resolve().parent.parent
PASTA = RAIZ / "3d" / "mapa"
SAIDA_TS = RAIZ / "lib" / "jogo" / "mapas" / "escritorio.ts"
# nome, cor RGB, grade da lógica ("#" bloqueia passo e visão, "M" só o passo, "" é enfeite), cobre a célula inteira
LADRILHOS = [
("parede", (55, 58, 70), "#", True),
("chão", (214, 206, 188), ".", True),
("mesa", (176, 120, 64), "M", True),
("cadeira", (40, 110, 200), "", False),
("armário", (110, 140, 110), "#", True),
("estante de servidor", (0, 150, 140), "#", True),
("sofá", (190, 50, 60), "M", True),
("bancada", (230, 180, 40), "M", True),
("planta de vaso", (60, 160, 60), "", False),
("bebedouro", (120, 200, 240), "", False),
]
GID = {nome: i + 1 for i, (nome, *_resto) in enumerate(LADRILHOS)}
PAREDES = [
# borda
(0, 0, 43, 0), (0, 31, 43, 31), (0, 0, 0, 31), (43, 0, 43, 31),
# faixas de salas e anel do corredor
(0, 7, 43, 7), (0, 24, 43, 24), (9, 7, 9, 24), (34, 7, 34, 24),
(12, 10, 31, 10), (12, 21, 31, 21), (12, 10, 12, 21), (31, 10, 31, 21),
# divisórias
(15, 0, 15, 7), (28, 0, 28, 7), (0, 16, 9, 16), (34, 16, 43, 16), (17, 24, 17, 31), (24, 24, 24, 31),
# poços dos elevadores, caixa da escada, cabines dos banheiros
(1, 29, 3, 30), (6, 29, 8, 30), (21, 27, 23, 30), (1, 17, 2, 21),
]
AMBIENTES = [
("recepção", 1, 1, 14, 6),
("sala de reunião", 16, 1, 27, 6),
("diretoria", 29, 1, 42, 6),
("copa", 1, 8, 8, 15),
("banheiros", 1, 17, 8, 23),
("área de mesas", 13, 11, 30, 20),
("sala do servidor", 35, 8, 42, 15),
("almoxarifado", 35, 17, 42, 23),
("hall dos elevadores", 1, 25, 16, 30),
("escada", 18, 25, 23, 30),
("área de serviço", 25, 25, 42, 30),
]
# trechos do anel, só para espalhar luminárias
CORREDOR = [(10, 8, 33, 9), (10, 22, 33, 23), (10, 10, 11, 21), (32, 10, 33, 21)]
PORTAS = [
("recepção – copa", 4, 7, 5, 7),
("recepção – corredor", 12, 7, 13, 7),
("sala de reunião – corredor", 18, 7, 19, 7),
("sala de reunião – corredor", 24, 7, 25, 7),
("sala de reunião – diretoria", 28, 3, 28, 4),
("diretoria – corredor", 30, 7, 31, 7),
("copa – corredor", 9, 11, 9, 12),
("banheiros – corredor", 9, 19, 9, 20),
("banheiros – hall dos elevadores", 4, 24, 5, 24),
("hall dos elevadores – corredor", 13, 24, 14, 24),
("hall dos elevadores – escada", 17, 27, 17, 28),
("escada – corredor", 20, 24, 21, 24),
("área de serviço – corredor", 28, 24, 29, 24),
("área de serviço – almoxarifado", 38, 24, 39, 24),
("almoxarifado – corredor", 34, 19, 34, 20),
("sala do servidor – almoxarifado", 38, 16, 39, 16),
("sala do servidor – corredor", 34, 11, 34, 12),
("área de mesas – corredor norte", 21, 10, 22, 10),
("área de mesas – corredor norte", 28, 10, 29, 10),
("área de mesas – corredor sul", 21, 21, 22, 21),
("área de mesas – corredor oeste", 12, 15, 12, 16),
("área de mesas – corredor leste", 31, 15, 31, 16),
]
ILHAS = [(15, 13, 19, 14), (23, 13, 27, 14), (15, 17, 19, 18), (23, 17, 27, 18)]
MOVEIS = [
# recepção
("sofá", 3, 1, 5, 1), ("sofá", 1, 3, 1, 6), ("bancada", 8, 3, 11, 3),
("cadeira", 9, 2, 9, 2), ("cadeira", 10, 2, 10, 2), ("planta de vaso", 1, 1, 1, 1), ("bebedouro", 14, 1, 14, 1),
# sala de reunião
("mesa", 18, 3, 25, 4), ("cadeira", 17, 3, 17, 3), ("cadeira", 26, 4, 26, 4),
("planta de vaso", 16, 1, 16, 1), ("planta de vaso", 27, 1, 27, 1),
# diretoria
("mesa", 35, 3, 38, 4), ("cadeira", 37, 2, 37, 2), ("cadeira", 36, 5, 36, 5), ("cadeira", 38, 5, 38, 5),
("sofá", 29, 1, 31, 1), ("armário", 42, 1, 42, 4), ("planta de vaso", 41, 6, 41, 6),
# copa
("bancada", 1, 11, 1, 14), ("bancada", 1, 15, 4, 15), ("armário", 7, 15, 8, 15), ("mesa", 4, 11, 5, 12),
("cadeira", 3, 11, 3, 11), ("cadeira", 6, 12, 6, 12), ("cadeira", 4, 10, 4, 10), ("cadeira", 5, 13, 5, 13),
("bebedouro", 1, 8, 1, 8),
# banheiros
("bancada", 5, 17, 8, 17), ("planta de vaso", 8, 23, 8, 23),
# hall dos elevadores
("sofá", 11, 30, 14, 30), ("planta de vaso", 1, 25, 1, 25), ("bebedouro", 16, 25, 16, 25),
# área de serviço
("armário", 27, 30, 32, 30), ("bancada", 40, 27, 42, 28), ("bebedouro", 33, 25, 33, 25),
# almoxarifado
("armário", 42, 17, 42, 19), ("armário", 37, 20, 39, 20), ("armário", 35, 23, 36, 23),
# sala do servidor
("estante de servidor", 37, 10, 42, 10), ("estante de servidor", 37, 13, 42, 13),
# área de mesas
*[("mesa", *ilha) for ilha in ILHAS],
("armário", 30, 11, 30, 13), ("planta de vaso", 13, 11, 13, 11), ("planta de vaso", 13, 20, 13, 20),
("planta de vaso", 30, 20, 30, 20), ("bebedouro", 30, 14, 30, 14),
# corredor
("planta de vaso", 10, 8, 10, 8), ("planta de vaso", 33, 8, 33, 8),
("planta de vaso", 10, 23, 10, 23), ("planta de vaso", 33, 23, 33, 23),
]
INICIO = (7, 5)
NASCEDOUROS = [
("elevador 1", 2, 27), ("elevador 2", 7, 27), ("escada", 19, 29), ("banheiros", 6, 21),
("fundos, área de serviço", 26, 29), ("fundos, área de serviço", 41, 30), ("fundos, almoxarifado", 41, 22),
]
# célula de chão e para onde o cartaz olha; a parede fica do lado oposto
CARTAZES = [
(7, 1, "S"), (12, 1, "S"), (21, 1, "S"), (16, 4, "L"), (36, 1, "S"), (8, 14, "O"), (20, 9, "N"),
(10, 18, "L"), (33, 14, "O"), (25, 22, "S"), (20, 11, "S"), (13, 18, "L"), (11, 25, "S"),
]
FACES = {"N": (0, 1), "S": (0, -1), "L": (-1, 0), "O": (1, 0)} # deslocamento até a parede
NOTAS = """Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui.
Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo).
Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4)."""
def celulas(x0, z0, x1, z1):
return [(x, z) for z in range(z0, z1 + 1) for x in range(x0, x1 + 1)]
def png(largura, altura, rgba):
"""PNG RGBA 8 bits sem metadados (os mesmos pixels dão os mesmos bytes)."""
linhas = b"".join(b"\0" + rgba[y * largura * 4 : (y + 1) * largura * 4] for y in range(altura))
def bloco(tipo, dados):
return struct.pack(">I", len(dados)) + tipo + dados + struct.pack(">I", zlib.crc32(tipo + dados))
cabecalho = struct.pack(">IIBBBBB", largura, altura, 8, 6, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + bloco(b"IHDR", cabecalho) + bloco(b"IDAT", zlib.compress(linhas, 9)) + bloco(b"IEND", b"")
def imagem_ladrilhos():
"""Uma linha de ladrilhos de 32 px: cheios com borda escura; enfeites são um quadrado menor sobre fundo transparente."""
largura = LADO * len(LADRILHOS)
pixels = bytearray(largura * LADO * 4)
for i, (_nome, cor, _grade, cheio) in enumerate(LADRILHOS):
escura = tuple(c // 2 for c in cor)
margem = 0 if cheio else 8
for y in range(margem, LADO - margem):
for x in range(margem, LADO - margem):
borda = min(x - margem, y - margem, LADO - 1 - margem - x, LADO - 1 - margem - y) < 2
p = (y * largura + i * LADO + x) * 4
pixels[p : p + 4] = bytes((*(escura if borda else cor), 255))
return png(largura, LADO, bytes(pixels))
def montar_planta():
planta = [["chão"] * LARGURA for _ in range(ALTURA)]
for parede in PAREDES:
for x, z in celulas(*parede):
planta[z][x] = "parede"
for _nome, *porta in PORTAS:
for x, z in celulas(*porta):
planta[z][x] = "chão"
return planta
def montar_moveis(rng):
moveis = [[None] * LARGURA for _ in range(ALTURA)]
for tipo, *retangulo in MOVEIS:
for x, z in celulas(*retangulo):
moveis[z][x] = tipo
# cadeiras dos dois lados de cada ilha; algumas vagas ficam sem cadeira
for x0, z0, x1, z1 in ILHAS:
for x in range(x0, x1 + 1):
for z in (z0 - 1, z1 + 1):
if rng.random() < 0.8:
moveis[z][x] = "cadeira"
return moveis
def objeto(id_, tipo, nome, x, y, largura=None, altura=None, propriedades=()):
attrs = f'id="{id_}" name={quoteattr(nome)} type="{tipo}" x="{x:g}" y="{y:g}"'
if largura is not None:
attrs += f' width="{largura:g}" height="{altura:g}"'
corpo = []
if propriedades:
corpo.append(" <properties>")
corpo += [f' <property name="{n}" {t}value={quoteattr(str(v))}/>' for n, t, v in propriedades]
corpo.append(" </properties>")
if largura is None:
corpo.append(" <point/>")
return [f" <object {attrs}>", *corpo, " </object>"]
def luminarias(x0, z0, x1, z1):
"""Pontos a cada ~5 m dentro do retângulo, em pixels, arredondados à meia célula."""
largura, altura = x1 - x0 + 1, z1 - z0 + 1
nx, nz = max(1, round(largura / 5)), max(1, round(altura / 5))
return [
(round((x0 + largura * (i + 0.5) / nx) * 2) * LADO // 2, round((z0 + altura * (j + 0.5) / nz) * 2) * LADO // 2)
for j in range(nz)
for i in range(nx)
]
def gerar():
rng = random.Random(SEMENTE)
planta = montar_planta()
moveis = montar_moveis(rng)
marcas = []
total = 0
def novo(*args, **kwargs):
nonlocal total
total += 1
marcas.extend(objeto(total, *args, **kwargs))
novo("inicio", "inicio", (INICIO[0] + 0.5) * LADO, (INICIO[1] + 0.5) * LADO)
for nome, x, z in NASCEDOUROS:
novo("nascedouro", nome, (x + 0.5) * LADO, (z + 0.5) * LADO)
for nome, x0, z0, x1, z1 in AMBIENTES:
novo("ambiente", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO)
for nome, x0, z0, x1, z1 in PORTAS:
novo("porta", nome, x0 * LADO, z0 * LADO, (x1 - x0 + 1) * LADO, (z1 - z0 + 1) * LADO)
for retangulo in [r[1:] for r in AMBIENTES] + CORREDOR:
for x, y in luminarias(*retangulo):
novo("luminaria", "luminaria", x, y)
for x, z, face in CARTAZES:
dx, dz = FACES[face]
assert planta[z][x] == "chão" and planta[z + dz][x + dx] == "parede", (x, z, face)
px = (x + 0.5 + dx / 2) * LADO
pz = (z + 0.5 + dz / 2) * LADO
propriedades = [("face", "", face), ("imagem", 'type="int" ', rng.randint(1, 4))]
novo("cartaz", "cartaz", px, pz, propriedades=propriedades)
def csv(grade):
return ",\n".join(",".join(str(GID[c] if c else 0) for c in linha) for linha in grade)
tiles = []
for i, (nome, _cor, grade, _cheio) in enumerate(LADRILHOS):
tiles += [
f' <tile id="{i}">',
" <properties>",
f" <property name=\"nome\" value={quoteattr(nome)}/>",
f' <property name="grade" value="{grade}"/>',
" </properties>",
" </tile>",
]
marcas_txt = "\n".join(marcas)
tmx = "\n".join(
[
'<?xml version="1.0" encoding="UTF-8"?>',
f'<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="{LARGURA}" height="{ALTURA}" tilewidth="{LADO}" tileheight="{LADO}" infinite="0" nextlayerid="4" nextobjectid="{total + 1}">',
" <properties>",
f' <property name="notas">{escape(NOTAS)}</property>',
f' <property name="semente" type="int" value="{SEMENTE}"/>',
" </properties>",
f' <tileset firstgid="1" name="ladrilhos" tilewidth="{LADO}" tileheight="{LADO}" tilecount="{len(LADRILHOS)}" columns="{len(LADRILHOS)}">',
f' <image source="ladrilhos.png" width="{LADO * len(LADRILHOS)}" height="{LADO}"/>',
*tiles,
" </tileset>",
f' <layer id="1" name="planta" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(planta),
"</data>",
" </layer>",
f' <layer id="2" name="moveis" width="{LARGURA}" height="{ALTURA}">',
' <data encoding="csv">',
csv(moveis),
"</data>",
" </layer>",
' <objectgroup id="3" name="marcas">',
marcas_txt,
" </objectgroup>",
"</map>",
"",
]
)
PASTA.mkdir(parents=True, exist_ok=True)
(PASTA / "escritorio.tmx").write_text(tmx, encoding="utf-8")
(PASTA / "ladrilhos.png").write_bytes(imagem_ladrilhos())
def converter():
dados = json.loads((PASTA / "escritorio.json").read_text(encoding="utf-8"))
largura, altura, lado = dados["width"], dados["height"], dados["tilewidth"]
grade_por_gid = {}
for tileset in dados["tilesets"]:
for tile in tileset.get("tiles", []):
propriedades = {p["name"]: p["value"] for p in tile.get("properties", [])}
grade_por_gid[tileset["firstgid"] + tile["id"]] = propriedades["grade"]
camadas = {c["name"]: c for c in dados["layers"]}
linhas = [["#"] * largura for _ in range(altura)]
for nome in ("planta", "moveis"):
for i, gid in enumerate(camadas[nome]["data"]):
grade = grade_por_gid.get(gid & 0x1FFFFFFF, "") # bits altos: ladrilho espelhado ou girado
if grade:
linhas[i // largura][i % largura] = grade
ambientes, portas = [], []
for obj in camadas["marcas"]["objects"]:
x, z = int(obj["x"] // lado), int(obj["y"] // lado)
if obj["type"] in ("inicio", "nascedouro"):
linhas[z][x] = "J" if obj["type"] == "inicio" else "N"
elif obj["type"] in ("ambiente", "porta"):
retangulo = {"x": x, "z": z, "largura": round(obj["width"] / lado), "altura": round(obj["height"] / lado)}
if obj["type"] == "ambiente":
ambientes.append({"nome": obj["name"], **retangulo})
else:
portas.append(retangulo)
def lista(itens):
return "[\n" + "".join(f" {json.dumps(i, ensure_ascii=False)},\n" for i in itens) + "]"
retangulo_ts = "x: number; z: number; largura: number; altura: number"
ts = f"""// Gerado por `bash 3d/mapa.sh` (`python3 3d/mapa.py converter`) a partir de 3d/mapa/escritorio.tmx: não edite à mão.
/** Planta do escritório para `lerMapa`: `#` parede ou móvel alto, `M` móvel baixo, `.` chão, `J` início, `N` nascedouro. */
export const ESCRITORIO: readonly string[] = {lista("".join(linha) for linha in linhas)};
/** Salas com nome, em células (x para a direita, z para baixo). */
export const AMBIENTES: readonly {{ nome: string; {retangulo_ts} }}[] = {lista(ambientes)};
/** Células abertas nas paredes, em células. */
export const PORTAS: readonly {{ {retangulo_ts} }}[] = {lista(portas)};
"""
SAIDA_TS.parent.mkdir(parents=True, exist_ok=True)
SAIDA_TS.write_text(ts, encoding="utf-8")
if __name__ == "__main__":
comandos = {"gerar": gerar, "converter": converter}
if len(sys.argv) != 2 or sys.argv[1] not in comandos:
sys.exit("uso: python3 3d/mapa.py gerar|converter")
comandos[sys.argv[1]]()
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Refaz o mapa do escritório a partir de 3d/mapa/escritorio.tmx, a fonte editada no Tiled: exporta JSON e PNG pelo
# Tiled (sem janela), converte para a grade da lógica e monta o 3D no Blender. Rodar de qualquer lugar:
# bash 3d/mapa.sh só exporta, converte e monta
# bash 3d/mapa.sh --gerar antes, refaz o .tmx pelo 3d/mapa.py (apaga o que foi editado à mão no Tiled)
set -euo pipefail
cd "$(dirname "$0")/.."
tiled() {
env -u DISPLAY -u WAYLAND_DISPLAY flatpak run --env=QT_QPA_PLATFORM=offscreen --command="$1" org.mapeditor.Tiled "${@:2}"
}
case "${1:-}" in
"") ;;
--gerar)
echo "aviso: --gerar sobrescreve 3d/mapa/escritorio.tmx pelo 3d/mapa.py; o que foi editado no Tiled se perde" >&2
python3 3d/mapa.py gerar
;;
*)
echo "uso: bash 3d/mapa.sh [--gerar]" >&2
exit 2
;;
esac
tiled tiled --export-map json "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.json"
tiled tmxrasterizer "$PWD/3d/mapa/escritorio.tmx" "$PWD/3d/mapa/escritorio.png"
python3 3d/mapa.py converter
./node_modules/.bin/biome format --write lib/jogo/mapas/escritorio.ts 3d/mapa/escritorio.json
blender -b -P 3d/mapa_3d.py
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

-477
View File
@@ -1,477 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.2" orientation="orthogonal" renderorder="right-down" width="44" height="32" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="105">
<properties>
<property name="notas">Escritório do jogo em /jogo, 1 célula = 1 m. Este arquivo é a fonte do mapa: abra no Tiled (`flatpak run org.mapeditor.Tiled`), edite, salve e rode `bash 3d/mapa.sh`, que exporta o JSON, converte a grade da lógica e monta o 3D. `bash 3d/mapa.sh --gerar` refaz este arquivo pelo 3d/mapa.py e apaga as edições feitas aqui.
Camadas: planta (parede e chão), moveis (a propriedade grade de cada ladrilho diz como entra na lógica: # bloqueia passo e visão, M bloqueia o passo e deixa o tiro passar, vazio é só enfeite), marcas (objetos pelo tipo).
Tipos em marcas: inicio (ponto do jogador), nascedouro (ponto; o nome diz o lugar), ambiente (retângulo com o nome da sala), porta (retângulo sobre as células abertas na parede), luminaria (ponto no teto), cartaz (ponto na face da parede; face = para onde o cartaz olha: N para cima no desenho, S para baixo, L para a direita, O para a esquerda; imagem de 1 a 4).</property>
<property name="semente" type="int" value="20261110"/>
</properties>
<tileset firstgid="1" name="ladrilhos" tilewidth="32" tileheight="32" tilecount="10" columns="10">
<image source="ladrilhos.png" width="320" height="32"/>
<tile id="0">
<properties>
<property name="nome" value="parede"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="1">
<properties>
<property name="nome" value="chão"/>
<property name="grade" value="."/>
</properties>
</tile>
<tile id="2">
<properties>
<property name="nome" value="mesa"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="3">
<properties>
<property name="nome" value="cadeira"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="4">
<properties>
<property name="nome" value="armário"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="5">
<properties>
<property name="nome" value="estante de servidor"/>
<property name="grade" value="#"/>
</properties>
</tile>
<tile id="6">
<properties>
<property name="nome" value="sofá"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="7">
<properties>
<property name="nome" value="bancada"/>
<property name="grade" value="M"/>
</properties>
</tile>
<tile id="8">
<properties>
<property name="nome" value="planta de vaso"/>
<property name="grade" value=""/>
</properties>
</tile>
<tile id="9">
<properties>
<property name="nome" value="bebedouro"/>
<property name="grade" value=""/>
</properties>
</tile>
</tileset>
<layer id="1" name="planta" width="44" height="32">
<data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,1,1,1,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,2,2,2,2,2,2,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,1,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,1,1,1,1,2,2,1,1,1,1,1,2,2,1,1,1,1,1,1,2,2,1,1,1,1,1,1,1,1,2,2,1,1,1,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,2,2,1,1,1,2,2,2,2,2,2,2,2,1,2,2,2,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
</data>
</layer>
<layer id="2" name="moveis" width="44" height="32">
<data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,9,0,7,7,7,0,0,0,0,0,0,0,0,10,0,9,0,0,0,0,0,0,0,0,0,0,9,0,7,7,7,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,5,0,
0,7,0,0,0,0,0,0,8,8,8,8,0,0,0,0,0,4,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,4,0,0,0,0,0,0,0,0,3,3,3,3,0,0,0,5,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,0,0,0,0,0,
0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,10,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,6,6,6,6,0,
0,8,0,4,3,3,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,0,0,3,3,4,0,0,0,0,0,0,0,0,4,0,4,4,4,0,0,0,4,0,4,0,4,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,0,0,0,4,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,5,0,0,0,0,0,0,6,6,6,6,6,6,0,
0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,8,8,8,8,0,0,5,5,0,0,0,0,0,0,4,4,4,4,4,0,0,0,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,0,4,0,0,0,0,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,8,8,8,8,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,4,4,0,0,0,4,0,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,0,0,0,0,0,5,5,5,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,9,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,9,0,5,5,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,8,8,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
</data>
</layer>
<objectgroup id="3" name="marcas">
<object id="1" name="inicio" type="inicio" x="240" y="176">
<point/>
</object>
<object id="2" name="elevador 1" type="nascedouro" x="80" y="880">
<point/>
</object>
<object id="3" name="elevador 2" type="nascedouro" x="240" y="880">
<point/>
</object>
<object id="4" name="escada" type="nascedouro" x="624" y="944">
<point/>
</object>
<object id="5" name="banheiros" type="nascedouro" x="208" y="688">
<point/>
</object>
<object id="6" name="fundos, área de serviço" type="nascedouro" x="848" y="944">
<point/>
</object>
<object id="7" name="fundos, área de serviço" type="nascedouro" x="1328" y="976">
<point/>
</object>
<object id="8" name="fundos, almoxarifado" type="nascedouro" x="1328" y="720">
<point/>
</object>
<object id="9" name="recepção" type="ambiente" x="32" y="32" width="448" height="192">
</object>
<object id="10" name="sala de reunião" type="ambiente" x="512" y="32" width="384" height="192">
</object>
<object id="11" name="diretoria" type="ambiente" x="928" y="32" width="448" height="192">
</object>
<object id="12" name="copa" type="ambiente" x="32" y="256" width="256" height="256">
</object>
<object id="13" name="banheiros" type="ambiente" x="32" y="544" width="256" height="224">
</object>
<object id="14" name="área de mesas" type="ambiente" x="416" y="352" width="576" height="320">
</object>
<object id="15" name="sala do servidor" type="ambiente" x="1120" y="256" width="256" height="256">
</object>
<object id="16" name="almoxarifado" type="ambiente" x="1120" y="544" width="256" height="224">
</object>
<object id="17" name="hall dos elevadores" type="ambiente" x="32" y="800" width="512" height="192">
</object>
<object id="18" name="escada" type="ambiente" x="576" y="800" width="192" height="192">
</object>
<object id="19" name="área de serviço" type="ambiente" x="800" y="800" width="576" height="192">
</object>
<object id="20" name="recepção – copa" type="porta" x="128" y="224" width="64" height="32">
</object>
<object id="21" name="recepção – corredor" type="porta" x="384" y="224" width="64" height="32">
</object>
<object id="22" name="sala de reunião – corredor" type="porta" x="576" y="224" width="64" height="32">
</object>
<object id="23" name="sala de reunião – corredor" type="porta" x="768" y="224" width="64" height="32">
</object>
<object id="24" name="sala de reunião – diretoria" type="porta" x="896" y="96" width="32" height="64">
</object>
<object id="25" name="diretoria – corredor" type="porta" x="960" y="224" width="64" height="32">
</object>
<object id="26" name="copa – corredor" type="porta" x="288" y="352" width="32" height="64">
</object>
<object id="27" name="banheiros – corredor" type="porta" x="288" y="608" width="32" height="64">
</object>
<object id="28" name="banheiros – hall dos elevadores" type="porta" x="128" y="768" width="64" height="32">
</object>
<object id="29" name="hall dos elevadores – corredor" type="porta" x="416" y="768" width="64" height="32">
</object>
<object id="30" name="hall dos elevadores – escada" type="porta" x="544" y="864" width="32" height="64">
</object>
<object id="31" name="escada – corredor" type="porta" x="640" y="768" width="64" height="32">
</object>
<object id="32" name="área de serviço – corredor" type="porta" x="896" y="768" width="64" height="32">
</object>
<object id="33" name="área de serviço – almoxarifado" type="porta" x="1216" y="768" width="64" height="32">
</object>
<object id="34" name="almoxarifado – corredor" type="porta" x="1088" y="608" width="32" height="64">
</object>
<object id="35" name="sala do servidor – almoxarifado" type="porta" x="1216" y="512" width="64" height="32">
</object>
<object id="36" name="sala do servidor – corredor" type="porta" x="1088" y="352" width="32" height="64">
</object>
<object id="37" name="área de mesas – corredor norte" type="porta" x="672" y="320" width="64" height="32">
</object>
<object id="38" name="área de mesas – corredor norte" type="porta" x="896" y="320" width="64" height="32">
</object>
<object id="39" name="área de mesas – corredor sul" type="porta" x="672" y="672" width="64" height="32">
</object>
<object id="40" name="área de mesas – corredor oeste" type="porta" x="384" y="480" width="32" height="64">
</object>
<object id="41" name="área de mesas – corredor leste" type="porta" x="992" y="480" width="32" height="64">
</object>
<object id="42" name="luminaria" type="luminaria" x="112" y="128">
<point/>
</object>
<object id="43" name="luminaria" type="luminaria" x="256" y="128">
<point/>
</object>
<object id="44" name="luminaria" type="luminaria" x="400" y="128">
<point/>
</object>
<object id="45" name="luminaria" type="luminaria" x="608" y="128">
<point/>
</object>
<object id="46" name="luminaria" type="luminaria" x="800" y="128">
<point/>
</object>
<object id="47" name="luminaria" type="luminaria" x="1008" y="128">
<point/>
</object>
<object id="48" name="luminaria" type="luminaria" x="1152" y="128">
<point/>
</object>
<object id="49" name="luminaria" type="luminaria" x="1296" y="128">
<point/>
</object>
<object id="50" name="luminaria" type="luminaria" x="96" y="320">
<point/>
</object>
<object id="51" name="luminaria" type="luminaria" x="224" y="320">
<point/>
</object>
<object id="52" name="luminaria" type="luminaria" x="96" y="448">
<point/>
</object>
<object id="53" name="luminaria" type="luminaria" x="224" y="448">
<point/>
</object>
<object id="54" name="luminaria" type="luminaria" x="96" y="656">
<point/>
</object>
<object id="55" name="luminaria" type="luminaria" x="224" y="656">
<point/>
</object>
<object id="56" name="luminaria" type="luminaria" x="480" y="432">
<point/>
</object>
<object id="57" name="luminaria" type="luminaria" x="640" y="432">
<point/>
</object>
<object id="58" name="luminaria" type="luminaria" x="768" y="432">
<point/>
</object>
<object id="59" name="luminaria" type="luminaria" x="928" y="432">
<point/>
</object>
<object id="60" name="luminaria" type="luminaria" x="480" y="592">
<point/>
</object>
<object id="61" name="luminaria" type="luminaria" x="640" y="592">
<point/>
</object>
<object id="62" name="luminaria" type="luminaria" x="768" y="592">
<point/>
</object>
<object id="63" name="luminaria" type="luminaria" x="928" y="592">
<point/>
</object>
<object id="64" name="luminaria" type="luminaria" x="1184" y="320">
<point/>
</object>
<object id="65" name="luminaria" type="luminaria" x="1312" y="320">
<point/>
</object>
<object id="66" name="luminaria" type="luminaria" x="1184" y="448">
<point/>
</object>
<object id="67" name="luminaria" type="luminaria" x="1312" y="448">
<point/>
</object>
<object id="68" name="luminaria" type="luminaria" x="1184" y="656">
<point/>
</object>
<object id="69" name="luminaria" type="luminaria" x="1312" y="656">
<point/>
</object>
<object id="70" name="luminaria" type="luminaria" x="112" y="896">
<point/>
</object>
<object id="71" name="luminaria" type="luminaria" x="288" y="896">
<point/>
</object>
<object id="72" name="luminaria" type="luminaria" x="464" y="896">
<point/>
</object>
<object id="73" name="luminaria" type="luminaria" x="672" y="896">
<point/>
</object>
<object id="74" name="luminaria" type="luminaria" x="864" y="896">
<point/>
</object>
<object id="75" name="luminaria" type="luminaria" x="1024" y="896">
<point/>
</object>
<object id="76" name="luminaria" type="luminaria" x="1152" y="896">
<point/>
</object>
<object id="77" name="luminaria" type="luminaria" x="1312" y="896">
<point/>
</object>
<object id="78" name="luminaria" type="luminaria" x="400" y="288">
<point/>
</object>
<object id="79" name="luminaria" type="luminaria" x="544" y="288">
<point/>
</object>
<object id="80" name="luminaria" type="luminaria" x="704" y="288">
<point/>
</object>
<object id="81" name="luminaria" type="luminaria" x="864" y="288">
<point/>
</object>
<object id="82" name="luminaria" type="luminaria" x="1008" y="288">
<point/>
</object>
<object id="83" name="luminaria" type="luminaria" x="400" y="736">
<point/>
</object>
<object id="84" name="luminaria" type="luminaria" x="544" y="736">
<point/>
</object>
<object id="85" name="luminaria" type="luminaria" x="704" y="736">
<point/>
</object>
<object id="86" name="luminaria" type="luminaria" x="864" y="736">
<point/>
</object>
<object id="87" name="luminaria" type="luminaria" x="1008" y="736">
<point/>
</object>
<object id="88" name="luminaria" type="luminaria" x="352" y="416">
<point/>
</object>
<object id="89" name="luminaria" type="luminaria" x="352" y="608">
<point/>
</object>
<object id="90" name="luminaria" type="luminaria" x="1056" y="416">
<point/>
</object>
<object id="91" name="luminaria" type="luminaria" x="1056" y="608">
<point/>
</object>
<object id="92" name="cartaz" type="cartaz" x="240" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="93" name="cartaz" type="cartaz" x="400" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="94" name="cartaz" type="cartaz" x="688" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="95" name="cartaz" type="cartaz" x="512" y="144">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="96" name="cartaz" type="cartaz" x="1168" y="32">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="97" name="cartaz" type="cartaz" x="288" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="98" name="cartaz" type="cartaz" x="656" y="320">
<properties>
<property name="face" value="N"/>
<property name="imagem" type="int" value="4"/>
</properties>
<point/>
</object>
<object id="99" name="cartaz" type="cartaz" x="320" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
<object id="100" name="cartaz" type="cartaz" x="1088" y="464">
<properties>
<property name="face" value="O"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="101" name="cartaz" type="cartaz" x="816" y="704">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="102" name="cartaz" type="cartaz" x="656" y="352">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="2"/>
</properties>
<point/>
</object>
<object id="103" name="cartaz" type="cartaz" x="416" y="592">
<properties>
<property name="face" value="L"/>
<property name="imagem" type="int" value="1"/>
</properties>
<point/>
</object>
<object id="104" name="cartaz" type="cartaz" x="368" y="800">
<properties>
<property name="face" value="S"/>
<property name="imagem" type="int" value="3"/>
</properties>
<point/>
</object>
</objectgroup>
</map>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 B

-341
View File
@@ -1,341 +0,0 @@
"""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()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

-83
View File
@@ -1,83 +0,0 @@
# DESIGN.md
Registro das decisões de desenho do evandro.dev.br. O código (`app/globals.css`, componentes) é a fonte dos valores; este arquivo diz o porquê.
## Conceito
```
Ideia: os blocos da marca são as peças com que se monta o site ou o sistema de cada cliente
Metáfora: kit de blocos de encaixe, como uma maquete
Assinatura: no hero, os blocos da marca se soltam e se remontam com a rolagem em tela, integrações e dados
Emoção: nos três primeiros segundos, "isto foi montado peça por peça"
Verbo: encaixar: chega rápido, assenta curto, sem quique
Quadro: a marca em porcelana com os quatro cubos verdes ao lado do título
```
Âncora: o símbolo é feito de blocos (`public/brand/mark-dark.svg`); o conceito não serve para outra marca.
## Paleta
| Papel | Token | Valor |
|---|---|---|
| Fundo escuro, tinta | `--ink` | `#0a0a0a` |
| Fundo claro | `--paper` | `#f4f2ed` |
| Fundo claro, segundo plano | `--paper-dark` | `#eceae4` |
| Acento | `--slash` | `#ccff00` |
| Texto secundário | `--meta` | `#4a4a4a` |
| Controles que abrem o WhatsApp | `--whatsapp` | `#25d366` |
O acento aparece em pouca área: um trecho do título, um botão, os cubos da marca. Verde sobre preto é a identidade da marca desde 2026-09, não uma escolha de tendência.
## Tipografia
- Display e corpo: Space Grotesk (`--font-display`).
- Rótulos, números e código: Geist Mono (`--font-mono`), 13 px, sem caixa alta, `letter-spacing` de 0,02em.
## Forma
Cantos retos por padrão (blocos, molduras, botões do hero). Raio aparece em poucos lugares: formulário de contato, botão do WhatsApp e indicadores circulares.
## Movimento
Só CSS e Three.js direto. Sem biblioteca de animação (GSAP, Motion e React Three Fiber saíram em 2026-10-14 por desempenho).
| Uso | Duração | Curva |
|---|---|---|
| Entrada de componente | 280 ms | `--ease-out` |
| Saída de componente | 180 ms | `--ease-in` |
| Entrada do título e do hero | 850 a 1100 ms | `--ease-out` |
| Troca de página | 600 ms; a capa compartilhada viaja em 500 ms | cortina em degraus de 8 colunas; `--ease-out` na capa |
| Detalhes de bloco (cursor, cortina) | conforme o caso | `steps()` |
Regras:
- Animam só `transform` (e `translate`, `scale`), `opacity`, `clip-path` e `mask`. Única exceção: a altura das perguntas frequentes ao abrir e fechar.
- O texto está no HTML desde o primeiro quadro; a animação nunca esconde conteúdo de quem não a recebe.
- Coisa grande se move devagar, detalhe se move rápido; escalonamento entre 20 e 80 ms.
- Com `prefers-reduced-motion: reduce` não há cena, rolagem suave nem animação; o conteúdo aparece inteiro.
## Resposta ao ponteiro e ao foco
Um tratamento por papel, sempre entrando pela esquerda em 280 ms com `--ease-out`; com movimento reduzido a troca é instantânea.
| Papel | Tratamento |
|---|---|
| Botão verde | um bloco cor de papel cobre o botão e o texto continua escuro; sobre fundo claro o bloco é escuro e o texto inverte |
| Botão de contorno | o mesmo bloco: translúcido no fundo escuro, escuro no fundo claro (o texto inverte) |
| Link de texto e item do menu | uma barra de 2 px cresce sob o texto |
| Item da vitrine | uma barra verde entra pela esquerda |
| Tocar vídeo | o círculo cresce e fica verde |
| Logo | os quatro quadrados verdes sobem em sequência e assentam |
"Falar sobre meu projeto" sempre leva ao formulário. Controle que abre o WhatsApp se chama "Chamar no WhatsApp".
## Cena 3D
Fonte em `3d/blocos.py` (Blender), saída em `public/3d/`. Um bloco chanfrado, repetido, com quatro estados: `marca`, `tela`, `integracoes`, `dados`.
| Nível (`sceneTier()` em `lib/device.ts`) | Quem recebe | O que recebe |
|---|---|---|
| `full` | computador com GPU, mais de 4 núcleos e mais de 4 GB | cena em tempo real guiada pela rolagem |
| `lite` | celular com 6 núcleos ou mais e 4 GB ou mais | cena em tempo real leve, que troca de estado sozinha |
| `static` | o resto, movimento reduzido, economia de dados, ou quem não segura 28 ms por quadro | pôster renderizado no Blender |
O pôster é o primeiro quadro de todos os níveis e o LCP continua sendo o título.
-53
View File
@@ -1,53 +0,0 @@
# 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.
+170 -124
View File
@@ -1,123 +1,188 @@
<div align="center">
<img src="assets/readme/hero.svg" width="100%" alt="evandro.dev.br: o portfólio de Evandro Fonseca Junior, com o índice dos nove cases publicados e o estado de cada um">
<img src="public/edev_logo.svg" width="110" alt="Logo do Evandro.dev.br">
![Next.js 16.3.8](https://img.shields.io/badge/Next.js-16.3.8-ccff00?style=flat-square&labelColor=0a0a0a)
![React 19.1.0](https://img.shields.io/badge/React-19.1.0-ccff00?style=flat-square&labelColor=0a0a0a)
![Node 22.x](https://img.shields.io/badge/Node-22.x-ccff00?style=flat-square&labelColor=0a0a0a)
![Status: em produção](https://img.shields.io/badge/status-em_produ%C3%A7%C3%A3o-ccff00?style=flat-square&labelColor=0a0a0a)
# Evandro.dev.br — portfólio
[Site](https://evandro.dev.br) · [Cases](https://evandro.dev.br/cases) · [Currículo](https://evandro.dev.br/curriculo) · [LinkedIn](https://www.linkedin.com/in/evandrodevbr/) · [Issues](https://git.evandro.dev.br/evandro/portifolio-evandrodevbr/issues)
**Portfólio profissional de Evandro Fonseca Junior: projetos, cases, serviços,
currículo e contato por WhatsApp — com hero e notebook 3D progressivos que caem
para HTML puro quando o dispositivo não comporta.**
![Next.js](https://img.shields.io/badge/Next.js-16.3.0-informational)
![React](https://img.shields.io/badge/React-19.1.0-informational)
![Node](https://img.shields.io/badge/Node-22.x-informational)
[Rotas](#o-que-o-site-entrega) · [Como funciona](#como-funciona) · [Execução local](#execu%C3%A7%C3%A3o-local) · [CI/CD](#cicd)
</div>
| SEV Moda Íntima | NutriScan |
|---|---|
| <img src="./public/assets/prints/print-sev.png" width="380" alt="Site da SEV Moda Íntima"> | <img src="./public/assets/prints/print-nutriscan.png" width="380" alt="NutriScan"> |
| **SC Plus Aquicultura** | **AS Serviços** |
| <img src="./public/assets/prints/print-scplus.png" width="380" alt="Site da SC Plus Aquicultura"> | <img src="./public/assets/prints/print-asservicos.png" width="380" alt="Site da AS Serviços"> |
> [!NOTE]
> **Estado do projeto:** em desenvolvimento ativo (último commit: "refine portfolio
> showcase, services and quality lab"); o site de produção é `https://evandro.dev.br`,
> que é o host declarado em `app/sitemap.ts` e `app/robots.ts`. As ações externas
> de SEO/GEO (Search Console, Bing, Cloudflare, CrUX) estão descritas como runbook e
> **não executadas** em [`docs/seo-geo-operations.md`](docs/seo-geo-operations.md).
Capturas feitas com Chrome headless a partir de deploys de produção do próprio autor. Origem e créditos (inclusive do ícone do WhatsApp) em [`CREDITS.md`](CREDITS.md).
> [!IMPORTANT]
> **Next.js `16.3.0` é intencional.** A `16.2.11` ainda mantinha três advisories
> transitivos de severidade alta; a `16.3.0` foi a menor versão estável da mesma
> major que deixou as auditorias atuais sem vulnerabilidades high. Não faça
> downgrade sem repetir `pnpm audit`.
## O que é
O código do site [evandro.dev.br](https://evandro.dev.br), o portfólio de Evandro Fonseca Junior, engenheiro da computação em Garuva-SC. Pequenos negócios costumam vender e atender por mensagem: o cliente pergunta o preço um a um, o estoque vive em planilha e qualquer mudança no site depende de um desenvolvedor. O site mostra projetos entregues, serviços, currículo e um botão de WhatsApp para começar a conversa.
- Apresenta três ofertas: criação de sites, sistemas web sob medida e revisão de apps feitos com IA (`/ia`).
- Publica nove cases com stack, entregas e decisões técnicas, cada um em `/cases/<id>`.
- Detalha seis serviços em `/servicos/*`, com escopo e etapas visíveis e a parte técnica em `<details>`.
- Serve o currículo em HTML e o PDF em `/Profile.pdf`.
- Gera `sitemap.xml`, `robots.txt`, manifest, imagens Open Graph e dados estruturados JSON-LD.
### Cases publicados
Fonte: `public/portfolio-data.json` e `lib/site-content.ts` (os mesmos nove ids).
| Case | O que é | Estado | Demo |
|---|---|---|---|
| [`julia-beauty`](https://evandro.dev.br/cases/julia-beauty) | Julia Beauty: site de estúdio de extensão de cílios em Camboriú, com simulador de efeito e SEO local | Em produção | [juliabeauty.com.br](https://juliabeauty.com.br) |
| [`sev-modaintima`](https://evandro.dev.br/cases/sev-modaintima) | SEV Moda Íntima: catálogo digital e loja virtual, pedido pelo WhatsApp e painel de estoque | Em produção | [sevmodaintima.com.br](https://sevmodaintima.com.br) |
| [`lavacao-047`](https://evandro.dev.br/cases/lavacao-047) | Lavação 047: site e painel para lavação automotiva em Garuva-SC, o dono edita preços, textos e fotos | Em produção | [landing-page-lavacao047.vercel.app](https://landing-page-lavacao047.vercel.app) |
| [`nutriscan`](https://evandro.dev.br/cases/nutriscan) | NutriScan: consulta de produtos por nome e código de barras | Em produção | [nutriscan.evandro.dev.br](https://nutriscan.evandro.dev.br) |
| [`twins-lab`](https://evandro.dev.br/cases/twins-lab) | Twins Lab: painel ao vivo de um reator simulado | Protótipo | sem demo |
| [`reddit-explorer`](https://evandro.dev.br/cases/reddit-explorer) | Reddit Posts Explorer: posts de um fórum de IA, filtrados por período | Concluído | sem demo |
| [`sc-plus`](https://evandro.dev.br/cases/sc-plus) | SC Plus Aquicultura: site institucional com áreas, projetos e contato | Concluído | [scplus.evandro.dev.br](https://scplus.evandro.dev.br) |
| [`gcc-installer`](https://evandro.dev.br/cases/gcc-installer) | MinGW GCC Installer: instalador do compilador GCC para Windows | Concluído | sem demo |
| [`as-servicos`](https://evandro.dev.br/cases/as-servicos) | AS Serviços: site institucional com cena 3D | Concluído | sem demo |
## Por que usar
O repositório é a referência de como o site funciona, não um template. As decisões que valem conferir:
- **Sem backend.** O formulário de contato abre o `wa.me` com o briefing já preenchido, e o rodapé mantém o contato direto por e-mail; não há API route ativa nem banco.
- **3D só onde o aparelho aguenta.** O hero e o notebook de projetos em Three.js aparecem só onde o dispositivo comporta; em telas pequenas, com ponteiro `coarse` ou movimento reduzido, o site entrega a versão em HTML estático.
- **Analytics com consentimento.** O Google Analytics só carrega depois de aceite explícito, e o consentimento pode ser revogado depois (detalhes em `/privacidade`).
- **Demos externas não derrubam o render.** O estado de cada demo vem de um snapshot versionado; em produção, o render não consulta as demos.
Quando não usar: se você procura um template de portfólio. O projeto é pessoal (`"private": true` no `package.json`) e não tem licença publicada.
## Como funciona
1. O conteúdo vem de `public/portfolio-data.json` (lido por `lib/portfolio-data.server.ts`) e de `lib/site-content.ts`; a homepage escolhe os projetos pelo campo `featured`.
2. `data/project-health.json` é um snapshot versionado das demos. Ele só controla o CTA de cada card (disponível, indisponível, desconhecido ou sem demo) e nunca remove um card.
3. Em produção, o render só lê o snapshot. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é atualizado em memória. `pnpm check:projects` atualiza o arquivo por rename atômico. Contrato completo em [`docs/project-health.md`](docs/project-health.md).
4. Um único workflow, [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml), roda em `push`, `pull_request` e `workflow_dispatch`, com permissão `contents: read`:
| Job | O que roda | Bloqueia o deploy |
|---|---|---|
| `verify` | Node 22, `pnpm install --frozen-lockfile`, `lint`, `typecheck`, `test -- --runInBand`, `build` | Sim |
| `audit` | `pnpm audit --prod` e `pnpm audit --dev`, ambos com `--audit-level=high` | Não |
| `e2e` | Playwright (Chromium) contra o build de produção na porta 3001; publica `test-results/` | Não |
| `deploy` | Vercel CLI `61.1.0`, só após `verify`: branch padrão vai para produção, as demais para preview; pull request não publica | n/a |
O deploy usa o secret `VERCEL_TOKEN`; o projeto na Vercel é o nome do repositório, ou a variável `VERCEL_PROJECT`. [`nixpacks.toml`](nixpacks.toml) (`nodejs_22`, `pnpm-9_x`) e [`vercel.json`](vercel.json) (`framework: nextjs`) descrevem o build.
<details>
<summary>Rotas e estrutura</summary>
## O que o site entrega
| Rota | Conteúdo |
|---|---|
| `/` | Hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato |
| `/cases`, `/cases/<id>` | Índice e página de cada um dos nove cases |
| `/servicos` | Índice dos seis serviços |
| --- | --- |
| `/` | Homepage com hero, notebook de projetos, perfil, competências, experiência, projetos, modalidades de trabalho e contato |
| `/cases` | Índice dos seis cases |
| `/cases/nutriscan` | Case NutriScan |
| `/cases/twins-lab` | Case Twins Lab |
| `/cases/reddit-explorer` | Case Reddit Posts Explorer |
| `/cases/sc-plus` | Case SC Plus Aquicultura |
| `/cases/gcc-installer` | Case MinGW GCC Installer |
| `/cases/as-servicos` | Case AS Serviços |
| `/servicos` | Índice dos seis serviços, com explicações de uso e acesso ao detalhamento técnico |
| `/servicos/criacao-de-sites` | Criação de sites |
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida |
| `/servicos/consultoria-nextjs` | Consultoria Next.js |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux |
| `/servicos/tauri-rust` | Aplicativos desktop para Windows e Linux, com compatibilidade definida por projeto |
| `/servicos/integracao-hardware-software-iot` | Integração de hardware, software e IoT |
| `/servicos/arquitetura-de-software` | Diagnóstico de arquitetura |
| `/ia` | Revisão de sistema e de app feito com IA |
| `/servicos/arquitetura-de-software` | Arquitetura de software |
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf` |
| `/privacidade` | Privacidade e consentimento de analytics |
| `/privacidade` | Página de privacidade e consentimento de analytics |
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`,
`/opengraph-image`, `/icon.svg` e `/favicon.ico`. Caminhos desconhecidos usam a
página 404 do projeto. Não há API route ativa.
As páginas de serviços mantêm escopo, exemplos ilustrativos e etapas visíveis;
tecnologias e decisões de implementação ficam em um `<details>` nativo. Sobre e
Contato apontam para `/#about` e `/#contact`, com ajuste de chegada após a
definição do layout do notebook.
## Como funciona
```mermaid
flowchart LR
A["public/portfolio-data.json<br/>+ lib/site-content.ts"] --> B["lib/portfolio-data.ts<br/>loadFeaturedProjects()"]
C["data/project-health.json<br/>snapshot versionado"] --> B
D["scripts/check-projects.mjs<br/>pnpm check:projects"] -->|rename atômico| C
B --> E["app/ — App Router<br/>homepage, cases, serviços"]
E --> F["Next.js 16.3.0<br/>build e start (Turbopack)"]
```
<details>
<summary>Conteúdo e project health</summary>
O conteúdo principal vem de `public/portfolio-data.json` e `lib/site-content.ts`.
A seleção da homepage usa o campo `featured`; o snapshot de disponibilidade não
remove cards. Ele apenas controla o estado do CTA de cada demo: disponível,
indisponível, desconhecido ou sem demo.
Em produção, a renderização só lê o snapshot versionado e não consulta demos
externas. Em desenvolvimento, um snapshot ausente ou com mais de 15 minutos é
atualizado em memória. A atualização manual opcional é `pnpm check:projects` e
grava o arquivo por rename atômico. O contrato completo está em
[`docs/project-health.md`](docs/project-health.md).
</details>
<details>
<summary>3D progressivo e movimento reduzido</summary>
O fundo 3D do hero (Three.js, sem React Three Fiber) só é montado a partir de
`1024px`, com ponteiro fino, sem `prefers-reduced-motion: reduce`, sem economia
de dados e com placa de vídeo real (WebGL por software mostra o hero estático).
A cena monta depois da primeira interação ou com o navegador ocioso, desenha no
máximo ~30 quadros por segundo e para quando nada se move. No celular e nos
demais casos, o hero é estático e ocupa uma tela.
As passagens entre seções e a barra de progresso são CSS (`animation-timeline`);
a rolagem suave (Lenis) só existe com ponteiro fino.
</details>
<details>
<summary>Contato e consentimento de analytics</summary>
O formulário da homepage valida os campos nativos de nome, e-mail, tipo e
contexto. Ao enviar, ele abre `wa.me` em uma nova aba com o briefing
preenchido; não existe backend de formulário. O rodapé mantém contato direto por
e-mail.
Google Analytics só é carregado depois de aceite explícito. A decisão fica em
`evandro.analytics-consent.v1`; recusar não monta o loader. As preferências
permanecem acessíveis e a revogação envia consentimento negado, ativa a flag de
desabilitação, remove o loader e expira apenas cookies `_ga`/`_ga_*`
acessíveis.
</details>
<details>
<summary>Stack e estrutura</summary>
| Área | Pacotes usados |
| --- | --- |
| Aplicação | Next.js 16.3.8, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2 |
| Estilos | Tailwind CSS 4 |
| Movimento | CSS (`animation-timeline`) e Lenis |
| 3D | Three.js |
| Interface | Lucide React |
| Qualidade | Biome, Jest, Testing Library (JSDOM), Playwright e axe-core |
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o
lockfile é instalado sem atualização implícita.
```text
app/ rotas (App Router) e componentes
lib/ conteúdo (site-content.ts), SEO, JSON-LD, WhatsApp, project health
public/ portfolio-data.json, Profile.pdf, logos, capturas e mídias dos cases
data/ project-health.json, snapshot versionado das demos
scripts/ check-projects.mjs e utilitários de SEO e vídeo
e2e/ testes Playwright (9 specs)
docs/ SEO/GEO, project health e planos
.gitea/workflows/ ci.yml (verify, audit, e2e, deploy)
app/ App Router, rotas e componentes da interface
lib/ Dados derivados, navegação, JSON-LD e project health
public/ Conteúdo JSON, PDF, fontes, imagens e capturas
scripts/check-projects.mjs
Atualização manual do snapshot de demos
data/project-health.json
Última observação versionada das demos
docs/project-health.md Contrato operacional do snapshot
.github/workflows/ci.yml
Gate único de integração contínua
nixpacks.toml Build e start para Nixpacks
```
</details>
## Início rápido
## Limitações conhecidas
Requisitos: Node.js `22.x` (`engines`), pnpm `9.15.9` (`packageManager`) e, só para `pnpm test:e2e`, o Chromium do Playwright.
- **Sem backend.** O formulário de contato não grava nada: abre `wa.me` com o
briefing preenchido.
- **Demos externas podem aparecer como indisponíveis.** O CTA de cada case reflete
o último snapshot versionado em `data/project-health.json`, não uma consulta em
tempo real (em produção o render não faz fetch externo).
- **Sem WebGL em telas pequenas, com ponteiro coarse ou movimento reduzido.** Nesses
casos o hero e o notebook entregam a versão HTML.
## Execução local
> [!WARNING]
> Use **Node.js `22.x`** e **pnpm `9.15.9`** (fixado em `package.json#packageManager`).
> A instalação é sempre com `--frozen-lockfile`; o CI e o Nixpacks fazem o mesmo.
```bash
corepack [email protected] install --frozen-lockfile
corepack [email protected] dev # Next.js com Turbopack
corepack [email protected] build # build de produção
corepack [email protected] start # serve um build já gerado
corepack [email protected] dev # Next.js com Turbopack
corepack [email protected] build # build de produção com Turbopack
corepack [email protected] start # serve um build já gerado
```
A instalação é sempre com `--frozen-lockfile`, igual ao CI e ao Nixpacks.
> [!IMPORTANT]
> O Next.js (`16.3.8`) é fixado de propósito: a `16.2.11` mantinha três advisories transitivos de severidade alta, e a `16.3.0` foi a menor versão estável da mesma major que deixou as auditorias sem vulnerabilidades high. Não faça downgrade sem repetir `pnpm audit`.
| Script | Comportamento |
| --- | --- |
| `pnpm dev` | Inicia o Next.js em desenvolvimento com Turbopack |
| `pnpm build` | Gera o build de produção com Turbopack |
| `pnpm start` | Serve um build já gerado |
| `pnpm lint` | Executa `biome check` |
| `pnpm typecheck` | Executa TypeScript sem emitir arquivos |
| `pnpm format` | Formata o repositório com Biome |
| `pnpm test` | Executa a suíte Jest |
| `pnpm test:watch` | Executa Jest em modo watch |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json` |
Validação completa, na mesma ordem do CI:
@@ -131,39 +196,20 @@ corepack [email protected] audit --prod --audit-level=high
corepack [email protected] audit --dev --audit-level=high
```
Testes de ponta a ponta: `pnpm build && pnpm start -p 3001` e, em outro terminal, `BASE_URL=http://localhost:3001 pnpm test:e2e`.
## CI/CD
Outros scripts: `pnpm format` (Biome), `pnpm test:watch` (Jest em modo watch) e `pnpm check:projects` (atualiza `data/project-health.json`).
O workflow único [`.github/workflows/ci.yml`](.github/workflows/ci.yml) roda em
cada `push` e `pull_request`, com permissão `contents: read`. No job `verify`
(`ubuntu-latest`) ele instala Node 22 e pnpm conforme `packageManager`, restaura o
cache do pnpm e executa, nesta ordem: frozen install, lint, typecheck, Jest
serial, build e auditorias de produção e desenvolvimento com limite high.
## Configuração
O Nixpacks ([`nixpacks.toml`](nixpacks.toml)) usa `nodejs_22`, `pnpm-9_x`,
`pnpm install --frozen-lockfile`, `pnpm run build` e `pnpm run start`. Não há
etapa de deploy nem secret de CI neste repositório.
Não há `.env.example`, e nenhuma variável é necessária para rodar localmente. O código lê:
## Avisos
| Variável | Descrição |
|---|---|
| `NEXT_PUBLIC_GOOGLE_ADS_CONVERSION_LABEL` | Rótulo de conversão do Google Ads; sem ele, nenhum evento de conversão é enviado |
| `BASE_URL` | Endereço do site testado por `pnpm test:e2e` (padrão `http://localhost:3001`) |
| `PW_CHROMIUM` | Caminho de um Chromium local para o Playwright, para não baixar o navegador |
## Estado do projeto
Em produção em [evandro.dev.br](https://evandro.dev.br). Stack: Next.js 16.3.8, React 19.1.0, TypeScript 5.9.2, Tailwind CSS 4, Three.js, Lenis e Lucide React. Qualidade: Biome, Jest 30 com Testing Library (150 arquivos de teste), Playwright e axe-core. Todas as dependências diretas estão fixadas em versões exatas, exceto `lenis` (`^1.3.26`).
Limites e próximos passos:
- **Demos externas podem aparecer como indisponíveis.** O CTA reflete o último snapshot versionado, não uma consulta em tempo real. No snapshot de 2026-07-11, só a SEV respondeu 200; `nutriscan` falhou na consulta, `twins-lab` respondeu 404 e `sc-plus`, 502. Rode `pnpm check:projects` para renová-lo. Em 2026-10-24, `nutriscan.evandro.dev.br` continuava sem responder com o app.
- **Ações externas de SEO/GEO pendentes.** Search Console, Bing, Cloudflare e CrUX estão só no runbook.
Documentação: [`docs/project-health.md`](docs/project-health.md) (estados do CTA das demos e atualização do snapshot), [`docs/seo-geo-operations.md`](docs/seo-geo-operations.md) (runbook de SEO e GEO, descrito e **não executado**), [`docs/seo-geo-validation.md`](docs/seo-geo-validation.md) (validação de SEO e GEO) e [`CREDITS.md`](CREDITS.md).
O CI roda em `push` e `pull_request`; antes de abrir um PR, rode `pnpm lint && pnpm typecheck && pnpm test -- --runInBand && pnpm build`.
## Licença
Sem licença publicada: todos os direitos reservados.
<div align="center">
<sub>Feito por <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
- As capturas em `public/assets/prints/*.png` foram feitas com Chrome headless a
partir de deploys de produção do próprio autor (evandro.dev.br) e aparecem como
conteúdo da tela do notebook. Detalhes em [`CREDITS.md`](CREDITS.md).
-32
View File
@@ -1,32 +0,0 @@
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 };
}
@@ -132,7 +132,7 @@ describe("opengraph-image Image", () => {
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
expect(
all.filter(({ contentType }) => contentType === "image/jpeg"),
).toHaveLength(6);
).toHaveLength(5);
});
it("path: unknown slug -> notFound() and no image", async () => {
+1 -1
View File
@@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
const contacts = screen.getAllByRole("link", {
name: "Chamar no WhatsApp",
name: "Falar sobre meu projeto",
});
expect(contacts).toHaveLength(1);
expect(contacts[0]).toBeVisible();
+14 -22
View File
@@ -20,7 +20,6 @@ const clientCaseIds = [
"lavacao-047",
"sc-plus",
"as-servicos",
"julia-beauty",
];
const labCaseIds = [
"nutriscan",
@@ -62,8 +61,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
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos.
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
@@ -72,7 +71,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
expect(ctas).toHaveLength(1);
for (const cta of ctas) {
const destination = new URL(cta.getAttribute("href") ?? "");
@@ -87,21 +86,15 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
}
}
it("exposes the nine public cases, the new client cases among them", async () => {
it("exposes the eight public cases, the two new client cases among them", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(9);
expect(new Set(staticSlugs).size).toBe(9);
expect(staticSlugs).toHaveLength(8);
expect(new Set(staticSlugs).size).toBe(8);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
expect(staticSlugs).toContain("sev-modaintima");
expect(staticSlugs).toContain("lavacao-047");
expect(staticSlugs).toContain("julia-beauty");
for (const id of [
"sev-modaintima",
"lavacao-047",
"as-servicos",
"julia-beauty",
]) {
for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) {
const caseStudy = getCase(id);
const url = `${siteUrl}/cases/${caseStudy.id}`;
const { metaTitle: title, metaDescription: description } = caseStudy;
@@ -179,13 +172,13 @@ it("renders a client case in the sales order, with media, contact and collapsed
const capabilitiesSection = sectionHeadings[4].closest("section");
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.whatsapp,
name: buttonLabels.contact,
}),
).toBeNull();
const closingSection = sectionHeadings[5].closest("section");
expect(closingSection).toContainElement(
within(closingSection as HTMLElement).getByRole("link", {
name: buttonLabels.whatsapp,
name: buttonLabels.contact,
}),
);
for (const capability of caseStudy.capabilities ?? []) {
@@ -371,10 +364,10 @@ it("writes what the client can do now right after 'Como ficou', with no contact
expect(items.length).toBeLessThanOrEqual(3);
// sem número inventado
expect(items.join(" ")).not.toMatch(/\d/);
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
// o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos
expect(
within(capabilitiesSection as HTMLElement).queryByRole("link", {
name: buttonLabels.whatsapp,
name: buttonLabels.contact,
}),
).toBeNull();
const closingSection = screen
@@ -386,7 +379,7 @@ it("writes what the client can do now right after 'Como ficou', with no contact
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
expect(
within(closingSection as HTMLElement).getAllByRole("link", {
name: buttonLabels.whatsapp,
name: buttonLabels.contact,
}),
).toHaveLength(1);
unmount();
@@ -477,7 +470,7 @@ it("shows at most two related services, as cards under the contact button", asyn
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
// o botão do fecho (o único) vem antes dos cartões
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
const contact = screen.getByRole("link", { name: buttonLabels.contact });
expect(isBefore(contact, cards[0])).toBe(true);
unmount();
@@ -687,7 +680,6 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
"lavacao-047",
"sc-plus",
"as-servicos",
"julia-beauty",
]);
const clientLinks = listedIds.map((id) => {
const card = container
@@ -748,7 +740,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
expect(hrefs).toContain("/servicos");
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
const cta = screen.getByRole("link", { name: buttonLabels.contact });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
@@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
/**
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
*/
@@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => {
for (const item of capabilities) {
expect(screen.getByText(item)).toBeVisible();
}
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelectorAll("a")).toHaveLength(0);
});
@@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content";
/**
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
*/
export function CaseCapabilitiesSection({
caseStudy,
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
name: "Quer um projeto parecido com este?",
}),
).toBeVisible();
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
expect(cta).toHaveAttribute("target", "_blank");
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
expect(messageOf(cta)).toContain(
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
// o rótulo do botão é o mesmo do case de cliente: uma lista só
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
+3 -3
View File
@@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices";
/**
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
* `similarProjectSubject`.
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
* (e no botão do cabeçalho): `similarProjectSubject`.
*/
const contactCopy = {
client: { title: "Quer um projeto parecido com este?" },
@@ -47,7 +47,7 @@ export function CaseContactSection({
placement="case_detail"
className={primaryButton}
>
{buttonLabels.whatsapp}
{buttonLabels.contact}
</WhatsAppLink>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
+2 -27
View File
@@ -1,23 +1,15 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
* 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.
* 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"`).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
jest.mock("react", () =>
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
);
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
@@ -30,23 +22,6 @@ afterEach(() => {
});
describe("CaseCover", () => {
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
const transition = image.parentElement;
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
expect(transition).toHaveAttribute("data-vt-default", "none");
});
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
expect(new Set(names).size).toBe(names.length);
});
it("path: no desktop capture -> renders nothing", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCover {...props} />);
+8 -12
View File
@@ -1,11 +1,9 @@
import Image from "next/image";
import { ViewTransition } from "react";
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import { coverTransitionProps } from "./cover-transition";
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
@@ -56,16 +54,14 @@ export function CaseCover({
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<ViewTransition {...coverTransitionProps(id)}>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</ViewTransition>
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
{...priorityProps}
className="object-cover object-top"
/>
</div>
</BrowserFrame>
);
+2 -2
View File
@@ -70,9 +70,9 @@ export function CaseHero({
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"}
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
{caseStudy.title}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
+1 -1
View File
@@ -43,7 +43,7 @@ export function CaseMediaSection({
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noopener noreferrer"
rel="noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
+3 -12
View File
@@ -27,18 +27,9 @@ describe("CasePlayer", () => {
name: `Reproduzir o vídeo do site ${title} no computador`,
});
expect(button).toHaveAttribute("type", "button");
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
const circle = button.querySelector("[aria-hidden='true']");
const restClasses = (circle?.className ?? "")
.split(" ")
.filter((name) => !name.startsWith("group-"));
expect(restClasses.join(" ")).not.toContain("--slash");
expect(circle).toHaveClass(
"group-hover:scale-108",
"group-hover:bg-[var(--slash)]",
"group-focus-visible:scale-108",
"group-focus-visible:bg-[var(--slash)]",
);
// só botão de contato é limão: o de play é escuro, com contorno branco
expect(button.innerHTML).not.toContain("--slash");
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
});
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
+1 -2
View File
@@ -78,10 +78,9 @@ export function CasePlayer({
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[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"
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"
>
<Play className="ml-1 size-6" fill="currentColor" />
</span>
-10
View File
@@ -1,10 +0,0 @@
/**
* 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;
}
+2 -5
View File
@@ -43,14 +43,11 @@ describe("CasesPage: carregamento das capas", () => {
it("loads the first card cover at once with high priority, and every other image lazily", () => {
const { container } = render(<CasesPage />);
// sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
const decorative = Array.from(
container.querySelectorAll<HTMLImageElement>("img"),
).filter((image) => image.getAttribute("alt") === "");
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");
expect(decorative).toHaveLength(0);
const [first, ...others] = coverImages(container);
expect(first).toHaveAttribute("loading", "eager");
+7 -30
View File
@@ -77,27 +77,11 @@ describe("CasesPage: cases de cliente", () => {
).toBeNull();
});
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", () => {
it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus and AS Serviços", () => {
const { container } = render(<CasesPage />);
expect(
screen.getByRole("link", { name: buttonLabels.whatsapp }),
screen.getByRole("link", { name: buttonLabels.contact }),
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
const clientTitles = Array.from(
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
@@ -110,28 +94,21 @@ describe("CasesPage: cases de cliente", () => {
"Lavação 047",
"SC Plus Aquicultura",
"AS Serviços",
"Julia Beauty",
]);
// um botão de contato só na página: o do fecho
expect(
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
screen.getAllByRole("link", { name: buttonLabels.contact }),
).toHaveLength(1);
});
});
describe("CasesPage: topo sem mosaico", () => {
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", () => {
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", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
const images = hero?.querySelectorAll("img") ?? [];
expect(images).toHaveLength(1);
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
// decorativa e só de 1024 px em diante
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
"hidden",
"lg:block",
);
expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.className).toContain("md:!pb-sp-12");
// o título e o subtítulo não repetem a mesma frase
const heading = within(hero as HTMLElement).getByRole("heading", {
@@ -152,7 +129,7 @@ describe("CasesPage: topo sem mosaico", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelectorAll("img")).toHaveLength(1);
expect(hero?.querySelector("img")).toBeNull();
const firstCover = clientCard(
container,
clientCasesForListing[0].id,
+28 -45
View File
@@ -8,7 +8,6 @@ import {
textLink,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { PagePiece } from "@/app/components/PagePiece";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -21,7 +20,7 @@ import {
} from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
import { compactHero } from "./_components/hero-styles";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
@@ -43,60 +42,45 @@ 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.
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
*/
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
function ClientCaseCard({
caseStudy,
priority = false,
wide = false,
}: {
caseStudy: CaseStudyContent;
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
priority?: boolean;
wide?: boolean;
}) {
return (
<article
className={`ds-card !h-auto ${
// 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"
: ""
}`}
>
<article className="ds-card !h-auto">
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<div>
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
{buttonLabels.case}
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noopener noreferrer"
>
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
</a>
) : null}
</div>
</article>
);
@@ -160,14 +144,15 @@ export default function CasesPage() {
<section
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
>
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
<div className="min-w-0 max-w-[860px]">
<div className="ds-section-shell">
<div className="max-w-[860px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<p className="ds-eyebrow">Trabalhos entregues</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Veja o que já fiz para empresas.
</h1>
@@ -175,7 +160,6 @@ export default function CasesPage() {
{pageLead}
</p>
</div>
<PagePiece image="/3d/estado-tela.webp" />
</div>
</section>
@@ -185,12 +169,11 @@ export default function CasesPage() {
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCasesForListing.map((caseStudy, index, all) => (
{clientCasesForListing.map((caseStudy, index) => (
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
priority={index === 0}
wide={index === all.length - 1 && all.length % 2 === 1}
/>
))}
</div>
@@ -228,7 +211,7 @@ export default function CasesPage() {
placement="cases_hub_footer"
className={primaryButton}
>
{buttonLabels.whatsapp}
{buttonLabels.contact}
</WhatsAppLink>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
{buttonLabels.allServices}
+2 -29
View File
@@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit
expect(none.container).toBeEmptyDOMElement();
});
it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
render(<ClientStrip clients={clients} />);
for (const link of screen.getAllByRole("link")) {
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
expect(link).toHaveClass("min-h-11");
}
});
@@ -107,30 +107,3 @@ it("keeps the years number in the display font (the mono font is not downloaded
expect(screen.getByText("5").className).not.toContain("font-mono");
});
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
render(
<ClientStrip
clients={[
{
id: "sev-modaintima",
name: "SEV Moda Íntima",
sector: "loja virtual",
},
{ id: "lavacao-047", name: "Lavação 047" },
]}
/>,
);
const sector = screen.getByText("loja virtual");
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
expect(link).not.toContainElement(sector);
expect(link.parentElement).toContainElement(sector);
expect(link.parentElement).toHaveClass("flex-col");
// o nome continua levando ao case
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
const plain = screen.getByRole("link", { name: "Lavação 047" });
expect(plain.parentElement?.childElementCount).toBe(1);
});
+11 -28
View File
@@ -1,19 +1,11 @@
import HoverPrefetchLink from "./HoverPrefetchLink";
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;
};
export type ClientLink = { id: string; name: string };
/**
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* 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`).
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
*
* 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
@@ -38,19 +30,12 @@ export default function ClientStrip({
...clients.map((client) => ({
key: client.id,
node: (
<span className="flex flex-col">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center self-start font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] sm:min-h-6"
>
{client.name}
</HoverPrefetchLink>
{client.sector && (
<span className="text-[13px] leading-[18px] max-sm:hidden">
{client.sector}
</span>
)}
</span>
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</HoverPrefetchLink>
),
})),
...(years
@@ -72,16 +57,14 @@ export default function ClientStrip({
return (
<div
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] sm:items-start ${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)] ${className}`.trim()}
>
{clients.length > 0 && <p>Já entregue para:</p>}
{/* 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">
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4 sm:items-start${index > 0 ? " -ml-4" : ""}`}
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
>
{index > 0 && (
<span
+1 -15
View File
@@ -24,7 +24,7 @@ afterEach(() => {
});
describe("Contact: opções do tipo de projeto", () => {
it("lists the five offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option");
@@ -32,12 +32,10 @@ describe("Contact: opções do tipo de projeto", () => {
"Ainda preciso definir",
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
@@ -63,9 +61,7 @@ describe("Contact: tipo escolhido pela oferta", () => {
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
@@ -160,9 +156,7 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
it.each([
["site", "Site que traz pedidos pelo WhatsApp"],
["sistema", "Sistema para tirar a operação da planilha"],
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
["revisao", "Revisão de sistema que já existe"],
["fiscal", "Nota fiscal pronta para a reforma tributária"],
])(
"after the offer %s the form says 'Você escolheu: <name>'",
(kind, name) => {
@@ -207,14 +201,6 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
expect(directMessage()).toContain(
"Quero conversar sobre a revisão de um sistema que já existe.",
);
choose("whatsapp");
expect(directMessage()).toContain(
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
);
choose("fiscal");
expect(directMessage()).toContain(
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
);
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
await user.selectOptions(
subjectSelect(),
+7 -7
View File
@@ -265,11 +265,11 @@ export default function Contact() {
service={directSubject}
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-[15px] text-white/65">
@@ -277,7 +277,7 @@ export default function Contact() {
</span>
</span>
<ArrowUpRight
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
aria-hidden="true"
/>
</WhatsAppLink>
@@ -310,7 +310,7 @@ export default function Contact() {
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
>
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
<WhatsAppMark className="h-7 w-7" />
</span>
<div>
@@ -412,7 +412,7 @@ export default function Contact() {
</Field>
<button
type="submit"
className="cv-btn 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"
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"
>
<WhatsAppMark className="h-6 w-6 shrink-0" />
Continuar no WhatsApp
@@ -444,9 +444,9 @@ export default function Contact() {
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
>
Chamar no WhatsApp
Conversar no WhatsApp
</a>
<a
href={`mailto:${CONTACT_EMAIL}`}
+1 -1
View File
@@ -42,7 +42,7 @@ export function Faq({
{answered.map((item) => (
<details
key={item.question}
className="ds-faq-item group border-t border-[var(--border)] last:border-b"
className="group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
-41
View File
@@ -1,41 +0,0 @@
"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>
);
}
+5 -10
View File
@@ -49,7 +49,7 @@ class FakeObserver {
const hero = () => document.getElementById("hero-contact-cta") as Element;
const contact = () => document.getElementById("contact") as Element;
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => {
resetNavigation();
@@ -221,7 +221,7 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
});
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
it("path: the bar marks <html> while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
const user = userEvent.setup();
localStorage.clear();
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
@@ -237,16 +237,12 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
);
const refuse = await screen.findByRole("button", { name: "Recusar" });
// 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
// o botão flutuante usa a variável publicada pela barra para subir
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("72px");
expect(document.documentElement).toHaveAttribute(
"data-cookie-bar",
"open",
);
expect(floatingLink()?.parentElement).toHaveClass(
"[[data-cookie-bar=open]_&]:hidden",
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
await user.click(refuse);
@@ -254,7 +250,6 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
expect(
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
).toBe("");
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
expect(floatingLink()).toBeInTheDocument();
} finally {
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
@@ -35,7 +35,7 @@ class FakeObserver {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
const messageOf = (link: HTMLElement | null) =>
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
+16 -5
View File
@@ -52,7 +52,7 @@ function setVisibility(hero: boolean, contact: boolean) {
}
const floatingLink = () =>
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
beforeEach(() => {
global.IntersectionObserver =
@@ -112,14 +112,25 @@ it("shows right away on a page that has neither the hero button nor the contact
expect(floatingLink()).toBeInTheDocument();
});
it("hides by CSS while the cookie bar is open (the data-cookie-bar mark on <html>), instead of sitting on top of it", () => {
it("lifts itself by the height of the open cookie bar", () => {
render(<Page />);
setVisibility(false, false);
expect(floatingLink()?.parentElement?.className).toContain(
"var(--cookie-bar-height,0px)",
);
});
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
render(<Page />);
setVisibility(false, false);
const className = floatingLink()?.parentElement?.className ?? "";
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
// não sobe mais pela altura da barra: some enquanto ela está aberta
expect(className).not.toContain("--cookie-bar-height");
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
expect(className).toContain("transition-transform");
expect(className).toContain("motion-reduce:transition-none");
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
expect(className).not.toContain("transition-[bottom]");
expect(className).toContain(
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
);
+7 -7
View File
@@ -6,16 +6,16 @@ import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
/** Link "Chamar no WhatsApp" do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
export const HERO_CTA_ID = "hero-contact-cta";
const CONTACT_SECTION_ID = "contact";
/**
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
*/
export default function FloatingWhatsApp({
contexts,
@@ -61,15 +61,15 @@ export default function FloatingWhatsApp({
return (
<aside
aria-label="Contato rápido"
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"
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"
>
<WhatsAppLink
placement="floating"
service={subject}
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"
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"
>
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Chamar no WhatsApp</span>
<span className="sr-only">Conversar no WhatsApp</span>
</WhatsAppLink>
</aside>
);
+8 -10
View File
@@ -39,22 +39,20 @@ describe("Footer contact and navigation", () => {
expect(emailLink).not.toHaveAttribute("target", "_blank");
});
it("offers WhatsApp in the contact column, in a new tab, named like every control that opens it", () => {
it("offers WhatsApp in the contact column, in a new tab", () => {
render(<Footer />);
const contact = screen.getByRole("region", { name: "Contato" });
const whatsApp = within(contact).getByRole("link", {
name: "Chamar no WhatsApp",
});
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
});
it("lists the eight services from the services hub as same-tab links", () => {
it("lists the six services from the services hub as same-tab links", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
expect(serviceHubLinks).toHaveLength(8);
expect(serviceHubLinks).toHaveLength(6);
const services = screen.getByRole("navigation", { name: "Serviços" });
const links = within(services).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(
@@ -66,7 +64,7 @@ describe("Footer contact and navigation", () => {
for (const link of links) expect(link).not.toHaveAttribute("target");
});
it("links Cases, the /ia page by its menu label, Privacidade and a discreet Currículo, with no PDF download", () => {
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
render(<Footer />);
const more = screen.getByRole("navigation", { name: "Mais" });
@@ -76,7 +74,7 @@ describe("Footer contact and navigation", () => {
);
expect(
within(more).getByRole("link", {
name: "Revisão de app com IA",
name: "Revisão de sistema que já existe",
}),
).toHaveAttribute("href", "/ia");
expect(
@@ -147,11 +145,11 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
it("names /ia like the menu and the page, and no old 'app feito com IA' name", () => {
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
render(<Footer />);
expect(
screen.getByRole("link", { name: "Revisão de app com IA" }),
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
).toHaveAttribute("href", "/ia");
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
});
+4 -4
View File
@@ -5,9 +5,9 @@ import {
trackEmailClick,
trackSocialClick,
} from "@/lib/analytics";
import { offerLabels } from "@/lib/offer-labels";
import {
CONTACT_EMAIL,
IA_PAGE_LABEL,
INSTAGRAM_URL,
LINKEDIN_URL,
type ServiceLink,
@@ -18,7 +18,7 @@ import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
const headingClassName =
"font-mono text-[13px] font-medium tracking-[0.02em] text-[var(--meta)]";
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
const linkClassName =
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
@@ -48,7 +48,7 @@ export default function Footer({
placement="footer"
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
>
Chamar no WhatsApp
WhatsApp
</WhatsAppLink>
</li>
<li>
@@ -118,7 +118,7 @@ export default function Footer({
</li>
<li>
<HoverPrefetchLink href="/ia" className={linkClassName}>
{IA_PAGE_LABEL}
{offerLabels.revisao.name}
</HoverPrefetchLink>
</li>
<li>
+1 -1
View File
@@ -60,7 +60,7 @@ describe("GoogleAnalytics consent", () => {
).toBeVisible();
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
/Métricas de navegação do Google, só se você permitir\./,
/Usamos métricas de navegação do Google só se você permitir\./,
);
expect(
screen.queryByTestId("google-analytics-script"),
+6 -8
View File
@@ -231,18 +231,16 @@ function GoogleAnalyticsConsent() {
onKeyDown={(event) => {
if (event.key === "Escape" && preferencesOpen) closePreferences();
}}
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"
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"
>
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
>
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 */}
Usamos métricas de navegação do Google só se você permitir.{" "}
<a
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"
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
href="/privacidade"
>
Política de privacidade
@@ -259,14 +257,14 @@ function GoogleAnalyticsConsent() {
<button
type="button"
ref={acceptRef}
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
onClick={acceptAnalytics}
>
Aceitar
</button>
<button
type="button"
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
onClick={denyAnalytics}
>
Recusar
+8 -20
View File
@@ -69,13 +69,7 @@ describe("Header", () => {
within(navigation)
.getAllByRole("link")
.map((link) => link.textContent),
).toEqual([
"Cases",
"Serviços",
"Como trabalho",
"Sobre",
"Revisão de app com IA",
]);
).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
// "Projetos" saiu: duplicava "Cases"
expect(
within(navigation).queryByRole("link", { name: "Projetos" }),
@@ -185,15 +179,11 @@ describe("Header", () => {
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
const links = within(mobileNavigation).getAllByRole("link");
for (const link of links.slice(0, 5)) {
// 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");
expect(link).toHaveClass("text-[22px]", "min-h-14");
}
const contact = links[5];
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
expect(contact.parentElement).toHaveClass("mt-auto");
// o botão verde ganha o bloco cor de papel, não só uma troca de cor
expect(contact).toHaveClass("cv-fill");
expect(contact.className).not.toMatch(/hover:bg-/);
});
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
@@ -255,13 +245,9 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Chamar no WhatsApp",
name: "Falar sobre meu projeto",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
// "Falar sobre meu projeto" é só do formulário: o botão que abre o WhatsApp tem outro nome
expect(
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(sentence);
},
@@ -272,7 +258,7 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Chamar no WhatsApp",
name: "Falar sobre meu projeto",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
@@ -288,7 +274,7 @@ describe("Header", () => {
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const buttons = screen.getAllByRole("link", {
name: "Chamar no WhatsApp",
name: "Falar sobre meu projeto",
});
expect(buttons).toHaveLength(2);
for (const button of buttons) {
@@ -309,7 +295,9 @@ describe("Header", () => {
render(<Header whatsAppContexts={contexts} />);
expect(
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
whatsAppHref(
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
),
).toContain("Quero conversar sobre um projeto.");
});
});
+7 -10
View File
@@ -15,8 +15,6 @@ import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
const WHATSAPP_LABEL = "Chamar no WhatsApp";
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
const INERT_IDS = ["main-content", "site-footer"];
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
@@ -57,8 +55,7 @@ function trapTab(event: KeyboardEvent, header: HTMLElement) {
/**
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
* já com o assunto da página, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
* ("Falar sobre meu projeto").
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
*/
function ContactCta({
className,
@@ -81,7 +78,7 @@ function ContactCta({
placement={placement}
onClick={onNavigate}
>
{WHATSAPP_LABEL}
{CTA_LABEL}
</WhatsAppLink>
);
}
@@ -221,7 +218,7 @@ export function Header({
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
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"
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"
>
{item.label}
</HoverPrefetchLink>
@@ -233,7 +230,7 @@ export function Header({
<div className="flex shrink-0 items-center">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
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"
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)]"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
@@ -265,7 +262,7 @@ export function Header({
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
<div className="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="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-section-shell flex flex-1 flex-col">
<nav
id={MENU_ID}
@@ -277,7 +274,7 @@ export function Header({
<li key={item.href} className="border-b border-white/10">
<HoverPrefetchLink
href={item.href}
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"
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"
onClick={closeMobileMenu}
>
{item.label}
@@ -287,7 +284,7 @@ export function Header({
</ul>
<div className="mt-auto pt-sp-8">
<ContactCta
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"
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)]"
placement="header_menu"
viaWhatsApp={viaWhatsApp}
subject={subject}
+17 -38
View File
@@ -3,7 +3,7 @@ import Hero from "./Hero";
/**
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
* links sociais (externo x mailto, com rastreio) e faixa de clientes.
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
*/
jest.mock("./cinematic/HeroField", () => ({
@@ -107,46 +107,25 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
expect(screen.queryByText("Já entregue para:")).toBeNull();
});
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
expect(screen.getByTestId("hero-work")).toHaveTextContent(
"lavacao.example.com",
);
});
it("path: workUrl omitted -> the browser bar shows no address", () => {
render(<Hero />);
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
});
it("path: the main button carries the id that hides the floating WhatsApp", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
).toHaveAttribute("id", "hero-contact-cta");
expect(
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).not.toHaveAttribute("id");
});
});
describe("Hero: contato", () => {
it("path: 'Chamar no WhatsApp' -> a whatsapp_open from the hero, with the home subject", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(window.gtag).toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.objectContaining({
placement: "hero",
service: "um site ou sistema para a minha empresa",
}),
);
});
it("path: 'Falar sobre meu projeto' -> the form, with no WhatsApp event", () => {
render(<Hero />);
const link = screen.getByRole("link", { name: /Falar sobre meu projeto/ });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link, { button: 0 });
expect(link).toHaveAttribute("href", "/#contact-form");
expect(window.gtag).not.toHaveBeenCalledWith(
"event",
"whatsapp_open",
expect.anything(),
);
).toHaveAttribute("id", "hero-contact-cta");
});
});
+46 -86
View File
@@ -36,76 +36,49 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /falar sobre meu projeto/i,
});
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink).toHaveAttribute("href", "/#contact-form");
expect(contactLink).not.toHaveAttribute("target");
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
expect(contactLink).toHaveClass("cv-fill");
expect(contactLink.className).not.toMatch(/hover:bg-/);
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).toHaveClass("cv-outline-dark");
expect(projects).not.toHaveAttribute("target");
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(whatsApp).toHaveAttribute("target", "_blank");
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
"text",
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
expect(contactLink).toHaveAttribute("target", "_blank");
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(
contactLink.getAttribute("href") ?? "",
).searchParams.get("text");
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
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();
// o botão primário é o único com o fundo limão; o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).not.toHaveAttribute("target");
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
render(<Hero />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
);
});
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
render(<Hero />);
const heading = screen.getByRole("heading", { level: 1 });
const highlighted = Array.from(
heading.querySelectorAll<HTMLElement>(".hero-word"),
).filter((word) => word.className.includes("text-[var(--slash)]"));
expect(highlighted.map((word) => word.textContent)).toEqual([
"sob",
"medida",
]);
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
const marks = heading.querySelectorAll(".hero-mark");
expect(marks).toHaveLength(1);
expect(marks[0]).toHaveClass("whitespace-nowrap");
expect(marks[0].textContent).toBe("sob medida");
for (const word of highlighted) expect(marks[0]).toContainElement(word);
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
)
.filter((word) => word.className.includes("text-[var(--slash)]"))
.map((word) => word.textContent);
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
});
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
@@ -133,22 +106,11 @@ describe("Hero", () => {
expect(
screen.getByText(
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
// o primeiro passo, logo abaixo dos botões
const step = screen.getByText(
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
);
expect(step.className).toContain("text-[15px]");
expect(
screen
.getByRole("link", { name: "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" }),
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
).toHaveAttribute("id", "hero-contact-cta");
});
@@ -174,15 +136,27 @@ describe("Hero", () => {
}
});
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
const { container } = render(<Hero clients={clients} />);
it("renders the work column with lazy posters that stay out of small screens", () => {
render(<Hero clients={clients} />);
const piece = container.querySelector(".hero-piece");
expect(piece).not.toBeNull();
expect(piece).toHaveAttribute("aria-hidden", "true");
const image = piece?.querySelector("img");
expect(image?.getAttribute("src")).toContain("hero-poster");
expect(image).toHaveAttribute("alt", "");
const work = screen.getByTestId("hero-work");
expect(work).toHaveClass("hidden", "lg:block");
const images = work.querySelectorAll("img");
expect(images).toHaveLength(2);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
}
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
const original = (image: Element) =>
new URL(
image.getAttribute("src") ?? "",
"http://localhost",
).searchParams.get("url");
expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(images[0]).toHaveAccessibleName(
"Site da Lavação 047 aberto no computador",
);
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
});
it("shows a wide card to the SEV case below the buttons on small screens", () => {
@@ -199,27 +173,13 @@ describe("Hero", () => {
});
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
const { container } = render(<Hero />);
render(<Hero />);
const label = container.querySelector(".hero-kicker") as HTMLElement;
expect(label.textContent).toBe(
"Evandro Fonseca Junior · Engenharia de software",
const label = screen.getByText(
/Evandro Fonseca Junior · Engenharia de software/,
);
expect(label.className).toContain("font-display");
expect(label.className).not.toContain("font-mono");
// sem caixa alta nem espaçamento largo
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
});
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
render(<Hero />);
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
"max-lg:hidden",
);
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
"hidden",
);
});
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
+45 -121
View File
@@ -1,20 +1,16 @@
"use client";
import Image from "next/image";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import { textLinkDark } from "./button-styles";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { CONTACT_FORM_HASH } from "./contact-subject";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork from "./HeroWork";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
const SOCIAL_LINKS = [
{
@@ -37,58 +33,28 @@ export type HeroStats = {
years?: number;
};
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
const ACCENT = new Set([3, 4]);
type TitleWord = { word: string; key: string; index: number };
/**
* Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. As palavras
* do destaque seguidas viram um trecho só: elas nunca se separam numa quebra de linha, e o bloco do marcador as cobre
* de uma vez.
*/
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
const ACCENT = new Set([5, 6, 7]);
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
const TITLE =
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
.split(" ")
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
(runs, word, index) => {
const accent = ACCENT.has(index);
// a mesma palavra se repete ("e"), então a posição entra na chave
const entry = { word, key: `${word}-${index}`, index };
const last = runs.at(-1);
if (accent && last?.accent) last.words.push(entry);
else runs.push({ key: entry.key, accent, words: [entry] });
return runs;
},
[],
);
function TitleWords({
words,
accent,
}: {
words: TitleWord[];
accent: boolean;
}) {
return words.map(({ word, key, index }, position) => (
<Fragment key={key}>
{position > 0 && " "}
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>
</Fragment>
));
}
.map((word, index) => ({
word,
// a mesma palavra se repete ("e"), então a posição entra na chave
key: `${word}-${index}`,
accent: ACCENT.has(index),
}));
export default function Hero({
stats,
clients,
workUrl,
}: {
stats?: HeroStats;
clients?: ClientLink[];
/** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
workUrl?: HeroWorkUrl;
}) {
return (
<section
@@ -97,9 +63,8 @@ export default function Hero({
>
<div className="hero-stage">
<div className="hero-glow" 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 />
<div className="hero-scrim" aria-hidden="true" />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
@@ -116,48 +81,38 @@ export default function Hero({
</ol>
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)]">
<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">
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
{/* Abaixo de 1024 px o rótulo divide a linha com a peça: fica só a profissão, e o nome volta na tela larga. */}
<p className="hero-in hero-kicker mb-sp-4 flex items-center gap-sp-3 font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]">
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
/>
<span>
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
Engenharia de software
</span>
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
<h1 className="hero-title hero-col max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em]">
{TITLE.map(({ key, accent, words }) => (
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
<h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
{TITLE.map(({ word, key, accent }, index) => (
<Fragment key={key}>
{accent ? (
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
<span
className="hero-mark whitespace-nowrap"
style={{ "--i": words[0].index } as CSSProperties}
>
<TitleWords words={words} accent />
</span>
) : (
<TitleWords words={words} accent={false} />
)}{" "}
<span
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
style={{ "--i": index } as CSSProperties}
>
{word}
</span>{" "}
</Fragment>
))}
</h1>
<p className="hero-in hero-col mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.4s]">
Do site institucional ao sistema que a sua equipe usa todo dia. Você
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
fala com quem desenvolve.
</p>
<div className="hero-in hero-col hero-actions mt-sp-5 flex flex-col gap-sp-3 [--d:.48s] sm:flex-row sm:flex-wrap sm:items-center">
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
<HoverPrefetchLink
href={`/${CONTACT_FORM_HASH}`}
className="cv-fill group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline sm:px-sp-6 sm:text-[16px]"
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
Falar sobre meu projeto
<svg
@@ -170,59 +125,28 @@ export default function Hero({
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</HoverPrefetchLink>
</WhatsAppLink>
<HoverPrefetchLink
href="/cases"
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]"
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]"
>
Ver projetos entregues
</HoverPrefetchLink>
{/* Abre o WhatsApp com o assunto da home; enquanto está à vista, o botão flutuante (que observa este id) some. */}
<WhatsAppLink
id={HERO_CTA_ID}
service={HOME_SUBJECT}
placement="hero"
className={`${textLinkDark} gap-sp-2 self-start sm:self-auto`}
>
<WhatsAppMark className="h-4 w-4 shrink-0" />
Chamar no WhatsApp
</WhatsAppLink>
</div>
{/* O primeiro passo, dito antes de a pessoa clicar: tira a dúvida de "e depois, o que acontece?". */}
<p className="hero-in hero-col mt-sp-3 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] [--d:.54s]">
Começa com uma conversa para entender o seu caso. A proposta vem por
escrito.
</p>
<ClientStrip
clients={clients}
years={stats?.years}
className="hero-in hero-col mt-sp-3 [--d:.6s]"
className="hero-in mt-sp-3 [--d:.75s]"
/>
<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>
<HeroWork url={workUrl} />
</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">
<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="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
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">
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
<div className="max-lg:hidden">
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
(service) => (
@@ -261,7 +185,7 @@ export default function Hero({
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
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"
>
Role para mergulhar
<span className="hero-cue-line" />
+53 -9
View File
@@ -1,9 +1,9 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import HeroWork from "./HeroWork";
/**
* O pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
* LCP), no cartão largo e clicável da SEV para as telas abaixo de 1024 px. Em tela larga a coluna é da peça 3D.
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
*/
const original = (image: Element) =>
@@ -12,9 +12,23 @@ const original = (image: Element) =>
);
describe("HeroWork: pôsteres no tamanho certo", () => {
it("serves the SEV poster through the image optimizer, with a `sizes` that matches how it shows", () => {
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
render(<HeroWork />);
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
expect(desktop).toHaveLength(2);
expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 500px, 36vw",
);
expect(desktop[1]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 136px, 10vw",
);
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
const small = screen
@@ -41,11 +55,23 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
render(<HeroWork />);
const [browser, phone] = within(
screen.getByTestId("hero-work"),
).getAllByRole("img");
expect(browser).toHaveAttribute("width", "1440");
expect(browser).toHaveAttribute("height", "900");
expect(phone).toHaveAttribute("width", "390");
expect(phone).toHaveAttribute("height", "844");
});
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
const { container } = render(<HeroWork />);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(1);
expect(images).toHaveLength(3);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("fetchpriority", "low");
@@ -56,12 +82,13 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
});
describe("HeroWork: cartão do celular", () => {
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
const { container } = render(<HeroWork />);
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
render(<HeroWork />);
const column = screen.getByTestId("hero-work");
expect(column).toHaveClass("hidden", "lg:block");
const card = screen.getByTestId("hero-work-mobile");
expect(container.firstChild).toBe(card);
expect(container.childElementCount).toBe(1);
expect(card).toHaveClass("lg:hidden", "w-full");
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
expect(card.querySelector(".ds-phone")).toBeNull();
@@ -92,4 +119,21 @@ describe("HeroWork: cartão do celular", () => {
expect(screen.getAllByRole("link")).toHaveLength(1);
});
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
render(<HeroWork />);
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
expect(
within(screen.getByTestId("hero-work")).queryByRole("link"),
).toBeNull();
});
it("passes the address to the browser bar of the desktop frame", () => {
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
expect(
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
).toBeInTheDocument();
});
});
+93 -44
View File
@@ -1,8 +1,16 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
/** O cartão mostra o site da SEV e leva ao case. */
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "lavacao-047";
const DESKTOP_PROJECT_TITLE = "Lavação 047";
const MOBILE_PROJECT = "as-servicos";
/** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
@@ -20,54 +28,95 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
/**
* Trabalho real do hero, só abaixo de 1024 px: um cartão largo e clicável (a largura da coluna, cerca de 350 px num
* celular) com o pôster do site da SEV, abaixo dos botões, que leva ao case da SEV; antes era uma moldura de celular
* de 65 por 160 px, pequena demais para ver o que era. Em telas largas a coluna da direita é da peça 3D, e o trabalho
* real começa na seção Projetos.
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
* Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
* fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
*/
export default function HeroWork() {
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return (
<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 }}
<>
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
>
<Image
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
loading="lazy"
fetchPriority="low"
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
/>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
<BrowserFrame url={url} className="w-[88%]">
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
<Image
src={desktop.poster}
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
width={desktop.width}
height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw"
loading="lazy"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
<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 className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
</>
);
}
-33
View File
@@ -1,33 +0,0 @@
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",
);
});
});
+9 -46
View File
@@ -1,5 +1,4 @@
import Image from "next/image";
import "./logo.css";
interface LogoProps {
theme?: "dark" | "light";
@@ -7,56 +6,20 @@ interface LogoProps {
loading?: "eager" | "lazy";
}
/*
* O logo em duas partes sobre a caixa do wordmark-<tema>.webp (1600x330): o símbolo em SVG em linha, para os blocos
* lima poderem se mexer (logo.css), e o texto, que é o mesmo .webp com o símbolo apagado (wordmark-text-<tema>.webp,
* do mesmo tamanho, para passar pelo mesmo redimensionamento e dar a mesma altura de antes). As coordenadas do SVG
* são pixels do .webp original, medidas nas bordas das formas; cor e opacidade também vêm dele.
*/
const SHAPE_FILL = { dark: "#f4f3ee", light: "#00090b" } as const;
// em ordem de encaixe, de baixo para cima
const BLOCKS = [
{ x: 130.66, y: 110.25, width: 76.47, height: 86.05 },
{ x: 204.39, y: 43.9, width: 67.72, height: 69.39 },
{ x: 287.27, y: 3.28, width: 39.58, height: 40.82 },
{ x: 312.91, y: 79.72, width: 39.39, height: 39.76 },
];
export function Logo({
theme = "dark",
className = "w-48",
loading = "lazy",
}: Readonly<LogoProps>) {
return (
<span className={`relative block max-w-full ${className}`}>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 360 330"
preserveAspectRatio="none"
className="absolute top-0 left-0 h-full w-[22.5%] overflow-visible"
>
<path
fill={SHAPE_FILL[theme]}
fillOpacity={0.933}
d="M10.17 223.44H40.23V188.63H74.87V153.98H111.2V222.33H74.93V277.73H139.93V217.4H200.14V291.36H171.59V325.32H10.17Z"
/>
<g fill="#ccfa00" opacity={0.98}>
{BLOCKS.map((block) => (
<rect key={block.x} className="logo-bloco" {...block} />
))}
</g>
</svg>
<Image
src={`/brand/wordmark-text-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className="block h-auto w-full"
/>
</span>
<Image
src={`/brand/wordmark-${theme}.webp`}
alt="Evandro.dev.br"
width={1600}
height={330}
sizes="240px"
loading={loading}
className={`block h-auto max-w-full ${className}`}
/>
);
}
-70
View File
@@ -1,70 +0,0 @@
import { act, render, waitFor } from "@testing-library/react";
import { OfferVideo } from "./OfferVideo";
/**
* O vídeo da peça 3D toca quando aparece (o IntersectionObserver do jest.setup avisa logo depois de observar) e fica
* parado no pôster com movimento reduzido ou "Economizar dados".
*/
const realMatchMedia = window.matchMedia;
let play: jest.SpyInstance;
let pause: jest.SpyInstance;
beforeEach(() => {
play = jest
.spyOn(HTMLMediaElement.prototype, "play")
.mockResolvedValue(undefined);
pause = jest
.spyOn(HTMLMediaElement.prototype, "pause")
.mockImplementation(() => {});
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
window.matchMedia = realMatchMedia;
Reflect.deleteProperty(navigator, "connection");
});
it("path: in view -> plays, muted and in a loop", async () => {
const { container } = render(<OfferVideo name="whatsapp" />);
await waitFor(() => expect(play).toHaveBeenCalled());
const video = container.querySelector("video") as HTMLVideoElement;
expect(video.muted).toBe(true);
expect(video.loop).toBe(true);
});
it("path: reduced motion -> never plays, stays on the poster", () => {
jest.useFakeTimers();
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-reduced-motion: reduce)",
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
const { container } = render(<OfferVideo name="sistema" />);
// o aviso de "à vista" do IntersectionObserver chega num setTimeout
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
expect(container.querySelector("video")).toHaveAttribute(
"poster",
"/3d/peca-sistema.webp",
);
});
it("path: save data -> never plays", () => {
jest.useFakeTimers();
Object.defineProperty(navigator, "connection", {
configurable: true,
value: { saveData: true },
});
render(<OfferVideo name="sistema" />);
act(() => jest.runAllTimers());
expect(pause).toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
});
-53
View File
@@ -1,53 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { useInView } from "@/app/hooks/useInView";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import { useSaveData } from "./showcase/useShowcaseMedia";
/**
* Peça 3D em loop no cartão da oferta (`public/3d/peca-<nome>.{webm,mp4}`, 640x400, com o pôster `.webp`). É decorativa:
* o nome do cartão e a lista dizem o que é. Nada baixa antes de tocar (`preload="none"`); toca só enquanto está à vista e
* fica no pôster com movimento reduzido ou "Economizar dados".
*/
export function OfferVideo({ name }: Readonly<{ name: string }>) {
const ref = useRef<HTMLVideoElement>(null);
const inView = useInView(ref, { amount: 0.3, once: false });
const reducedMotion = useReducedMotion();
const saveData = useSaveData();
const play = inView && !reducedMotion && !saveData;
useEffect(() => {
const video = ref.current;
if (!video) return;
if (!play) {
video.pause();
return;
}
// o navegador pode recusar a reprodução (economia de energia, aba em segundo plano): o vídeo fica no pôster
video.play()?.catch(() => {});
}, [play]);
const src = `/3d/peca-${name}`;
return (
<video
ref={ref}
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
muted
loop
playsInline
preload="none"
poster={`${src}.webp`}
width={640}
height={400}
// contain, não cover: o fundo do vídeo é o mesmo do quadro, e a peça nunca é cortada num quadro mais largo
className="block h-full w-full object-contain"
>
<source src={`${src}.webm`} type="video/webm" />
<source src={`${src}.mp4`} type="video/mp4" />
</video>
);
}
-32
View File
@@ -1,32 +0,0 @@
import Image from "next/image";
import { OfferVideo } from "./OfferVideo";
/**
* Peça de blocos na coluna da direita do topo de uma página interna (a grade é `heroWithMediaGrid`), só de 1024 px em
* diante. Decorativa: fora da leitura. A peça não mede nada: ocupa a altura que o texto ao lado já deu à linha (e um
* pouco da folga de cima e de baixo), então não empurra o texto nem aumenta o topo.
* `image`: imagem de fundo transparente (`/3d/estado-*.webp`, 1200x900). `loop`: o loop `public/3d/peca-<nome>`
* (640x400, fundo #0a0a0a, que some no topo escuro), pelo OfferVideo.
*/
export function PagePiece(
props: Readonly<{ image: string } | { loop: string }>,
) {
return (
<div aria-hidden="true" className="relative hidden self-stretch lg:block">
{/* object-contain: a peça inteira à vista; o loop chega com object-cover do OfferVideo e não pode perder as bordas */}
<div className="absolute inset-x-0 -top-sp-8 -bottom-sp-6 [&_video]:object-contain">
{"image" in props ? (
<Image
src={props.image}
alt=""
fill
sizes="(min-width: 1024px) 40vw, 1px"
className="object-contain"
/>
) : (
<OfferVideo name={props.loop} />
)}
</div>
</div>
);
}
+1 -1
View File
@@ -178,7 +178,7 @@ describe("Section", () => {
"ds-section-description",
"ds-reveal",
);
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-type");
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
const side = screen.getByText("lateral").parentElement;
expect(side?.tagName).toBe("DIV");
+1 -3
View File
@@ -43,9 +43,7 @@ describe("Section", () => {
</Section>,
);
const eyebrow = screen.getByText("Sobre");
expect(eyebrow).toHaveClass("ds-eyebrow", "ds-type");
expect(eyebrow.style.getPropertyValue("--n")).toBe("5");
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
expect(container.querySelector(".ds-section-description")).toBeNull();
expect(
container.querySelector(".ds-section-header > .ds-reveal"),
+1 -9
View File
@@ -45,15 +45,7 @@ export const Section = ({
>
<div className="ds-section-meta">
{eyebrow && (
<p
className="ds-eyebrow ds-type"
style={
{
...stagger(0.02),
"--n": eyebrow.length,
} as CSSProperties
}
>
<p className="ds-eyebrow ds-reveal" style={stagger(0.02)}>
{eyebrow}
</p>
)}
+69 -199
View File
@@ -5,10 +5,9 @@ import { WorkModalities } from "./WorkModalities";
/**
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as quatro ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo"
* uma vez só depois dos cartões), a faixa da nota fiscal acima deles, o estilo único de cartão, o texto de pelo menos
* 15 px e a lista compacta de etapas.
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
* `serviceOffers`, foto do case ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
* vez só depois dos cartões), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
*/
@@ -34,18 +33,6 @@ jest.mock("./Section", () => ({
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
// o jsdom não toca vídeo: os cartões do sistema e do atendimento chamam play() ao aparecer
beforeEach(() => {
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
const HOME_OFFERS = serviceOffers.filter(({ id }) => id !== "fiscal");
const original = (image: Element) =>
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
"url",
@@ -56,20 +43,20 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
expect(reveals).toHaveLength(4);
expect(reveals).toHaveLength(3);
expect(
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
).toEqual(["20px", "20px", "20px", "20px"]);
).toEqual(["20px", "20px", "20px"]);
expect(
reveals.map((element) =>
element.style.getPropertyValue("--reveal-duration"),
),
).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]);
).toEqual(["0.75s", "0.75s", "0.75s"]);
expect(
reveals.map((element) =>
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
),
).toEqual([0.08, 0.16, 0.24, 0.32]);
).toEqual([0.08, 0.16, 0.24]);
// o estado escondido vive no CSS: sem opacidade nem transformação inline
for (const element of reveals) {
expect(element.style.opacity).toBe("");
@@ -81,7 +68,7 @@ describe("WorkModalities: movimento", () => {
const { container } = render(<WorkModalities />);
const cards = container.querySelectorAll("article");
expect(cards).toHaveLength(4);
expect(cards).toHaveLength(3);
for (const card of cards) {
expect(card).not.toHaveAttribute("tabindex");
expect(card.className).not.toContain("outline-none");
@@ -100,7 +87,7 @@ describe("WorkModalities: movimento", () => {
});
});
describe("WorkModalities: as quatro ofertas", () => {
describe("WorkModalities: as três ofertas", () => {
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
const { container } = render(<WorkModalities />);
@@ -111,7 +98,7 @@ describe("WorkModalities: as quatro ofertas", () => {
}),
).toBeInTheDocument();
const articles = Array.from(container.querySelectorAll("article"));
expect(articles).toHaveLength(4);
expect(articles).toHaveLength(3);
const titles = articles.map(
(article) =>
within(article).getByRole("heading", { level: 3 }).textContent,
@@ -119,24 +106,10 @@ describe("WorkModalities: as quatro ofertas", () => {
expect(titles).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Revisão de sistema que já existe",
]);
// a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão
expect(titles).toEqual(HOME_OFFERS.map(({ name }) => name));
expect(
screen.getByText(
"Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado.",
),
).toBeInTheDocument();
});
it("path: two columns from 1024 px up and one below it", () => {
const { container } = render(<WorkModalities />);
const grid = container.querySelector("article")?.closest(".grid");
expect(grid).toHaveClass("grid-cols-1", "lg:grid-cols-2");
expect(grid?.className).not.toMatch(/grid-cols-3/);
// a fonte é uma só, a mesma de /servicos
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
});
it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
@@ -151,10 +124,10 @@ describe("WorkModalities: as quatro ofertas", () => {
).toEqual(serviceOffers[0].deliverables);
});
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", () => {
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", () => {
const { container } = render(<WorkModalities />);
const [site, system, whatsapp, review] = Array.from(
const [site, system, review] = Array.from(
container.querySelectorAll("article"),
);
const siteImage = within(site).getByRole("img");
@@ -164,114 +137,55 @@ describe("WorkModalities: as quatro ofertas", () => {
);
expect(siteImage).toHaveAttribute(
"sizes",
"(min-width: 1024px) 50vw, 100vw",
"(min-width: 1024px) 33vw, 100vw",
);
expect(siteImage).toHaveAttribute("loading", "lazy");
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
for (const card of [system, whatsapp, review]) {
expect(card.querySelector("img")).toBeNull();
}
expect(system.querySelector("img")).toBeNull();
expect(review.querySelector("img")).toBeNull();
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
expect(site.querySelector("video")).toBeNull();
expect(review.querySelector("video")).not.toBeNull();
});
it("path: the system, 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", () => {
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", () => {
const { container } = render(<WorkModalities />);
expect(screen.queryByTestId("offer-icon")).toBeNull();
const boxes = HOME_OFFERS.map(({ id }) =>
const boxes = ["site", "sistema", "revisao"].map((id) =>
screen.getByTestId(`offer-art-${id}`),
);
// as quatro caixas e os quatro quadros têm as mesmas classes, então o mesmo tamanho
// as três caixas têm as mesmas classes, então o mesmo tamanho
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
const frames = boxes.map((box) => box.firstElementChild as HTMLElement);
expect(new Set(frames.map((frame) => frame.className)).size).toBe(1);
for (const frame of frames) {
expect(frame.className).toContain("aspect-[16/10]");
expect(frame.className).toContain("md:aspect-[21/9]");
expect(frame.className).toContain("lg:aspect-[16/10]");
expect(frame.querySelector("img, video")).not.toBeNull();
for (const box of boxes) {
expect(box.className).toContain("aspect-[16/10]");
expect(box.className).toContain("md:aspect-[21/9]");
expect(box.className).toContain("lg:aspect-[16/10]");
expect(box.firstElementChild).not.toBeNull();
}
expect(container.querySelectorAll("article")).toHaveLength(4);
expect(container.querySelectorAll("article")).toHaveLength(3);
});
it("path: the system illustration is a sheet -> arrow -> dashboard scheme in SVG, with three indicators, no text and no numbers", () => {
render(<WorkModalities />);
const art = screen.getByTestId("offer-art-sistema");
const scheme = within(art).getByRole("img");
expect(scheme.tagName.toLowerCase()).toBe("svg");
expect(scheme).toHaveAccessibleName(
"Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras",
);
expect(scheme.getAttribute("viewBox")).toBe("0 0 240 150");
expect(scheme.querySelector('[data-art="planilha"]')).not.toBeNull();
expect(scheme.querySelector('[data-art="seta"]')).not.toBeNull();
const panel = scheme.querySelector('[data-art="painel"]') as Element;
expect(panel).not.toBeNull();
// três indicadores genéricos: um quadro com um traço de rótulo e um bloco de valor cada, sem dígito algum
expect(panel.querySelectorAll("g").length).toBe(3);
expect(scheme.querySelector("text")).toBeNull();
expect(art.textContent).toBe("");
// nada de cor de destaque: o limão é só do contato
expect(art.innerHTML).not.toMatch(/ccff00|var\(--slash\)/i);
});
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
render(<WorkModalities />);
@@ -292,21 +206,26 @@ describe("WorkModalities: as quatro ofertas", () => {
for (const severity of ["Alta", "Média"]) {
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
}
// 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
// o texto da ilustração também tem 15 px, como o resto da seção
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
});
it("path: the deliverables of the system, the WhatsApp and the review come from serviceOffers, the same lists of /servicos", () => {
it("path: the deliverables of the system and of the review come from serviceOffers, the same lists of /servicos", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
HOME_OFFERS.forEach((offer, index) => {
expect(
within(articles[index])
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(offer.deliverables);
});
const [, system, review] = Array.from(
container.querySelectorAll("article"),
);
expect(
within(system)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[1].deliverables);
expect(
within(review)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual(serviceOffers[2].deliverables);
});
it("path: every card says what you receive, with three or four items, once", () => {
@@ -322,7 +241,7 @@ describe("WorkModalities: as quatro ofertas", () => {
}
});
it("path: the first step shows once, below the cards: a first conversation and, after it, the scope and the proposal in writing", () => {
it("path: the first step shows once, below the three cards: a first conversation and, after it, the scope and the proposal in writing", () => {
const { container } = render(<WorkModalities />);
for (const card of container.querySelectorAll("article")) {
@@ -343,14 +262,13 @@ describe("WorkModalities: as quatro ofertas", () => {
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
});
it("path: each button opens the contact form card with the project type already chosen, all four with the same filled style", () => {
it("path: each button opens the contact form card with the project type already chosen, all three with the same filled style", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
const buttons = [
["Começar pelo site", "/?tipo=site#contact-form"],
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
["Começar pelo atendimento", "/?tipo=whatsapp#contact-form"],
["Começar pela revisão", "/?tipo=revisao#contact-form"],
] as const;
buttons.forEach(([name, href], index) => {
@@ -374,19 +292,9 @@ describe("WorkModalities: as quatro ofertas", () => {
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
within(articles[2]).getByRole("link", {
name: "Ver como funciona o atendimento",
}),
).toHaveAttribute("href", "/servicos/atendimento-whatsapp-ia");
expect(
within(articles[3]).getByRole("link", {
name: "Ver como funciona a revisão",
}),
).toHaveAttribute("href", "/ia");
expect(
within(articles[2]).getByText(
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
),
).toBeInTheDocument();
});
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
@@ -398,19 +306,19 @@ describe("WorkModalities: as quatro ofertas", () => {
name: "Ver todos os serviços",
});
expect(services).toHaveAttribute("href", "/servicos");
expect(container.querySelectorAll("article")).toHaveLength(4);
expect(container.querySelectorAll("article")).toHaveLength(3);
// o link fica fora dos cartões, depois da grade
expect(services.closest("article")).toBeNull();
expect(
screen
.getAllByRole("link")
.filter((link) => link.getAttribute("href") === "/servicos"),
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
).toEqual([services]);
});
});
describe("WorkModalities: um só estilo de cartão", () => {
it("path: the four cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
const { container } = render(<WorkModalities />);
const articles = Array.from(container.querySelectorAll("article"));
@@ -485,41 +393,3 @@ 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);
});
});
+153 -86
View File
@@ -7,35 +7,28 @@ import {
} from "@/lib/site-content";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import { FiscalNotice } from "./FiscalNotice";
import { isFiscalNoticeActive } from "./fiscal-notice";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { OfferVideo } from "./OfferVideo";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
import { WhatsAppMark } from "./WhatsAppMark";
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
interface OfferExtras {
description: string;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz um vídeo ou uma ilustração própria (ver OfferArt). */
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
poster?: { id: string; title: string };
moreLabel: string;
moreHref: string;
}
/** Ofertas com cartão na home; a da nota fiscal entra na faixa datada acima dos cartões (FiscalNotice). */
type HomeOfferId = Exclude<ServiceOffer["id"], "fiscal">;
/**
* Os textos da home para as quatro ofertas, na ordem de `serviceOffers`. Cada texto só diz o que o case, a página de /ia
* ou a página da oferta citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de
* catálogo e tabela de preços, o que o texto descreve. O sistema, o atendimento no WhatsApp e a revisão trazem uma peça
* 3D em vídeo (OfferVideo); por cima, o atendimento tem o selo do aplicativo e a revisão, um recorte do relatório.
* Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
* página de /ia citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de catálogo e
* tabela de preços, o que o texto descreve. O sistema e a revisão não têm um case que mostre o que descrevem (a vitrine
* da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
*/
const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
site: {
description:
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
@@ -51,13 +44,6 @@ const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
whatsapp: {
description:
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
ctaLabel: "Começar pelo atendimento",
moreLabel: "Ver como funciona o atendimento",
moreHref: "/servicos/atendimento-whatsapp-ia",
},
revisao: {
description:
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
@@ -67,10 +53,6 @@ const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
},
};
const homeOffers = serviceOffers.flatMap((offer) =>
offer.id === "fiscal" ? [] : [{ offer, extras: OFFER_EXTRAS[offer.id] }],
);
const PROCESS = [
{
title: "Entender a operação",
@@ -94,6 +76,127 @@ const PROCESS = [
},
];
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
const SHEET_COLUMNS = [0, 1, 2];
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
const SHEET_DASH_WIDTHS = [18, 12, 15];
/** Alturas das cinco barras do painel do esquema. */
const PANEL_BARS = [14, 26, 20, 34, 28];
/**
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
*/
function SystemArt() {
return (
<svg
role="img"
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
viewBox="0 0 240 150"
className="block h-full w-full text-white"
fill="none"
stroke="currentColor"
>
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
<rect x="14" y="30" width="80" height="92" />
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
</g>
<rect
x="15"
y="31"
width="78"
height="14"
fill="currentColor"
fillOpacity="0.16"
stroke="none"
/>
<g fill="currentColor" fillOpacity="0.5" stroke="none">
{SHEET_DATA_ROWS.flatMap((row) =>
SHEET_COLUMNS.map((column) => (
<rect
key={`${row}-${column}`}
x={19 + column * 26.6}
y={52 + row * 14.6}
width={
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
}
height="4"
rx="1"
/>
)),
)}
</g>
<path
data-art="seta"
d="M104 76h24M119 66l11 10-11 10"
strokeOpacity="0.85"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g data-art="painel">
<rect
x="140"
y="22"
width="92"
height="108"
rx="3"
fill="currentColor"
fillOpacity="0.06"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
{[147, 175, 203].map((x) => (
<g key={x}>
<rect
x={x}
y="30"
width="25"
height="32"
strokeOpacity="0.35"
strokeWidth="1"
/>
<rect
x={x + 4}
y="35"
width="12"
height="3"
fill="currentColor"
fillOpacity="0.5"
stroke="none"
/>
<rect
x={x + 4}
y="43"
width="16"
height="9"
fill="currentColor"
fillOpacity="0.92"
stroke="none"
/>
</g>
))}
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
{PANEL_BARS.map((height, index) => (
<rect
key={height}
x={151 + index * 15}
y={118 - height}
width="10"
height={height}
fill="currentColor"
fillOpacity="0.6"
stroke="none"
/>
))}
</g>
</svg>
);
}
/**
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
@@ -106,35 +209,28 @@ const REPORT_EXCERPT = [
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
const SEVERITY_BADGE =
"inline-flex min-w-14 shrink-0 items-center justify-center border border-[var(--ink)] px-2 font-display text-[18px] @[29rem]:text-[15px] leading-6";
"inline-flex min-w-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
/**
* Recorte do "Modelo do relatório" de /ia junto do vídeo da revisão: três linhas de achado, cada uma com o selo da
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
* quadro, então numa caixa larga (528 px ou mais) o recorte fica por cima da metade direita, centrado na vertical. Numa
* caixa mais estreita (celular, e as duas colunas logo acima de 1024 px) ele não cabe ao lado da janela sem cobri-la:
* desce para baixo do quadro, no mesmo fundo escuro. A largura vem da caixa (container query), não da tela.
*/
/*
* Onde o trecho do relatório fica: ao lado da janela quando o quadro tem 29rem ou mais (a janela do vídeo acaba em 51%
* da largura); embaixo do vídeo quando o cartão ocupa a linha inteira e é estreito (celular); fora da tela entre 1024
* e 1279 px, em que os cartões já estão lado a lado mas o quadro é estreito demais e empilhar desalinharia os títulos.
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
* numa linha só no cartão mais estreito (três colunas em 1024 px).
*/
function ReviewArt() {
return (
<div
role="img"
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
className="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"
className="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
>
<ul
aria-hidden="true"
className="w-max max-w-full divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-3 py-0.5 text-[var(--ink)]"
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
>
{REPORT_EXCERPT.map(({ finding, severity }) => (
<li
key={finding}
className="flex items-center justify-between gap-sp-4 py-1 font-display text-[18px] @[29rem]:text-[15px] leading-7"
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
>
<span className="min-w-0">{finding}</span>
<span
@@ -147,7 +243,7 @@ function ReviewArt() {
</ul>
<p
aria-hidden="true"
className="font-display text-[18px] @[29rem]:text-[15px] leading-6 text-white/80"
className="font-display text-[15px] leading-5 text-white/75"
>
Exemplo ilustrativo
</p>
@@ -155,11 +251,7 @@ function ReviewArt() {
);
}
/** 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. */
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
function OfferArt({
offer,
extras,
@@ -170,40 +262,21 @@ function OfferArt({
if (poster && extras.poster) {
return (
<div className={MEDIA_FRAME}>
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 50vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
</div>
<Image
src={poster.poster}
alt={`Site ${extras.poster.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
);
}
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}
</>
);
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
}
/** 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. */
/** 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. */
function OfferCard({
offer,
extras,
@@ -212,7 +285,7 @@ function OfferCard({
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div
data-testid={`offer-art-${offer.id}`}
className="@container relative overflow-hidden border border-[var(--border)] bg-[var(--ink)]"
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
>
<OfferArt offer={offer} extras={extras} />
</div>
@@ -273,22 +346,16 @@ function OfferCard({
}
export function WorkModalities() {
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
return (
<Section
id="modalities"
eyebrow="Soluções"
title="Escolha por onde começar"
description="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."
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."
disableChildrenAnimation
>
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
{fiscal && isFiscalNoticeActive(new Date()) ? (
<FiscalNotice href={fiscal.href} />
) : null}
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-2 lg:gap-sp-6">
{homeOffers.map(({ offer, extras }, index) => (
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{serviceOffers.map((offer, index) => (
<Reveal
key={offer.id}
y={20}
@@ -296,12 +363,12 @@ export function WorkModalities() {
delay={0.08 * (index + 1)}
className="min-w-0"
>
<OfferCard offer={offer} extras={extras} />
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
</Reveal>
))}
</div>
{/* 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. */}
{/* 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. */}
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
+7 -8
View File
@@ -1,27 +1,26 @@
/**
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
* A resposta ao ponteiro e ao foco é uma por papel (cv-fill, cv-outline-*, cv-bar, em globals.css).
*/
const base =
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline 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 transition-colors motion-reduce:transition-none sm:text-[16px]";
export const primaryButton = `${base} cv-fill bg-[var(--slash)] font-semibold text-[var(--ink)]`;
export const primaryButton = `${base} bg-[var(--slash)] font-semibold text-[var(--ink)] hover:bg-[#d9ff40]`;
/** Contorno sobre fundo escuro. */
export const secondaryButtonDark = `${base} cv-outline-dark border border-white/25 bg-transparent font-medium text-[#f4f2ed]`;
export const secondaryButtonDark = `${base} border border-white/25 bg-transparent font-medium text-[#f4f2ed] hover:border-white/55`;
/** Contorno sobre fundo claro. */
export const secondaryButtonLight = `${base} cv-outline-light border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)]`;
export const secondaryButtonLight = `${base} border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)] hover:border-[var(--ink)]`;
const textLinkBase =
"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]";
"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]";
/** Link de texto sobre fundo claro. */
export const textLink = textLinkBase;
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
/** Link de texto sobre fundo escuro. */
export const textLinkDark = `${textLinkBase} text-white/80`;
export const textLinkDark = `${textLinkBase} text-white/80 hover:text-white`;
/**
* 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),
@@ -1,614 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { act, render } from "@testing-library/react";
import type {
InstancedBufferGeometry,
Mesh,
ShaderMaterial,
Texture,
} from "three";
import { stateAt } from "@/lib/blocos";
import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device";
import BlocosScene from "./BlocosScene";
/*
* A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha
* sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o
* JSON dos estados) vêm de dublês também; o JSON é o arquivo real.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => Promise.resolve());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
this.options = options;
mockRenderers.push(this);
}
}
jest.mock("three", () => {
const three = jest.requireActual("three");
return {
...three,
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
// o jsdom não carrega imagens: o matcap é uma textura vazia
TextureLoader: jest.fn(() => ({
loadAsync: () => Promise.resolve(new three.Texture()),
})),
};
});
jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => {
const { BoxGeometry, Group, Mesh } = jest.requireActual("three");
return {
GLTFLoader: jest.fn(() => ({
loadAsync: () => {
const scene = new Group();
scene.add(new Mesh(new BoxGeometry(1, 1, 1)));
return Promise.resolve({ scene });
},
})),
};
});
const blocosJson = JSON.parse(
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
);
let mockFetchOk = true;
const originalFetch = global.fetch;
const props = () => ({
progressRef: { current: 0 },
visible: true,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockRendererName = null;
mockFetchOk = true;
global.fetch = jest.fn(() =>
Promise.resolve({
ok: mockFetchOk,
status: mockFetchOk ? 200 : 404,
json: () => Promise.resolve(blocosJson),
}),
) as unknown as typeof fetch;
setDevicePixelRatio(1);
sessionStorage.clear();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.fetch = originalFetch;
sessionStorage.clear();
});
/** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
const advance = (ms: number) =>
act(() => {
jest.advanceTimersByTime(ms);
});
/** A malha que o último quadro desenhou (a cena tem uma só). */
function drawnMesh() {
const calls = mockRenderers[0].render.mock.calls as unknown as [
{ children: Mesh[] },
][];
return calls.at(-1)?.[0].children[0] as Mesh<
InstancedBufferGeometry,
ShaderMaterial
>;
}
const uniform = (name: string) =>
drawnMesh().material.uniforms[name].value as number;
// precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo
describe("BlocosScene when the assets do not download", () => {
it("tells the page once, creates no context, and lets the next mount try again", async () => {
mockFetchOk = false;
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers).toHaveLength(0);
expect(container.querySelector("canvas")).toBeNull();
unmount();
mockFetchOk = true;
const again = props();
await mount(<BlocosScene {...again} />);
expect(again.onError).not.toHaveBeenCalled();
expect(mockRenderers).toHaveLength(1);
});
});
describe("BlocosScene", () => {
it("creates a transparent, antialiased renderer on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<BlocosScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toEqual({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
});
it.each([
["full", 3, 1.75],
["full", 1.5, 1.5],
["lite", 3, 1.5],
["lite", 0.5, 1],
] as const)(
"caps the pixel ratio on %s (device %p -> %p)",
async (tier, device, expected) => {
setDevicePixelRatio(device);
await mount(<BlocosScene {...props()} tier={tier} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws every block in one instanced call that starts on marca", async () => {
await mount(<BlocosScene {...props()} />);
advance(20);
const mesh = drawnMesh();
expect(mesh.geometry.type).toBe("InstancedBufferGeometry");
expect(mesh.geometry.instanceCount).toBe(238);
expect(uniform("uFrom")).toBe(0);
expect(uniform("uTo")).toBe(0);
});
it("tells the page it is ready on the second frame drawn", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
advance(17);
expect(mockRenderers[0].render).toHaveBeenCalledTimes(2);
expect(scene.onReady).toHaveBeenCalledTimes(1);
advance(3000);
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("draws nothing while not visible", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<BlocosScene {...scene} visible={false} />,
);
advance(3000);
expect(frames).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
rerender(<BlocosScene {...scene} visible />);
advance(100);
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<BlocosScene {...props()} />);
advance(1500); // entrada de 1,4 s, no ritmo da tela
const entry = frames.mock.calls.length;
expect(entry).toBeGreaterThan(80);
expect(uniform("uIntro")).toBe(1);
advance(1000);
const pulse = frames.mock.calls.length - entry;
expect(pulse).toBeGreaterThan(20);
expect(pulse).toBeLessThanOrEqual(34);
});
it("follows the dive progress on full", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1500);
scene.progressRef.current = 0.63;
advance(4000);
expect(uniform("uFrom")).toBe(2);
expect(uniform("uTo")).toBe(2);
expect(uniform("uT")).toBe(0);
});
it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => {
const scene = props();
scene.progressRef.current = 0.93; // ignorado no lite
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 3500); // entrada e 3,5 s parado na marca
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]);
advance(1100); // no meio da troca de 1,2 s
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
advance(1000);
expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([
1, 1, 0,
]);
});
/** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */
const hostOf800x600 = () => {
jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800);
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600);
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockReturnValue(new DOMRect(0, 0, 800, 600));
};
const fire = (event: Event) =>
act(() => {
window.dispatchEvent(event);
});
it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1000);
expect(uniform("uVida")).toBe(0); // a entrada ainda assenta
advance(1500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
expect(uniform("uEstado")).toBe(0);
const tempo = uniform("uTempo");
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
// no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.23;
advance(1000);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uVida")).toBeLessThan(0.05);
fire(new Event("scroll"));
scene.progressRef.current = 0.35;
advance(3000);
expect(uniform("uEstado")).toBe(1);
expect(uniform("uVida")).toBeGreaterThan(0.9);
});
it("freezes the resting life with the pulse once nobody moves", async () => {
const scene = props();
scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso
await mount(<BlocosScene {...scene} />);
advance(6000); // a batida para 5 s depois do último gesto
const drawn = mockRenderers[0].render.mock.calls.length;
const tempo = uniform("uTempo");
advance(3000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
expect(uniform("uTempo")).toBe(tempo);
fire(new Event("scroll"));
advance(500);
expect(uniform("uTempo")).toBeGreaterThan(tempo);
});
/** O estado que o último quadro desenhou: de, para e t. */
const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")];
it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 2500); // entrada e 2,5 s parado na marca
expect(shown()).toEqual([0, 0, 0]);
advance(1100); // na troca de 1,2 s para a tela
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeGreaterThan(0);
// durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
advance(1500);
expect(shown()).toEqual([1, 1, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela
// a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
advance(4200 * 2 + 1000); // integrações, dados
expect(shown()).toEqual([3, 3, 0]);
advance(4200);
expect(shown()).toEqual([0, 0, 0]);
// cada legenda entra com a peça assentada e sai quando a troca seguinte começa
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
null,
2,
null,
3,
null,
0,
]);
});
it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo
fire(new Event("scroll"));
scene.progressRef.current = 0.02;
advance(50); // a próxima batida
// a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava
expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]);
expect(uniform("uT")).toBeGreaterThan(0);
expect(uniform("uT")).toBeLessThan(0.1);
expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda
advance(1000);
expect(shown()).toEqual([0, 0, 0]);
// de volta ao topo: 3 s parada na marca antes da troca seguinte
fire(new Event("scroll"));
scene.progressRef.current = 0;
advance(3000);
expect(shown()).toEqual([0, 0, 0]);
advance(1500);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
});
it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} />);
advance(1400 + 3000 + 1200 + 1000);
expect(scene.onEstado).toHaveBeenLastCalledWith(1);
fire(new Event("scroll"));
scene.progressRef.current = 0.63;
advance(4000);
expect(shown()).toEqual([2, 2, 0]);
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
});
it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(1400 + 4200 + 4200 + 1000);
expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo
// a rolagem para no meio da troca da marca para a tela
fire(new Event("scroll"));
scene.progressRef.current = 0.26;
advance(3000);
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6);
});
it("lite: tells the legend every state it builds", async () => {
const scene = { ...props(), onEstado: jest.fn() };
await mount(<BlocosScene {...scene} tier="lite" />);
advance(1400 + 4000 + 1200 + 500);
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
0,
null,
1,
]);
});
it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} />);
advance(2500);
expect(uniform("uCursorPeso")).toBe(0);
// o centro do hospedeiro cai no centro da peça
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(1000);
expect(uniform("uCursorPeso")).toBeGreaterThan(0.9);
const cursor = drawnMesh().material.uniforms.uCursor.value as {
x: number;
y: number;
};
expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5);
// perto do canto de cima à esquerda, longe da peça, o ponto vai junto
fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 }));
advance(1000);
expect(cursor.x).toBeLessThan(-3);
expect(cursor.y).toBeGreaterThan(2);
fire(new PointerEvent("pointerout", { relatedTarget: null }));
advance(1500);
expect(uniform("uCursorPeso")).toBeLessThan(0.01);
});
it("gives lite the resting life on the pulse, without the cursor", async () => {
hostOf800x600();
await mount(<BlocosScene {...props()} tier="lite" />);
advance(2500);
expect(uniform("uVida")).toBeGreaterThan(0.9);
const tempo = uniform("uTempo");
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
advance(500);
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
expect(uniform("uCursorPeso")).toBe(0);
});
it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => {
const removed = jest.spyOn(window, "removeEventListener");
const scene = props();
const { container, unmount } = await mount(<BlocosScene {...scene} />);
advance(20);
const mesh = drawnMesh();
const disposed = [
jest.spyOn(mesh.geometry, "dispose"),
jest.spyOn(mesh.material, "dispose"),
jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"),
jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"),
];
const canvasRemoved = jest.spyOn(
mockRenderers[0].domElement,
"removeEventListener",
);
unmount();
for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
expect(jest.getTimerCount()).toBe(0);
expect(removed.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining(["resize", "pointermove", "pointerout"]),
);
expect(canvasRemoved).toHaveBeenCalledWith(
"webglcontextlost",
expect.any(Function),
);
const drawn = mockRenderers[0].render.mock.calls.length;
advance(1000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("tells the page once when the WebGL context is lost", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
it("gives up once and remembers it when the entry frames average more than 28 ms", async () => {
// quadros de 40 ms: um aparelho que não segura a cena
jest
.spyOn(window, "requestAnimationFrame")
.mockImplementation(
(callback) =>
setTimeout(
() => callback(performance.now()),
40,
) as unknown as number,
);
jest
.spyOn(window, "cancelAnimationFrame")
.mockImplementation((id) => clearTimeout(id));
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(isKnownSlowScene()).toBe(true);
const drawn = mockRenderers[0].render.mock.calls.length;
expect(drawn).toBeLessThanOrEqual(62);
advance(2000);
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("keeps a device that holds 60 fps, without remembering anything", async () => {
const scene = props();
await mount(<BlocosScene {...scene} />);
advance(3000);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSlowScene()).toBe(false);
});
});
describe("BlocosScene: software WebGL", () => {
it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => {
mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
});
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11)";
const scene = props();
const { container } = await mount(<BlocosScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
-919
View File
@@ -1,919 +0,0 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import {
type BufferGeometry,
Euler,
InstancedBufferAttribute,
InstancedBufferGeometry,
MathUtils,
Matrix3,
Matrix4,
Mesh,
PerspectiveCamera,
Plane,
Ray,
Scene,
ShaderMaterial,
SRGBColorSpace,
type Texture,
TextureLoader,
Vector2,
Vector3,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import {
type Blocos,
caixaDosEstados,
cicloAt,
createFrameBudget,
type Estado,
type FrameVerdict,
framePiece,
papeis,
parseBlocos,
type Rect,
seguir,
stateAt,
} from "@/lib/blocos";
import {
isSoftwareWebGL,
rememberSlowScene,
rememberSoftwareWebGL,
} from "@/lib/device";
import { startFramePulse } from "./FramePulse";
export type BlocosSceneProps = {
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
progressRef: RefObject<number>;
visible: boolean;
onReady: () => void;
onError: () => void;
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
tier?: "full" | "lite";
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
placementRef?: RefObject<Rect | null>;
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
stageRef?: RefObject<Rect | null>;
/**
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
*/
onEstado?: (estado: number | null) => void;
};
/*
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
*/
type Assets = {
bloco: BufferGeometry;
blocos: Blocos;
porcelana: Texture;
lima: Texture;
};
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
const ENTRADA = 1.4;
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
const FICA = 4;
const TROCA = 1.2;
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
const FICA_TOPO = 3;
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
const TOPO = 0.01;
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
const SEGUE = 2;
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
const FRENTE = { yaw: 0.24, pitch: 0.12 };
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
const DISTANCIA = 40;
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
const VERTEX = /* glsl */ `
#define ATRASO 0.35
attribute vec4 aPos0;
attribute vec4 aPos1;
attribute vec4 aPos2;
attribute vec4 aPos3;
attribute vec3 aEsc0;
attribute vec3 aEsc1;
attribute vec3 aEsc2;
attribute vec3 aEsc3;
attribute vec3 aEntrada;
attribute float aSemente;
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
attribute vec4 aPapel;
uniform float uFrom;
uniform float uTo;
uniform float uT;
uniform float uIntro;
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
uniform float uVida;
uniform float uEstado;
uniform float uTempo;
uniform vec3 uBotao;
uniform float uChao;
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
uniform vec2 uCursor;
uniform float uCursorPeso;
varying vec3 vNormal;
varying float vTom;
// posição (xyz) e tom (w) do bloco no estado i
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
float encaixe(float t) {
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
return 1.0 - pow(1.0 - k, 3.0);
}
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
float pulo(float u) {
if (u <= 0.0 || u >= 1.0) return 0.0;
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
}
// a vida em repouso do estado: muda posição, escala e tom do bloco
void vida(inout vec3 p, inout vec3 s, inout float tom) {
if (uEstado < 0.5) {
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
} else if (uEstado < 1.5) {
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
p = uBotao + (p - uBotao) * k;
s *= k;
} else if (uEstado < 2.5) {
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
} else if (aPapel.w >= 0.0) {
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
p.z = uChao + (p.z - uChao) * k;
s.z *= k;
}
}
void main() {
vec4 a = lugar(uFrom);
vec4 b = lugar(uTo);
vec3 ea = escala(uFrom);
vec3 eb = escala(uTo);
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
float e = encaixe(uT);
vec3 p = mix(pa, pb, e);
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
vec3 s = mix(ea, eb, e);
vTom = mix(a.w, b.w, e);
if (uVida > 0.0) vida(p, s, vTom);
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
float ei = encaixe(uIntro);
p += aEntrada * (1.0 - ei);
s *= mix(0.8, 1.0, ei);
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
// escala não uniforme: a normal vai pela inversa da escala
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
gl_Position = projectionMatrix * mv;
}
`;
const FRAGMENT = /* glsl */ `
uniform sampler2D uPorcelana;
uniform sampler2D uLima;
uniform mat3 uLuz;
varying vec3 vNormal;
varying float vTom;
void main() {
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
vec3 n = uLuz * normalize(vNormal);
vec2 uv = n.xy * 0.495 + 0.5;
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
#include <colorspace_fragment>
}
`;
let assets: Promise<Assets> | null = null;
async function loadAssets(): Promise<Assets> {
const textures = new TextureLoader();
const [gltf, blocos, porcelana, lima] = await Promise.all([
new GLTFLoader().loadAsync("/3d/bloco.glb"),
fetch("/3d/blocos.json").then((response) => {
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
return response.json().then(parseBlocos);
}),
textures.loadAsync("/3d/matcap-porcelana.webp"),
textures.loadAsync("/3d/matcap-lima.webp"),
]);
let bloco: BufferGeometry | undefined;
gltf.scene.traverse((object) => {
if (!bloco && object instanceof Mesh) bloco = object.geometry;
});
if (!bloco) throw new Error("bloco.glb sem malha");
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
porcelana.colorSpace = SRGBColorSpace;
lima.colorSpace = SRGBColorSpace;
return { bloco, blocos, porcelana, lima };
}
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
function loadBlocos() {
assets ??= loadAssets().catch((error) => {
assets = null;
throw error;
});
return assets;
}
/**
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
* Nunca rejeita: uma falha aparece ao montar (`onError`).
*/
export function prepareBlocos(): Promise<void> {
return loadBlocos().then(
() => undefined,
() => undefined,
);
}
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
function sorteio(bloco: number, canal: number) {
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
h ^= h >>> 13;
return (h >>> 0) / 4294967296;
}
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
const { n, estados } = blocos;
const geometry = new InstancedBufferGeometry();
geometry.index = bloco.index;
geometry.setAttribute("position", bloco.getAttribute("position"));
geometry.setAttribute("normal", bloco.getAttribute("normal"));
geometry.instanceCount = n;
estados.forEach((dados, e) => {
const lugar = new Float32Array(n * 4);
const escala = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
for (let k = 0; k < 3; k++) {
lugar[i * 4 + k] = dados[i * 7 + k];
escala[i * 3 + k] = dados[i * 7 + 3 + k];
}
lugar[i * 4 + 3] = dados[i * 7 + 6];
}
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
});
const semente = new Float32Array(n);
const entrada = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
semente[i] = sorteio(i, 0);
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
const x = sorteio(i, 1) - 0.5;
const y = sorteio(i, 2) - 0.5;
const z = sorteio(i, 3);
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
entrada[i * 3] = x * k;
entrada[i * 3 + 1] = y * k;
entrada[i * 3 + 2] = z * k;
}
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
const { vida, botao, chao } = papeis(blocos);
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
// a visão desfeita da pose da peça (ver o fragment shader)
const luz = new Matrix3();
const material = new ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms: {
uFrom: { value: 0 },
uTo: { value: 0 },
uT: { value: 0 },
uIntro: { value: 0 },
uVida: { value: 0 },
uEstado: { value: 0 },
uTempo: { value: 0 },
uBotao: { value: new Vector3(...botao) },
uChao: { value: chao },
uCursor: { value: new Vector2() },
uCursorPeso: { value: 0 },
uPorcelana: { value: porcelana },
uLima: { value: lima },
uLuz: { value: luz },
},
});
const mesh = new Mesh(geometry, material);
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
mesh.frustumCulled = false;
const scene = new Scene();
scene.add(mesh);
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
camera.position.set(0, 0, DISTANCIA);
// o raio do ponteiro sai dela antes do primeiro desenho
camera.updateMatrixWorld();
return {
scene,
camera,
mesh,
geometry,
material,
porcelana,
lima,
luz,
pose: new Euler(),
rotation: new Matrix4(),
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
inversa: new Matrix4(),
raio: new Ray(),
mira: new Vector3(),
};
}
type World = ReturnType<typeof buildWorld>;
type Tier = NonNullable<BlocosSceneProps["tier"]>;
type SceneState = {
/** Progresso amortecido (`full`). */
progress: number;
/** Entrada, de 0 a 1. */
intro: number;
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
clock: number;
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
quieto: number;
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
ultimoAlvo: number;
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
mostrado: Estado | null;
fonte: "ciclo" | "rolagem";
preso: boolean;
pointer: Vector2;
tilt: Vector2;
frames: number;
/**
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
* quadro: o balanço não salta quando o desenho volta depois de parado).
*/
vida: number;
vidaEstado: number;
vidaTempo: number;
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
cursor: Vector2;
cursorPeso: number;
};
type StepInput = {
tier: Tier;
target: number;
placement: Rect | null;
stage: Rect | null;
host: { width: number; height: number };
/** Tempo real desde o quadro anterior (ms). */
delta: number;
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
cursor: Vector2 | null;
};
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
function pesoMarca({ from, to, t }: Estado) {
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
}
/**
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
* cursor, e escreve os uniforms.
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
*/
function stepBlocos(world: World, s: SceneState, input: StepInput) {
const { tier, target, placement, stage, host, delta } = input;
const dt = Math.min(delta / 1000, 1 / 20);
s.intro = Math.min(1, s.intro + dt / ENTRADA);
let settling = s.intro < 1;
let wait = 0;
let pedido: Estado;
let fonte: SceneState["fonte"] = "ciclo";
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
const real = Math.min(delta / 1000, FICA + TROCA);
if (tier === "full") {
// rolagem amortecida: a peça segue o dedo com um leve atraso
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
if (Math.abs(target - s.progress) > 4e-4) settling = true;
else s.progress = target;
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
// s) assume; qualquer rolagem devolve o comando à rolagem
const noTopo =
s.intro >= 1 &&
target < TOPO &&
target === s.ultimoAlvo &&
s.progress === target;
s.ultimoAlvo = target;
s.quieto = noTopo ? s.quieto + real : 0;
if (s.quieto > 0) {
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
pedido = ciclo;
if (ciclo.wait === 0) settling = true;
else wait = ciclo.wait;
} else {
pedido = stateAt(s.progress);
fonte = "rolagem";
}
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
} else {
if (s.intro >= 1) s.clock += real;
const lite = cicloAt(s.clock, FICA, TROCA);
pedido = lite;
if (lite.wait === 0) settling = true;
else wait = lite.wait;
}
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
if (fonte !== s.fonte) s.preso = false;
s.fonte = fonte;
let estado = pedido;
if (s.mostrado && !s.preso) {
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
s.preso =
estado.from === pedido.from &&
estado.to === pedido.to &&
estado.t === pedido.t;
if (!s.preso) settling = true;
}
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
const u = world.material.uniforms;
u.uFrom.value = estado.from;
u.uTo.value = estado.to;
u.uT.value = estado.t;
u.uIntro.value = s.intro;
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
const w = pesoMarca(estado);
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
const fora =
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
let area = stage;
if (tier === "full" && placement) {
const topo = caixaDosEstados(placement, host);
const fim = stage ?? { x: 0, y: 0, ...host };
area = {
x: MathUtils.lerp(topo.x, fim.x, fora),
y: MathUtils.lerp(topo.y, fim.y, fora),
width: MathUtils.lerp(topo.width, fim.width, fora),
height: MathUtils.lerp(topo.height, fim.height, fora),
};
}
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
const k =
host.height /
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
const frame = framePiece(host, placement, w, area);
const x = (frame.x - host.width / 2) / k;
const y = (host.height / 2 - frame.y) / k;
world.mesh.position.set(x, y, 0);
world.mesh.scale.setScalar(frame.scale / k);
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
const pose = world.pose.set(
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
0,
);
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
world.mesh.rotation.set(
pose.x - s.tilt.y * 0.06,
pose.y + sway + s.tilt.x * 0.1,
0,
);
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
world.luz
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
.transpose();
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
const parado = estado.from === estado.to && s.intro >= 1;
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
s.vidaTempo += dt;
u.uVida.value = s.vida;
u.uEstado.value = s.vidaEstado;
u.uTempo.value = s.vidaTempo;
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
let mira = 0;
if (input.cursor) {
world.mesh.updateMatrixWorld();
const { raio, camera } = world;
raio.origin.setFromMatrixPosition(camera.matrixWorld);
raio.direction
.set(
(input.cursor.x / host.width) * 2 - 1,
1 - (input.cursor.y / host.height) * 2,
0.5,
)
.unproject(camera)
.sub(raio.origin);
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
}
if (mira) {
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
}
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
u.uCursor.value.copy(s.cursor);
u.uCursorPeso.value = s.cursorPeso * s.vida;
s.frames += 1;
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
const assentada = estado.from === estado.to && estado.t === 0;
const legenda =
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
return { again: settling, wait, ready: s.frames === 2, legenda };
}
/** Solta o contexto WebGL e tira o canvas da página. */
function disposeRenderer(renderer: WebGLRenderer) {
renderer.dispose();
renderer.forceContextLoss();
renderer.domElement.remove();
}
/**
* O renderizador transparente, com antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
* contexto não abre ou se ele desenha por software (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
*/
function createRenderer(host: HTMLElement, tier: Tier) {
const renderer = new WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: "high-performance",
});
if (isSoftwareWebGL(renderer.getContext())) {
rememberSoftwareWebGL();
disposeRenderer(renderer);
throw new Error("WebGL por software");
}
renderer.setPixelRatio(
Math.min(
tier === "lite" ? 1.5 : 1.75,
Math.max(1, window.devicePixelRatio || 1),
),
);
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
host.append(canvas);
return renderer;
}
type Parts = { renderer: WebGLRenderer; world: World };
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
function disposeParts({ renderer, world }: Parts) {
world.geometry.dispose();
world.material.dispose();
world.porcelana.dispose();
world.lima.dispose();
disposeRenderer(renderer);
}
type Callbacks = Pick<
BlocosSceneProps,
"progressRef" | "placementRef" | "stageRef" | "onEstado"
> & {
onReady: () => void;
onError: () => void;
};
export type BlocosEngine = {
setVisible: (visible: boolean) => void;
dispose: () => void;
};
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
function startBlocos(
host: HTMLElement,
parts: Parts,
{
progressRef,
placementRef,
stageRef,
onReady,
onError,
onEstado,
}: Callbacks,
tier: Tier,
): BlocosEngine {
const { renderer, world } = parts;
const canvas = renderer.domElement;
const s: SceneState = {
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
progress: progressRef.current ?? 0,
intro: 0,
clock: 0,
quieto: 0,
ultimoAlvo: progressRef.current ?? 0,
mostrado: null,
fonte: "rolagem",
preso: true,
pointer: new Vector2(),
tilt: new Vector2(),
frames: 0,
vida: 0,
vidaEstado: 0,
vidaTempo: 0,
cursor: new Vector2(),
cursorPeso: 0,
};
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
let mouse: Vector2 | null = null;
const noHost = new Vector2();
const size = { width: 1, height: 1 };
const budget = createFrameBudget();
let verdict: FrameVerdict = "pending";
let alive = true;
let visible = false;
let raf = 0;
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
let chained = false;
let last = 0;
let wake: ReturnType<typeof setTimeout> | undefined;
let stopPulse: (() => void) | undefined;
/** O último estado avisado para a legenda. */
let legenda: number | null = null;
const request = (fromFrame: boolean) => {
if (raf || !alive || !visible) return;
chained = fromFrame;
raf = requestAnimationFrame(render);
};
const invalidate = () => request(false);
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
const halt = () => {
cancelAnimationFrame(raf);
raf = 0;
clearTimeout(wake);
wake = undefined;
stopPulse?.();
stopPulse = undefined;
last = 0;
};
const fail = () => {
if (!alive) return;
alive = false;
halt();
onError();
};
const draw = (now: number) => {
const delta = last ? now - last : 1000 / 60;
last = now;
if (chained) verdict = budget(delta);
if (verdict === "slow") {
rememberSlowScene();
fail();
return;
}
let cursor: Vector2 | null = null;
if (mouse) {
const r = host.getBoundingClientRect();
noHost.set(mouse.x - r.left, mouse.y - r.top);
if (
noHost.x >= 0 &&
noHost.y >= 0 &&
noHost.x <= r.width &&
noHost.y <= r.height
)
cursor = noHost;
}
const step = stepBlocos(world, s, {
tier,
target: progressRef.current ?? 0,
placement: placementRef?.current ?? null,
stage: stageRef?.current ?? null,
host: size,
delta,
cursor,
});
renderer.render(world.scene, world.camera);
if (step.ready) onReady();
if (step.legenda !== legenda) {
legenda = step.legenda;
onEstado?.(legenda);
}
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
if (step.again || verdict === "pending") request(true);
else if (step.wait) {
clearTimeout(wake);
wake = setTimeout(invalidate, step.wait * 1000);
}
};
const render = (now: number) => {
raf = 0;
if (!alive) return;
try {
draw(now);
} catch {
fail();
}
};
const resize = () => {
size.width = Math.max(1, host.clientWidth);
size.height = Math.max(1, host.clientHeight);
renderer.setSize(size.width, size.height, false);
world.camera.aspect = size.width / size.height;
world.camera.updateProjectionMatrix();
invalidate();
};
const onPointer = (event: PointerEvent) => {
if (event.pointerType !== "mouse") return;
s.pointer.set(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
);
mouse ??= new Vector2();
mouse.set(event.clientX, event.clientY);
};
// o mouse saiu da janela: os blocos voltam
const onLeave = (event: PointerEvent) => {
if (!event.relatedTarget) mouse = null;
};
const onLost = (event: Event) => {
event.preventDefault();
fail();
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
observer?.observe(host);
if (!observer) window.addEventListener("resize", resize);
if (tier === "full") {
window.addEventListener("pointermove", onPointer, { passive: true });
window.addEventListener("pointerout", onLeave, { passive: true });
}
canvas.addEventListener("webglcontextlost", onLost);
resize();
return {
setVisible(next) {
visible = next;
if (!next || !alive) return halt();
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
stopPulse ??= startFramePulse(invalidate);
},
dispose() {
alive = false;
halt();
observer?.disconnect();
window.removeEventListener("resize", resize);
window.removeEventListener("pointermove", onPointer);
window.removeEventListener("pointerout", onLeave);
canvas.removeEventListener("webglcontextlost", onLost);
disposeParts(parts);
},
};
}
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
/**
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
* software ou se o shader não compila.
*/
export async function mountBlocos(
host: HTMLElement,
callbacks: Callbacks,
tier: Tier,
isAlive: () => boolean,
): Promise<BlocosEngine | null> {
const loaded = await loadBlocos();
await nextTask();
if (!isAlive()) return null;
const renderer = createRenderer(host, tier);
let parts: Parts | undefined;
try {
await nextTask();
if (!isAlive()) {
disposeRenderer(renderer);
return null;
}
parts = { renderer, world: buildWorld(loaded) };
await renderer.compileAsync(parts.world.scene, parts.world.camera);
await nextTask();
} catch (error) {
if (parts) disposeParts(parts);
else disposeRenderer(renderer);
throw error;
}
if (!isAlive()) {
disposeParts(parts);
return null;
}
return startBlocos(host, parts, callbacks, tier);
}
export default function BlocosScene({
progressRef,
visible,
onReady,
onError,
tier = "full",
placementRef,
stageRef,
onEstado,
}: BlocosSceneProps) {
const hostRef = useRef<HTMLDivElement>(null);
const engine = useRef<BlocosEngine | null>(null);
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
const callbacks = useRef({ onReady, onError, onEstado });
useEffect(() => {
callbacks.current = { onReady, onError, onEstado };
});
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
const wantsVisible = useRef(visible);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let alive = true;
mountBlocos(
host,
{
progressRef,
placementRef,
stageRef,
onReady: () => callbacks.current.onReady(),
onEstado: (estado) => callbacks.current.onEstado?.(estado),
onError: () => callbacks.current.onError(),
},
tier,
() => alive,
)
.then((created) => {
if (!created) return;
if (!alive) return created.dispose();
engine.current = created;
created.setVisible(wantsVisible.current);
})
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
.catch(() => {
if (alive) callbacks.current.onError();
});
return () => {
alive = false;
engine.current?.dispose();
engine.current = null;
};
}, [progressRef, placementRef, stageRef, tier]);
useEffect(() => {
wantsVisible.current = visible;
engine.current?.setVisible(visible);
}, [visible]);
return (
<div
ref={hostRef}
aria-hidden="true"
style={{ width: "100%", height: "100%" }}
/>
);
}
@@ -41,11 +41,11 @@ describe("CinematicHome: dados estruturados", () => {
expect(container.firstElementChild).toBe(scripts[0]);
});
it("describes only the five offers as Service nodes tied to the person, without repeating what the layout emits", () => {
it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
const [data] = structuredData();
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(5);
expect(data["@graph"]).toHaveLength(3);
for (const offer of data["@graph"]) {
expect(offer["@type"]).toBe("Service");
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
* vem do layout) e a ligação dos hooks.
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
* As cenas e os hooks são trocados por dublês que registram o que recebem.
*/
@@ -75,6 +75,59 @@ beforeEach(() => {
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
});
describe("CinematicHome: endereço do trabalho em destaque", () => {
it("path: the featured project (Lavação 047) is in the list with its own domain -> that domain goes to the hero", () => {
render(
<CinematicHome
projects={[
project("sev-modaintima", "https://sevmodaintima.com.br"),
project("lavacao-047", "https://www.lavacao047.com.br/"),
]}
/>,
);
expect(lastHeroProps().workUrl).toEqual({
text: "lavacao047.com.br",
secure: true,
});
});
it("path: the featured project is in the list but has no site -> no address", () => {
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project is not in the list -> no address", () => {
render(
<CinematicHome
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
render(
<CinematicHome
projects={[
project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
]}
/>,
);
expect(lastHeroProps().workUrl).toBeNull();
});
it("path: empty project list -> no address and the page still renders", () => {
render(<CinematicHome projects={[]} />);
expect(lastHeroProps().workUrl).toBeNull();
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
});
});
describe("CinematicHome: props e estrutura", () => {
it("path: every optional prop given -> each scene receives its own data", () => {
const projects = [project("lavacao-047", "https://a.example.com")];
+13 -1
View File
@@ -8,10 +8,14 @@ import Contact from "../Contact";
import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects";
import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import CinematicMain from "./CinematicMain";
/** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
const HERO_WORK_PROJECT = "lavacao-047";
/**
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
@@ -40,11 +44,19 @@ export default function CinematicHome({
clients?: ClientLink[];
testimonials?: Testimonial[];
}) {
const shownUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
?.demoUrl,
});
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
const heroWorkUrl =
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
return (
<>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<CinematicMain>
<Hero stats={heroStats} clients={clients} />
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Projects projects={projects} />
@@ -24,19 +24,16 @@ function MockScene(props: SceneProps) {
return require("react").createElement("canvas", { "data-testid": "scene" });
}
jest.mock("./BlocosScene", () => ({
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
get default() {
if (mockImportFails) throw new Error("chunk indisponível");
return MockScene;
},
get prepareBlocos() {
get prepareField() {
if (mockImportFails) throw new Error("chunk indisponível");
return () => {
mockPrepare();
return Promise.resolve();
};
return () => mockPrepare();
},
}));
@@ -348,7 +345,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 () => {
mockNavigator({ hardwareConcurrency: 4 });
mockMedia(["(pointer: coarse)"]);
const probe = jest.fn(() => ({ getExtension: () => null }));
setContext(probe);
const addListener = jest.spyOn(window, "addEventListener");
@@ -542,7 +539,7 @@ describe("HeroField: rolagem e visibilidade", () => {
});
describe("HeroField: navegador sem matchMedia", () => {
it("path: no matchMedia at all -> the dive is not started, but the scene still mounts", async () => {
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
window.matchMedia = undefined as unknown as typeof window.matchMedia;
render(<Hero />);
+12 -183
View File
@@ -1,22 +1,18 @@
import { act, render, screen } from "@testing-library/react";
import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device";
import { rememberSoftwareWebGL } from "@/lib/device";
import HeroField, { DIVE_QUERY } from "./HeroField";
type SceneProps = {
visible: boolean;
progressRef: { current: number };
placementRef?: { current: unknown };
stageRef?: { current: unknown };
tier?: string;
labelsRef?: unknown;
onReady: () => void;
onError: () => void;
onEstado?: (estado: number | null) => void;
};
const mockScenes: SceneProps[] = [];
const mockPrepare = jest.fn();
jest.mock("./BlocosScene", () => ({
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
default: function MockScene(props: SceneProps) {
mockScenes.push(props);
@@ -24,10 +20,7 @@ jest.mock("./BlocosScene", () => ({
"data-testid": "scene",
});
},
prepareBlocos: () => {
mockPrepare();
return Promise.resolve();
},
prepareField: () => mockPrepare(),
}));
const original = {
@@ -54,41 +47,14 @@ function mockNavigator(values: Record<string, unknown>) {
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
/** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */
function Hero() {
return (
<section className="hero" style={HERO_TAIL}>
<div
className="hero-stage"
style={{ "--site-header-height": "60px" } as React.CSSProperties}
>
<HeroField />
<div className="hero-piece" />
</div>
<HeroField />
</section>
);
}
const STAGE = { left: 0, top: -40, width: 1440, height: 900 };
const PIECE = { left: 760, top: 110, width: 600, height: 620 };
/** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */
const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 };
/** A caixa da peça medida a partir do palco. */
const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 };
function mockLayout() {
jest
.spyOn(Element.prototype, "getBoundingClientRect")
.mockImplementation(function (this: Element) {
const box = this.classList.contains("hero-stage")
? STAGE
: this.classList.contains("hero-piece")
? PIECE
: { left: 0, top: 0, width: 0, height: 0 };
return { ...box, x: box.left, y: box.top } as DOMRect;
});
}
const section = () => document.querySelector(".hero") as HTMLElement;
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
@@ -117,7 +83,6 @@ async function idle() {
beforeEach(() => {
jest.useFakeTimers();
mockLayout();
sessionStorage.clear();
mockScenes.length = 0;
mockPrepare.mockClear();
@@ -136,7 +101,6 @@ beforeEach(() => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
sessionStorage.clear();
window.matchMedia = original.matchMedia;
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
@@ -144,7 +108,7 @@ afterEach(() => {
HTMLCanvasElement.prototype.getContext = original.getContext;
});
describe("HeroField (the 3D blocks over the hero poster)", () => {
describe("HeroField (ambient background)", () => {
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
render(<Hero />);
expect(screen.queryByTestId("scene")).toBeNull();
@@ -211,13 +175,7 @@ describe("HeroField (the 3D blocks over the hero poster)", () => {
});
it.each<[string, () => void]>([
[
"a weak phone (touch with 5 cores)",
() => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 5 });
},
],
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
@@ -229,14 +187,13 @@ describe("HeroField (the 3D blocks over the hero poster)", () => {
undefined;
},
],
// os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
[
"a software WebGL found earlier in the session",
() => rememberSoftwareWebGL(),
],
["a slow scene found earlier in the session", () => rememberSlowScene()],
])(
"keeps only the poster (no WebGL, no dive, no scroll work) with %s",
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
async (_label, arrange) => {
arrange();
const { container } = render(<Hero />);
@@ -254,7 +211,7 @@ describe("HeroField (the 3D blocks over the hero poster)", () => {
},
);
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p", async () => {
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 () => {
const { container } = render(<Hero />);
const hero = container.querySelector(".hero") as HTMLElement;
expect(hero).toHaveAttribute("data-dive");
@@ -280,13 +237,15 @@ describe("HeroField (the 3D blocks over the hero poster)", () => {
scroll(700); // meio do mergulho (1400 px de trilho)
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
// a máscara já se abriu por completo: o fundo aparece inteiro
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
scroll(1400);
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
expect(mockScenes.at(-1)?.visible).toBe(false);
});
it("does not start the dive on a short screen (the CSS does not apply there), but the scene still runs", async () => {
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
mockMedia([]);
render(<Hero />);
@@ -318,134 +277,4 @@ describe("HeroField (the 3D blocks over the hero poster)", () => {
expect(section()).not.toHaveAttribute("data-dive");
expect(section().style.getPropertyValue("--hero-p")).toBe("");
});
it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => {
const { container } = render(<Hero />);
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("full");
expect(scene?.placementRef?.current).toEqual(PLACEMENT);
const area = scene?.stageRef?.current as typeof STAGE_AREA;
for (const key of ["x", "y", "width", "height"] as const)
expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6);
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "full");
expect(field.getAttribute("style")).toBeNull();
});
it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
expect(section()).not.toHaveAttribute("data-dive");
await idle();
const scene = mockScenes.at(-1);
expect(scene?.tier).toBe("lite");
expect(scene?.placementRef).toBeUndefined();
expect(scene?.stageRef).toBeUndefined();
const field = container.querySelector(".hero-field") as HTMLElement;
expect(field).toHaveAttribute("data-tier", "lite");
expect(field.style).toMatchObject({
left: "760px",
top: "150px",
width: "600px",
height: "620px",
});
expect(section()).not.toHaveAttribute("data-dive");
expect(scrollWritesProgress()).toBe(false);
});
it("lite: falls back to the poster when the scene gives up", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
act(() => mockScenes.at(-1)?.onError());
const field = container.querySelector(".hero-field") as HTMLElement;
expect(screen.queryByTestId("scene")).toBeNull();
expect(field).not.toHaveAttribute("data-ready");
expect(field).not.toHaveAttribute("data-tier");
// de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha
expect([field.style.left, field.style.width]).toEqual(["", ""]);
});
/** A legenda da peça, se existir. */
const caption = (container: HTMLElement) =>
container.querySelector<HTMLElement>(".hero-legenda");
const tell = (estado: number | null) =>
act(() => mockScenes.at(-1)?.onEstado?.(estado));
it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => {
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(0);
expect(caption(container)).toBeNull();
for (const [estado, texto] of [
[1, "Sites e sistemas"],
[2, "Integrações"],
[3, "Painéis e dados"],
] as const) {
tell(estado);
const legenda = caption(container) as HTMLElement;
expect(legenda).toHaveTextContent(texto);
expect(legenda).toHaveAttribute("data-shown");
}
const legenda = caption(container) as HTMLElement;
// 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22%
// da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2)
// (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam)
expect(Number.parseFloat(legenda.style.left)).toBeCloseTo(
892 + (468 * (1 - 14.5 / 17)) / 2,
3,
);
expect(Number.parseFloat(legenda.style.top)).toBeCloseTo(
460 + (468 * 12) / 17 / 2 + 12,
3,
);
// dentro do campo decorativo
expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull();
// cada texto novo é digitado (.ds-type, um passo por caractere)
const typed = legenda.querySelector<HTMLElement>(".ds-type");
expect(typed?.style.getPropertyValue("--n")).toBe("15");
// de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga
tell(0);
expect(caption(container)).not.toHaveAttribute("data-shown");
tell(2);
tell(null);
expect(caption(container)).not.toHaveAttribute("data-shown");
});
it("shows no caption before the scene is ready, nor after it gives up", async () => {
const { container } = render(<Hero />);
await idle();
tell(1);
expect(caption(container)).not.toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onReady());
expect(caption(container)).toHaveAttribute("data-shown");
act(() => mockScenes.at(-1)?.onError());
expect(caption(container)).toBeNull();
});
it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => {
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
const { container } = render(<Hero />);
await idle();
act(() => mockScenes.at(-1)?.onReady());
tell(1);
expect(caption(container)?.style).toMatchObject({
left: "0px",
top: "632px",
});
expect(caption(container)).toHaveClass("max-lg:hidden");
});
});
+42 -190
View File
@@ -2,21 +2,13 @@
import {
type ComponentType,
type CSSProperties,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos";
import {
isKnownSlowScene,
isKnownSoftwareWebGL,
type SceneTier,
sceneTier,
} from "@/lib/device";
import type { BlocosSceneProps } from "./BlocosScene";
import "./hero-legenda.css";
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
import type { HeroFieldSceneProps } from "./HeroFieldScene";
/** 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 =
@@ -36,13 +28,6 @@ function heroTailScreens(section: HTMLElement) {
const IDLE_SLACK_MS = 20;
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
const IDLE_PATIENCE_MS = 4000;
/** Distância (px) entre a caixa da peça e a legenda abaixo dela. */
const LEGENDA_GAP = 12;
/**
* Recuo da legenda no full, em fração da largura da caixa dos estados: tela, integrações e dados têm até 14,5 das 17
* unidades dela, centrados (3d/blocos.py), e a legenda começa onde eles começam.
*/
const LEGENDA_RECUO = (1 - 14.5 / 17) / 2;
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
const FIRST_ACTIVITY = [
"pointerdown",
@@ -53,10 +38,15 @@ const FIRST_ACTIVITY = [
"scroll",
] as const;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
/**
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
* no contexto que ela abre para desenhar (createRenderer em BlocosScene); o veredito "por software" fica anotado na
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
*/
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
@@ -101,86 +91,21 @@ function whenIdle(run: () => void) {
}
/**
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, os ativos (bloco, estados e
* matcaps, baixados num intervalo ocioso) e só então o canvas. Devolve `null` se a tela foi desmontada no meio do
* caminho.
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
*/
async function loadScene(isAlive: () => boolean) {
const scene = await import("./BlocosScene");
const scene = await import("./HeroFieldScene");
await nextIdle();
if (!isAlive()) return null;
await scene.prepareBlocos();
scene.prepareField();
await nextIdle();
return isAlive() ? scene.default : null;
}
/**
* Mede, em relação ao palco que a cena cobre (o pai do elemento raiz, onde ele fica com `inset: 0`), a peça do hero
* (o pôster, `.hero-piece`) e a área útil do mergulho: 6% a 94% da largura, do cabeçalho (`--site-header-height`) mais
* 24 px até 70% da altura, onde começam as legendas e a faixa escura delas. Mede na montagem, quando a entrada da peça
* acaba (`.hero-in` anima o deslocamento) e a cada mudança de tamanho do hero, um quadro depois. Sem a peça, `null`.
* Diz também o tamanho do palco. Devolve a função que desfaz tudo.
*/
function trackPiece(
root: HTMLElement,
section: HTMLElement,
onChange: (
piece: Rect | null,
stage: Rect | null,
size: { width: number; height: number },
) => void,
) {
let frame = 0;
const measure = () => {
frame = 0;
const piece = section.querySelector<HTMLElement>(".hero-piece");
const stage = root.parentElement;
if (!stage) return onChange(null, null, { width: 0, height: 0 });
const origin = stage.getBoundingClientRect();
const header =
Number.parseFloat(
getComputedStyle(stage).getPropertyValue("--site-header-height"),
) || 0;
const top = header + 24;
const area = {
x: origin.width * 0.06,
y: top,
width: origin.width * 0.88,
height: Math.max(0, origin.height * 0.7 - top),
};
const size = { width: origin.width, height: origin.height };
if (!piece) return onChange(null, area, size);
const box = piece.getBoundingClientRect();
onChange(
{
x: box.left - origin.left,
y: box.top - origin.top,
width: box.width,
height: box.height,
},
area,
size,
);
};
const queue = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue);
observer?.observe(section);
if (!observer) window.addEventListener("resize", queue);
section.addEventListener("animationend", queue);
measure();
return () => {
cancelAnimationFrame(frame);
observer?.disconnect();
window.removeEventListener("resize", queue);
section.removeEventListener("animationend", queue);
};
}
/**
* Liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho) e marca o hero com data-dive, que
* Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho) e --hero-open (0 em
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
*/
function trackDive(
@@ -204,6 +129,8 @@ function trackDive(
if (p === progress.current) return;
progress.current = p;
section.style.setProperty("--hero-p", p.toFixed(4));
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
onDone(p >= 0.999);
};
const queue = () => {
@@ -218,6 +145,7 @@ function trackDive(
window.removeEventListener("resize", queue);
delete section.dataset.dive;
section.style.removeProperty("--hero-p");
section.style.removeProperty("--hero-open");
};
}
@@ -240,71 +168,43 @@ function trackVisibility(
};
}
type Mounted = {
Scene: ComponentType<BlocosSceneProps>;
tier: Exclude<SceneTier, "static">;
};
/**
* A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o
* primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho
* por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do
* Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote,
* ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS):
* - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do
* hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das
* legendas.
* - `lite` (celular capaz): sem mergulho; o canvas ocupa só a caixa da peça e troca de estado sozinho.
* Com a cena pronta, uma legenda abaixo da caixa da peça diz o que ela está montando (`legendaDe`, avisada pela cena
* em `onEstado`): nenhuma na marca nem fora da caixa, no mergulho.
* O desenho por software e a cena lenta só se descobrem no contexto que a cena abre (nunca num contexto de teste na
* hidratação): ela avisa por `onError` e o hero volta ao pôster.
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
* é das molduras.
*/
export default function HeroField() {
const rootRef = useRef<HTMLDivElement>(null);
const progressRef = useRef(0);
const placementRef = useRef<Rect | null>(null);
// a área útil do mergulho (full): entre o cabeçalho e as legendas
const stageRef = useRef<Rect | null>(null);
const giveUp = useRef(() => {});
const [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>(
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
null,
);
const [legendaOn, setLegendaOn] = useState(false);
const [visible, setVisible] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
// fim do mergulho: a cena parou nos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
const [done, setDone] = useState(false);
useEffect(() => {
const tier = sceneTier();
if (tier === "static") return;
const root = rootRef.current;
const section = root?.closest<HTMLElement>(".hero");
if (!root || !section) return;
// sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, já na
// hidratação, sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai
// do contexto da cena, ocioso, sem uma tarefa longa na hidratação); se ela desistir, avisa e o hero volta ao pôster
if (!hasWebGL2() || isKnownSoftwareWebGL() || isKnownSlowScene()) return;
if (isLowPowerDevice()) return;
const section = rootRef.current?.closest<HTMLElement>(".hero");
if (!section) return;
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
let alive = true;
const stopDive =
tier === "full" && window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
? trackDive(section, progressRef, setDone)
: undefined;
const stopVisibility = trackVisibility(section, setVisible);
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
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
giveUp.current = () => {
stopDive?.();
if (alive) setFailed(true);
@@ -313,7 +213,7 @@ export default function HeroField() {
loadScene(() => alive)
.then((component) => {
if (!alive) return;
if (component) setMounted({ Scene: component, tier });
if (component) setScene(() => component);
else giveUp.current();
})
.catch(() => giveUp.current());
@@ -322,76 +222,28 @@ export default function HeroField() {
alive = false;
stopWaiting();
stopVisibility();
stopPiece();
stopDive?.();
};
}, []);
const handleReady = useCallback(() => setReady(true), []);
const handleError = useCallback(() => giveUp.current(), []);
const handleEstado = useCallback((estado: number | null) => {
const texto = legendaDe(estado);
setLegendaOn(texto !== null);
if (texto)
setLegenda((anterior) => ({ texto, vez: (anterior?.vez ?? 0) + 1 }));
}, []);
const scene = mounted && !failed ? mounted : null;
const lite = scene?.tier === "lite";
return (
<div
ref={rootRef}
aria-hidden="true"
className="hero-field"
data-tier={scene?.tier}
data-ready={ready && scene ? "" : undefined}
style={
lite && box
? {
left: box.x,
top: box.y,
width: box.width,
height: box.height,
}
: undefined
}
data-ready={ready && !failed ? "" : undefined}
>
{scene && (
<scene.Scene
{Scene && !failed && (
<Scene
progressRef={progressRef}
placementRef={lite ? undefined : placementRef}
stageRef={lite ? undefined : stageRef}
tier={scene.tier}
visible={visible && !done}
onReady={handleReady}
onError={handleError}
onEstado={handleEstado}
/>
)}
{/* abaixo de 1024 px a peça fica no canto, na linha do rótulo, e a legenda cairia sobre o título: só de 1024 em diante */}
{scene && legenda && box && (
<p
className="hero-legenda absolute flex max-lg:hidden items-center gap-sp-3 whitespace-nowrap font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]"
data-shown={ready && legendaOn ? "" : undefined}
style={
lite
? { left: 0, top: box.height + LEGENDA_GAP }
: {
left: box.x + box.width * LEGENDA_RECUO,
top: box.y + box.height + LEGENDA_GAP,
}
}
>
<span className="h-2 w-2 shrink-0 bg-[var(--slash)]" />
<span
key={legenda.vez}
className="ds-type"
style={{ "--n": legenda.texto.length } as CSSProperties}
>
{legenda.texto}
</span>
</p>
)}
</div>
);
}
@@ -0,0 +1,101 @@
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);
});
});
@@ -0,0 +1,957 @@
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);
});
});
@@ -0,0 +1,471 @@
import { act, render } from "@testing-library/react";
import { isKnownSoftwareWebGL } from "@/lib/device";
import HeroFieldScene from "./HeroFieldScene";
/*
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
* existe: o renderizador é um dublê.
*/
type RendererOptions = {
alpha?: boolean;
antialias?: boolean;
powerPreference?: string;
};
const mockRenderers: MockRenderer[] = [];
let mockFailToCreate = false;
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
let mockRendererName: string | null = null;
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
class MockRenderer {
domElement = document.createElement("canvas");
pixelRatio = 1;
options: RendererOptions;
render = jest.fn();
compileAsync = jest.fn(() => mockCompile());
dispose = jest.fn();
forceContextLoss = jest.fn();
setSize = jest.fn();
setPixelRatio = (value: number) => {
this.pixelRatio = value;
};
getPixelRatio = () => this.pixelRatio;
getContext = () => ({
getExtension: (name: string) =>
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
? { UNMASKED_RENDERER_WEBGL: 37446 }
: null,
getParameter: (parameter: number) =>
parameter === 37446 ? mockRendererName : null,
});
constructor(options: RendererOptions) {
if (mockFailToCreate) throw new Error("sem WebGL");
this.options = options;
mockRenderers.push(this);
}
}
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
jest.mock("three", () => ({
...jest.requireActual("three"),
WebGLRenderer: jest.fn(
(options: RendererOptions) => new MockRenderer(options),
),
}));
const props = () => ({
progressRef: { current: 0 },
visible: false,
onReady: jest.fn(),
onError: jest.fn(),
});
function setDevicePixelRatio(value: number) {
Object.defineProperty(window, "devicePixelRatio", {
configurable: true,
value,
});
}
beforeEach(() => {
jest.useFakeTimers();
mockRenderers.length = 0;
mockFailToCreate = false;
mockRendererName = null;
mockCompile = () => Promise.resolve();
setDevicePixelRatio(1);
});
afterEach(() => {
jest.useRealTimers();
});
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
const buildScene = (ms = 10) =>
act(async () => {
await jest.advanceTimersByTimeAsync(ms);
});
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
const nextTask = () =>
act(async () => {
await jest.runOnlyPendingTimersAsync();
});
async function mount(ui: React.ReactElement) {
const result = render(ui);
await buildScene();
return result;
}
describe("HeroFieldScene (ambient 3D)", () => {
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
const { container } = await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].options).toMatchObject({
alpha: true,
antialias: false,
powerPreference: "high-performance",
});
const canvas = container.querySelector("canvas");
expect(canvas).toBe(mockRenderers[0].domElement);
expect(canvas).toHaveAttribute("aria-hidden", "true");
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
});
it.each([
[0.5, 1],
[1, 1],
[1.25, 1.25],
[2, 1.5],
[3, 1.5],
])(
"caps the pixel ratio at 1.5 (device %s -> %s)",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
await mount(<HeroFieldScene {...props()} />);
expect(frames).toHaveBeenCalledTimes(1);
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
act(() => {
jest.advanceTimersByTime(5000);
});
const settled = frames.mock.calls.length;
expect(settled).toBeGreaterThan(2);
expect(settled).toBeLessThan(5000 / 16);
// hero fora de cena e tudo assentado: nenhum quadro a mais
act(() => {
jest.advanceTimersByTime(5000);
});
expect(frames.mock.calls.length).toBe(settled);
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
});
it("tells the page it is ready after the second frame", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onReady).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(100);
});
expect(scene.onReady).toHaveBeenCalledTimes(1);
});
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = await mount(
<HeroFieldScene {...scene} visible={false} />,
);
act(() => {
jest.advanceTimersByTime(6000);
});
const hidden = frames.mock.calls.length;
expect(jest.getTimerCount()).toBe(0);
rerender(<HeroFieldScene {...scene} visible />);
act(() => {
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
});
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
act(() => {
jest.advanceTimersByTime(980);
});
const shown = frames.mock.calls.length - hidden;
expect(shown).toBeGreaterThan(20);
expect(shown).toBeLessThanOrEqual(34);
rerender(<HeroFieldScene {...scene} visible={false} />);
const stopped = frames.mock.calls.length;
act(() => {
jest.advanceTimersByTime(2000);
});
expect(frames.mock.calls.length).toBe(stopped);
expect(jest.getTimerCount()).toBe(0);
});
it("releases the context, the canvas and the timers when it unmounts", async () => {
const scene = props();
const { container, unmount } = await mount(
<HeroFieldScene {...scene} visible />,
);
expect(container.querySelector("canvas")).not.toBeNull();
unmount();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(jest.getTimerCount()).toBe(0);
const drawn = mockRenderers[0].render.mock.calls.length;
act(() => {
jest.advanceTimersByTime(1000);
});
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
});
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
const first = props();
const { rerender } = await mount(<HeroFieldScene {...first} />);
const second = props();
rerender(
<HeroFieldScene
progressRef={first.progressRef}
visible={false}
onReady={second.onReady}
onError={second.onError}
/>,
);
expect(mockRenderers).toHaveLength(1);
act(() => {
jest.advanceTimersByTime(100);
});
expect(first.onReady).not.toHaveBeenCalled();
expect(second.onReady).toHaveBeenCalledTimes(1);
});
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
mockFailToCreate = true;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it.each([
[0, 1], // aparelho que não informa a proporção: 1
[0.5, 1], // nunca abaixo de 1
[1.25, 1.25],
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
])(
"draws at a pixel ratio of %p -> the renderer gets %p",
async (device, expected) => {
setDevicePixelRatio(device);
await mount(<HeroFieldScene {...props()} />);
expect(mockRenderers[0].pixelRatio).toBe(expected);
},
);
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
const scene = props();
await mount(<HeroFieldScene {...scene} />);
act(() => {
mockRenderers[0].domElement.dispatchEvent(
new Event("webglcontextlost", { cancelable: true }),
);
});
expect(scene.onError).toHaveBeenCalledTimes(1);
});
describe("built in separate tasks (no single long task)", () => {
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
render(<HeroFieldScene {...scene} />);
// nada acontece dentro do commit do React: nem o contexto WebGL
expect(mockRenderers).toHaveLength(0);
await nextTask();
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
expect(frames).not.toHaveBeenCalled();
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
finishCompile();
await act(async () => {
await Promise.resolve();
});
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
expect(frames).not.toHaveBeenCalled();
await nextTask();
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
expect(mockRenderers[0].setSize).toHaveBeenCalled();
expect(frames).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].render).not.toHaveBeenCalled();
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render).toHaveBeenCalled();
});
it("compiles the very scene it is going to draw", async () => {
await mount(<HeroFieldScene {...props()} />);
const [scene, camera] = mockRenderers[0].compileAsync.mock
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
expect(scene.isScene).toBe(true);
expect(camera.isCamera).toBe(true);
await act(async () => {
await jest.advanceTimersByTimeAsync(20);
});
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
});
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
const scene = props();
const { unmount } = render(<HeroFieldScene {...scene} />);
unmount();
await buildScene();
expect(mockRenderers).toHaveLength(0);
expect(scene.onError).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
const { container, unmount } = render(<HeroFieldScene {...props()} />);
await nextTask();
expect(mockRenderers).toHaveLength(1);
unmount();
await buildScene();
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
});
it("releases the context if it is unmounted while the shader compiles", async () => {
let finishCompile = () => {};
mockCompile = () =>
new Promise((resolve) => {
finishCompile = () => resolve(undefined);
});
const frames = jest.spyOn(window, "requestAnimationFrame");
const { container, unmount } = await mount(
<HeroFieldScene {...props()} />,
);
expect(mockRenderers).toHaveLength(1);
unmount();
finishCompile();
await buildScene();
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].domElement.isConnected).toBe(false);
expect(container.querySelector("canvas")).toBeNull();
expect(frames).not.toHaveBeenCalled();
});
it("tells the page, and frees the context, when the shader does not compile", async () => {
mockCompile = () => Promise.reject(new Error("shader inválido"));
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(container.querySelector("canvas")).toBeNull();
});
it("applies the visibility the page asked for while it was still being built", async () => {
const frames = jest.spyOn(window, "requestAnimationFrame");
const scene = props();
const { rerender } = render(
<HeroFieldScene {...scene} visible={false} />,
);
rerender(<HeroFieldScene {...scene} visible />);
await buildScene();
await act(async () => {
await jest.advanceTimersByTimeAsync(980);
});
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
expect(frames.mock.calls.length).toBeGreaterThan(20);
});
});
});
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
beforeEach(() => sessionStorage.clear());
afterEach(() => sessionStorage.clear());
it.each([
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"llvmpipe (LLVM 15.0.7, 256 bits)",
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
])(
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
async (name) => {
mockRendererName = name;
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
expect(mockRenderers).toHaveLength(1);
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
expect(container.querySelector("canvas")).toBeNull();
expect(scene.onError).toHaveBeenCalledTimes(1);
expect(scene.onReady).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(true);
},
);
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
mockRendererName =
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
const scene = props();
const { container } = await mount(<HeroFieldScene {...scene} />);
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
it("does not report an unknown renderer (no debug extension) as software", async () => {
mockRendererName = null;
const scene = props();
await mount(<HeroFieldScene {...scene} />);
expect(scene.onError).not.toHaveBeenCalled();
expect(isKnownSoftwareWebGL()).toBe(false);
});
});
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
* segue a variável), não o texto do CSS.
*/
jest.mock("./BlocosScene", () => ({
jest.mock("./HeroFieldScene", () => ({
__esModule: true,
default: () => null,
prepareBlocos: () => Promise.resolve(),
prepareField: () => {},
}));
const win = window as unknown as Record<string, unknown>;
-23
View File
@@ -1,23 +0,0 @@
/* Legenda da peça do hero (HeroField): o que a peça está montando. Cada texto novo entra digitado, com o passo e o
cursor do rótulo das seções (.ds-type e as animações ds-type, ds-type-caret e ds-type-blink, em globals.css); ela
some só pela opacidade. Sem movimento reduzido: com ele a cena nem monta, e a legenda não existe. */
.hero-legenda {
opacity: 0;
transition: opacity 0.3s var(--ease-out);
}
.hero-legenda[data-shown] {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
.hero-legenda .ds-type {
animation: ds-type calc(var(--n) * 32ms) steps(var(--n)) forwards;
}
.hero-legenda .ds-type::after {
animation:
ds-type-caret calc(var(--n) * 32ms) steps(var(--n)) forwards,
ds-type-blink 0.9s steps(1) calc(var(--n) * 32ms) forwards;
}
}
-2
View File
@@ -25,9 +25,7 @@ describe("contact-subject", () => {
expect(CONTACT_KINDS).toEqual({
site: "Site que traz pedidos pelo WhatsApp",
sistema: "Sistema para tirar a operação da planilha",
whatsapp: "Atendimento no WhatsApp com IA sob controle",
revisao: "Revisão de sistema que já existe",
fiscal: "Nota fiscal pronta para a reforma tributária",
});
});
+1 -5
View File
@@ -15,28 +15,24 @@ import { offerLabels } from "@/lib/offer-labels";
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
/**
* As cinco ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
* 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
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
*/
export const CONTACT_SUBJECT_OPTIONS = [
DEFAULT_CONTACT_SUBJECT,
offerLabels.site.name,
offerLabels.sistema.name,
offerLabels.whatsapp.name,
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
offerLabels.revisao.name,
offerLabels.fiscal.name,
] as const;
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
export const CONTACT_KINDS = {
site: offerLabels.site.name,
sistema: offerLabels.sistema.name,
whatsapp: offerLabels.whatsapp.name,
revisao: offerLabels.revisao.name,
fiscal: offerLabels.fiscal.name,
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
export type ContactKind = keyof typeof CONTACT_KINDS;
Loaded 100 of 303 files, more files were not shown because too many files have changed in this diff. Show more