Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67b32fcab3 | ||
|
|
c7d09d63d4 | ||
|
|
4671837c44 | ||
|
|
8e5d548598 | ||
|
|
558c3d5b48 | ||
|
|
bcdf0099c5 | ||
|
|
594daecd2b | ||
|
|
34c7eb7f8c | ||
|
|
fbcedc062f | ||
|
|
a97985633d | ||
|
|
3681141394 | ||
|
|
ea05ce4a5f |
No files matched your search
@@ -0,0 +1,108 @@
|
|||||||
|
# CI e deploy num arquivo só: o Gitea lê .gitea/workflows e ignora .github/workflows quando esta pasta existe.
|
||||||
|
# - verify: lint, tipos, testes e build. O deploy só roda se ele passar.
|
||||||
|
# - audit: alertas de segurança das dependências (fica vermelho sozinho, sem travar o deploy).
|
||||||
|
# - e2e: testes de ponta a ponta na build de produção (informativo).
|
||||||
|
# - deploy: Vercel CLI. Branch padrão -> produção; demais branches -> preview; pull requests não publicam.
|
||||||
|
# Token: secret VERCEL_TOKEN de nível de usuário no Gitea. Projeto na Vercel = nome do repo
|
||||||
|
# (ou a variável VERCEL_PROJECT do repo). O build roda na Vercel; o runner só envia o código.
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
"on":
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
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 test:jogo
|
||||||
|
- run: pnpm build
|
||||||
|
|
||||||
|
audit:
|
||||||
|
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 audit --prod --audit-level=high
|
||||||
|
- run: pnpm audit --dev --audit-level=high
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
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 exec playwright install --with-deps chromium
|
||||||
|
- run: pnpm build
|
||||||
|
- name: Subir o servidor de produção
|
||||||
|
run: pnpm start -p 3001 &
|
||||||
|
- name: Esperar a porta 3001
|
||||||
|
run: |
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if curl --silent --fail --output /dev/null http://localhost:3001/; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "O servidor não respondeu na porta 3001 em 120 s" >&2
|
||||||
|
exit 1
|
||||||
|
- name: Testes de ponta a ponta
|
||||||
|
run: pnpm test:e2e
|
||||||
|
env:
|
||||||
|
BASE_URL: http://localhost:3001
|
||||||
|
- name: Publicar test-results/
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: e2e-test-results
|
||||||
|
path: test-results/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: verify
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||||
|
VERCEL_SCOPE: asemlk54-1928s-projects
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
# versão fixa do CLI: um pacote sem versão num pipeline de produção é risco de cadeia de suprimentos
|
||||||
|
- run: npm install --global [email protected]
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
PROJECT=$(echo "${{ vars.VERCEL_PROJECT || github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [ "${{ github.ref_name }}" = "${{ github.event.repository.default_branch }}" ]; then
|
||||||
|
PROD=--prod
|
||||||
|
else
|
||||||
|
PROD=
|
||||||
|
fi
|
||||||
|
vercel link --yes --project "$PROJECT" --scope "$VERCEL_SCOPE" --token "$VERCEL_TOKEN"
|
||||||
|
vercel deploy $PROD --token "$VERCEL_TOKEN"
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
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 exec playwright install --with-deps chromium
|
|
||||||
- run: pnpm build
|
|
||||||
- name: Subir o servidor de produção
|
|
||||||
run: pnpm start -p 3001 &
|
|
||||||
- name: Esperar a porta 3001
|
|
||||||
run: |
|
|
||||||
for attempt in $(seq 1 60); do
|
|
||||||
if curl --silent --fail --output /dev/null http://localhost:3001/; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "O servidor não respondeu na porta 3001 em 120 s" >&2
|
|
||||||
exit 1
|
|
||||||
- name: Testes de ponta a ponta
|
|
||||||
run: pnpm test:e2e
|
|
||||||
env:
|
|
||||||
BASE_URL: http://localhost:3001
|
|
||||||
- name: Publicar test-results/
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: e2e-test-results
|
|
||||||
path: test-results/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
@@ -61,3 +61,8 @@ next-env.d.ts
|
|||||||
|
|
||||||
.omo/
|
.omo/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
/3d/out/
|
||||||
|
/3d/__pycache__/
|
||||||
|
/3d/fontes/
|
||||||
|
/3d/ferramentas/
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
"""Kit de blocos do hero: um bloco chanfrado e quatro estados com o mesmo N.
|
||||||
|
|
||||||
|
Rodar da raiz do repositório: blender -b --python 3d/blocos.py
|
||||||
|
Gera public/3d/bloco.glb, public/3d/blocos.json, os dois matcaps, o pôster do hero e os dos estados (Cycles) e, em
|
||||||
|
3d/out/, o .blend e as prévias de cada estado.
|
||||||
|
Coordenadas do JSON: Y para cima, peça de frente para +Z, centrada na origem.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import gzip
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bmesh
|
||||||
|
import bpy
|
||||||
|
import numpy as np
|
||||||
|
from bpy_extras.object_utils import world_to_camera_view
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
PUBLICO = os.path.join(RAIZ, "public", "3d")
|
||||||
|
SAIDA = os.path.join(RAIZ, "3d", "out")
|
||||||
|
GLB = os.path.join(PUBLICO, "bloco.glb")
|
||||||
|
JSON = os.path.join(PUBLICO, "blocos.json")
|
||||||
|
|
||||||
|
ESTADOS = ["marca", "tela", "integracoes", "dados"]
|
||||||
|
PASSO = 7 # números por bloco: x, y, z, sx, sy, sz, tom
|
||||||
|
CAIXA = (16, 11, 4)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- marca
|
||||||
|
# public/brand/mark-dark.svg (viewBox 210 220 830 800)
|
||||||
|
POLIGONO = [
|
||||||
|
(245, 757), (321, 757), (321, 681), (399, 681), (399, 606), (476, 606),
|
||||||
|
(476, 757), (399, 757), (399, 878), (523, 878), (523, 757), (654, 757),
|
||||||
|
(654, 913), (594, 913), (594, 982), (245, 982),
|
||||||
|
]
|
||||||
|
# a forma branca em retângulos exatos (u0, v0, u1, v1), sem sobreposição
|
||||||
|
BRANCOS = [
|
||||||
|
(399, 606, 476, 757),
|
||||||
|
(321, 681, 399, 757),
|
||||||
|
(245, 757, 399, 982),
|
||||||
|
(523, 757, 594, 878),
|
||||||
|
(594, 757, 654, 913),
|
||||||
|
(399, 878, 594, 982),
|
||||||
|
]
|
||||||
|
LIMAS = [
|
||||||
|
(511, 512, 667, 688),
|
||||||
|
(667, 358, 821, 512),
|
||||||
|
(844, 255, 947, 358),
|
||||||
|
(906, 432, 1007, 533),
|
||||||
|
]
|
||||||
|
LADO_SVG = 38
|
||||||
|
ESCALA = 10 / 727 # altura da marca (255..982) vira 10
|
||||||
|
CU, CV = (245 + 1007) / 2, (255 + 982) / 2 # centro da marca no SVG
|
||||||
|
|
||||||
|
|
||||||
|
def svg_para_mundo(u, v):
|
||||||
|
return (u - CU) * ESCALA, -(v - CV) * ESCALA
|
||||||
|
|
||||||
|
|
||||||
|
def estado_marca():
|
||||||
|
sz = LADO_SVG * ESCALA
|
||||||
|
blocos = []
|
||||||
|
for tom, retangulos in ((0, BRANCOS), (1, LIMAS)):
|
||||||
|
for u0, v0, u1, v1 in retangulos:
|
||||||
|
nx = max(1, round((u1 - u0) / LADO_SVG))
|
||||||
|
ny = max(1, round((v1 - v0) / LADO_SVG))
|
||||||
|
bu, bv = (u1 - u0) / nx, (v1 - v0) / ny
|
||||||
|
for i in range(nx):
|
||||||
|
for j in range(ny):
|
||||||
|
x, y = svg_para_mundo(u0 + (i + 0.5) * bu, v0 + (j + 0.5) * bv)
|
||||||
|
for z in (-sz / 2, sz / 2):
|
||||||
|
blocos.append((x, y, z, bu * ESCALA, bv * ESCALA, sz, tom))
|
||||||
|
return blocos
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- tela, integrações, dados
|
||||||
|
# Desenhados numa grade inteira (coluna i, linha j com j = 0 no topo, camada k
|
||||||
|
# para +Z). O passo em mundo sai do tamanho da grade; a densidade de cada
|
||||||
|
# desenho foi escolhida para ficar entre 85% e 100% de N.
|
||||||
|
|
||||||
|
|
||||||
|
class Grade:
|
||||||
|
def __init__(self):
|
||||||
|
self.celulas = {}
|
||||||
|
|
||||||
|
def bloco(self, i, j, k=0, tom=0):
|
||||||
|
# lima prevalece quando dois elementos caem na mesma célula
|
||||||
|
chave = (round(i), round(j), k)
|
||||||
|
self.celulas[chave] = max(tom, self.celulas.get(chave, 0))
|
||||||
|
|
||||||
|
def cheio(self, i0, j0, i1, j1, k=0, tom=0):
|
||||||
|
for i in range(i0, i1 + 1):
|
||||||
|
for j in range(j0, j1 + 1):
|
||||||
|
self.bloco(i, j, k, tom)
|
||||||
|
|
||||||
|
def contorno(self, i0, j0, i1, j1, k=0, tom=0):
|
||||||
|
for i in range(i0, i1 + 1):
|
||||||
|
self.bloco(i, j0, k, tom)
|
||||||
|
self.bloco(i, j1, k, tom)
|
||||||
|
for j in range(j0 + 1, j1):
|
||||||
|
self.bloco(i0, j, k, tom)
|
||||||
|
self.bloco(i1, j, k, tom)
|
||||||
|
|
||||||
|
def linha(self, a, b, k=0, tom=0):
|
||||||
|
n = math.ceil(max(abs(b[0] - a[0]), abs(b[1] - a[1]))) * 2 + 1
|
||||||
|
for t in range(n + 1):
|
||||||
|
f = t / n
|
||||||
|
self.bloco(
|
||||||
|
math.floor(a[0] + (b[0] - a[0]) * f + 0.5),
|
||||||
|
math.floor(a[1] + (b[1] - a[1]) * f + 0.5),
|
||||||
|
k,
|
||||||
|
tom,
|
||||||
|
)
|
||||||
|
|
||||||
|
def disco(self, ci, cj, r, k=0, tom=0):
|
||||||
|
n = math.floor(r)
|
||||||
|
for di in range(-n, n + 1):
|
||||||
|
for dj in range(-n, n + 1):
|
||||||
|
if di * di + dj * dj <= r * r:
|
||||||
|
self.bloco(ci + di, cj + dj, k, tom)
|
||||||
|
|
||||||
|
def blocos(self):
|
||||||
|
cel = self.celulas
|
||||||
|
ii = [c[0] for c in cel]
|
||||||
|
jj = [c[1] for c in cel]
|
||||||
|
kk = [c[2] for c in cel]
|
||||||
|
largura = max(ii) - min(ii) + 1
|
||||||
|
altura = max(jj) - min(jj) + 1
|
||||||
|
camadas = max(kk) - min(kk) + 1
|
||||||
|
# maior passo que cabe na caixa, com folga
|
||||||
|
p = min(14.5 / largura, 9.8 / altura, 3.8 / camadas)
|
||||||
|
mi, mj, mk = (max(ii) + min(ii)) / 2, (max(jj) + min(jj)) / 2, (max(kk) + min(kk)) / 2
|
||||||
|
return [
|
||||||
|
((i - mi) * p, -(j - mj) * p, (k - mk) * p, p, p, p, tom)
|
||||||
|
for (i, j, k), tom in sorted(cel.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def estado_tela():
|
||||||
|
# referência: drawScreen em app/components/cinematic/HeroFieldScene.tsx
|
||||||
|
g = Grade()
|
||||||
|
C, R = 26, 18
|
||||||
|
g.contorno(0, 0, C - 1, R - 1) # moldura
|
||||||
|
for i in (2, 4, 6):
|
||||||
|
g.bloco(i, 1) # três pontos
|
||||||
|
g.cheio(1, 2, C - 2, 2) # barra do navegador
|
||||||
|
g.cheio(2, 4, 9, 5) # título
|
||||||
|
g.cheio(2, 7, 12, 7) # texto
|
||||||
|
g.cheio(2, 9, 9, 9)
|
||||||
|
for k in (0, 1):
|
||||||
|
g.cheio(2, 11, 7, 11, k, tom=1) # botão, saltado para a frente
|
||||||
|
g.contorno(15, 4, C - 3, 11) # imagem
|
||||||
|
g.linha((16, 10), (19, 7))
|
||||||
|
g.linha((19, 7), (21, 9))
|
||||||
|
g.bloco(21, 6)
|
||||||
|
for i0 in (2, 10, 18):
|
||||||
|
g.contorno(i0, 13, i0 + 5, 15) # cartões
|
||||||
|
return g.blocos()
|
||||||
|
|
||||||
|
|
||||||
|
def estado_integracoes():
|
||||||
|
# referência: drawIntegrations em HeroFieldScene.tsx
|
||||||
|
g = Grade()
|
||||||
|
A, B = 11, 7.5 # semieixos da elipse dos nós
|
||||||
|
r_nucleo, r_no = 2.6, 1.6
|
||||||
|
for k in (0, 1):
|
||||||
|
g.disco(0, 0, r_nucleo, k, tom=1)
|
||||||
|
nos = []
|
||||||
|
for n in range(6):
|
||||||
|
a = n / 6 * math.tau + math.pi / 6
|
||||||
|
nos.append((round(math.cos(a) * A), round(math.sin(a) * B)))
|
||||||
|
for n, (x, y) in enumerate(nos):
|
||||||
|
for k in (0, 1):
|
||||||
|
g.disco(x, y, r_no, k)
|
||||||
|
g.bloco(x, y, 1, tom=1)
|
||||||
|
d = math.hypot(x, y)
|
||||||
|
ux, uy = x / d, y / d
|
||||||
|
g.linha((ux * (r_nucleo + 0.5), uy * (r_nucleo + 0.5)), (x - ux * (r_no + 0.5), y - uy * (r_no + 0.5)))
|
||||||
|
nx, ny = nos[(n + 1) % 6]
|
||||||
|
dx, dy = nx - x, ny - y
|
||||||
|
d = math.hypot(dx, dy)
|
||||||
|
g.linha((x + dx / d * (r_no + 0.5), y + dy / d * (r_no + 0.5)), (nx - dx / d * (r_no + 0.5), ny - dy / d * (r_no + 0.5)))
|
||||||
|
return g.blocos()
|
||||||
|
|
||||||
|
|
||||||
|
def estado_dados():
|
||||||
|
# referência: drawData em HeroFieldScene.tsx; a coluna da direita vira barras em +Z
|
||||||
|
g = Grade()
|
||||||
|
C, R = 24, 15
|
||||||
|
g.contorno(0, 0, C - 1, R - 1)
|
||||||
|
g.cheio(1, 1, C - 2, 2, tom=1) # cabeçalho
|
||||||
|
colunas = (7, 13, 18)
|
||||||
|
for c in colunas:
|
||||||
|
g.cheio(c, 3, c, R - 2)
|
||||||
|
valores = ((5, 3, 2), (3, 4, 3), (4, 2, 1), (2, 4, 3), (5, 3, 2))
|
||||||
|
alturas = (2, 4, 1, 5, 3)
|
||||||
|
for linha, (v, h) in enumerate(zip(valores, alturas)):
|
||||||
|
j = 4 + linha * 2
|
||||||
|
for inicio, comprimento in zip((2, 9, 15), v):
|
||||||
|
g.cheio(inicio, j, inicio + comprimento - 1, j)
|
||||||
|
for k in range(h + 1):
|
||||||
|
g.cheio(20, j, 21, j, k, tom=int(k == h)) # topo da barra em lima
|
||||||
|
return g.blocos()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------ montagem dos estados
|
||||||
|
|
||||||
|
|
||||||
|
def chave(b):
|
||||||
|
return (round(b[0], 3), round(b[1], 3), round(b[2], 3))
|
||||||
|
|
||||||
|
|
||||||
|
def completar(blocos, n):
|
||||||
|
"""Ordena por (x, y, z) e põe os que faltam para n ocultos sobre visíveis espalhados."""
|
||||||
|
visiveis = sorted(blocos, key=chave)
|
||||||
|
v, h = len(visiveis), n - len(visiveis)
|
||||||
|
if h < 0:
|
||||||
|
falhar(f"estado com {v} blocos, mais que N={n}")
|
||||||
|
ocultos = []
|
||||||
|
for m in range(h):
|
||||||
|
x, y, z, _, _, _, tom = visiveis[(m * 2 + 1) * v // (h * 2)]
|
||||||
|
ocultos.append((x, y, z, 0, 0, 0, tom))
|
||||||
|
# oculto fica logo depois do visível em que está, mesma chave espacial
|
||||||
|
return sorted(visiveis + ocultos, key=lambda b: (chave(b), b[3] == 0))
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(v):
|
||||||
|
s = f"{v:.3f}"
|
||||||
|
return "0.000" if s == "-0.000" else s
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Blender
|
||||||
|
|
||||||
|
|
||||||
|
def srgb(h):
|
||||||
|
def lin(c):
|
||||||
|
c /= 255
|
||||||
|
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
return (*(lin(int(h[i : i + 2], 16)) for i in (0, 2, 4)), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def criar_bloco():
|
||||||
|
malha = bpy.data.meshes.new("bloco")
|
||||||
|
bm = bmesh.new()
|
||||||
|
bmesh.ops.create_cube(bm, size=1)
|
||||||
|
bm.to_mesh(malha)
|
||||||
|
bm.free()
|
||||||
|
obj = bpy.data.objects.new("bloco", malha)
|
||||||
|
bpy.context.scene.collection.objects.link(obj)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
chanfro = obj.modifiers.new("chanfro", "BEVEL")
|
||||||
|
chanfro.width = 0.08
|
||||||
|
chanfro.segments = 2
|
||||||
|
malha.shade_smooth()
|
||||||
|
pesos = obj.modifiers.new("normais", "WEIGHTED_NORMAL")
|
||||||
|
pesos.keep_sharp = True
|
||||||
|
for m in ("chanfro", "normais"):
|
||||||
|
bpy.ops.object.modifier_apply(modifier=m)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def exportar_glb(obj):
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.ops.export_scene.gltf(
|
||||||
|
filepath=GLB,
|
||||||
|
export_format="GLB",
|
||||||
|
use_selection=True,
|
||||||
|
export_yup=True,
|
||||||
|
export_apply=True,
|
||||||
|
export_normals=True,
|
||||||
|
export_texcoords=False,
|
||||||
|
export_tangents=False,
|
||||||
|
export_materials="NONE",
|
||||||
|
export_vertex_color="NONE",
|
||||||
|
export_attributes=False,
|
||||||
|
export_extras=False,
|
||||||
|
export_animations=False,
|
||||||
|
export_draco_mesh_compression_enable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def montar_cena(estados, malha):
|
||||||
|
cena = bpy.context.scene
|
||||||
|
materiais = criar_materiais()
|
||||||
|
malha.materials.append(materiais[0])
|
||||||
|
colecoes = {}
|
||||||
|
for nome, blocos in zip(ESTADOS, estados):
|
||||||
|
col = bpy.data.collections.new(nome)
|
||||||
|
cena.collection.children.link(col)
|
||||||
|
colecoes[nome] = col
|
||||||
|
for n, (x, y, z, sx, sy, sz, tom) in enumerate(blocos):
|
||||||
|
obj = bpy.data.objects.new(f"{nome}.{n:03d}", malha)
|
||||||
|
col.objects.link(obj)
|
||||||
|
# arquivo Y-up/+Z frente -> Blender Z-up/-Y frente
|
||||||
|
obj.location = (x, -z, y)
|
||||||
|
obj.scale = (sx, sz, sy)
|
||||||
|
obj.material_slots[0].link = "OBJECT"
|
||||||
|
obj.material_slots[0].material = materiais[tom]
|
||||||
|
obj.hide_render = obj.hide_viewport = sx == 0
|
||||||
|
return colecoes, materiais
|
||||||
|
|
||||||
|
|
||||||
|
def renderizar(colecoes):
|
||||||
|
cena = bpy.context.scene
|
||||||
|
cena.render.engine = "BLENDER_WORKBENCH"
|
||||||
|
cena.render.resolution_x, cena.render.resolution_y = 800, 600
|
||||||
|
cena.render.resolution_percentage = 100
|
||||||
|
cena.render.image_settings.file_format = "PNG"
|
||||||
|
cena.view_settings.view_transform = "Standard"
|
||||||
|
cena.display.shading.light = "STUDIO"
|
||||||
|
cena.display.shading.color_type = "MATERIAL"
|
||||||
|
cena.display.shading.show_cavity = True
|
||||||
|
cena.world = bpy.data.worlds.new("fundo")
|
||||||
|
cena.world.color = (0.008, 0.008, 0.01)
|
||||||
|
|
||||||
|
cam = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
|
||||||
|
cena.collection.objects.link(cam)
|
||||||
|
cam.location = (-6.0, -25.0, 3.0) # pela esquerda: barras de dados saem para fora da tabela
|
||||||
|
cam.rotation_euler = (-cam.location.normalized()).to_track_quat("-Z", "Y").to_euler()
|
||||||
|
cena.camera = cam
|
||||||
|
|
||||||
|
for nome in ESTADOS:
|
||||||
|
for outro, col in colecoes.items():
|
||||||
|
col.hide_render = col.hide_viewport = outro != nome
|
||||||
|
cena.render.filepath = os.path.join(SAIDA, f"previa-{nome}.png")
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
for outro, col in colecoes.items():
|
||||||
|
col.hide_render = col.hide_viewport = outro != "marca"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- render final
|
||||||
|
# Porcelana fosca com um verniz fino e esmalte lima com emissão leve, numa luz de estúdio de três pontos.
|
||||||
|
# Os matcaps (uma esfera de cada material, vista de frente) levam essa mesma luz para o tempo real.
|
||||||
|
|
||||||
|
|
||||||
|
def criar_materiais():
|
||||||
|
def material(nome, cor, **entradas):
|
||||||
|
mat = bpy.data.materials.new(nome)
|
||||||
|
mat.diffuse_color = srgb(cor) # cor da prévia (Workbench)
|
||||||
|
bsdf = mat.node_tree.nodes["Principled BSDF"]
|
||||||
|
bsdf.inputs["Base Color"].default_value = srgb(cor)
|
||||||
|
for entrada, valor in entradas.items():
|
||||||
|
bsdf.inputs[entrada].default_value = valor
|
||||||
|
return mat
|
||||||
|
|
||||||
|
return {
|
||||||
|
0: material("porcelana", "f4f3ef", **{"Roughness": 0.5, "Coat Weight": 0.12, "Coat Roughness": 0.3}),
|
||||||
|
1: material(
|
||||||
|
"lima",
|
||||||
|
"d5fa00",
|
||||||
|
**{
|
||||||
|
"Roughness": 0.28,
|
||||||
|
"Coat Weight": 0.3,
|
||||||
|
"Coat Roughness": 0.12,
|
||||||
|
"Emission Color": srgb("d5fa00"),
|
||||||
|
"Emission Strength": 0.06,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def olhar_para_a_origem(obj):
|
||||||
|
obj.rotation_euler = (-Vector(obj.location).normalized()).to_track_quat("-Z", "Y").to_euler()
|
||||||
|
|
||||||
|
|
||||||
|
def enquadrar(camera, objetos, direcao, ocupa):
|
||||||
|
"""Põe a câmera na direção dada, olhando a origem, na distância em que os objetos ocupam essa fração do quadro."""
|
||||||
|
cena = bpy.context.scene
|
||||||
|
cantos = [obj.matrix_world @ Vector(c) for obj in objetos for c in obj.bound_box]
|
||||||
|
distancia = 40
|
||||||
|
for _ in range(3): # com lente longa o tamanho na tela é quase 1 / distância: três passos convergem
|
||||||
|
camera.location = Vector(direcao).normalized() * distancia
|
||||||
|
olhar_para_a_origem(camera)
|
||||||
|
bpy.context.view_layer.update()
|
||||||
|
vistos = [world_to_camera_view(cena, camera, p) for p in cantos]
|
||||||
|
distancia *= 2 * max(max(abs(p.x - 0.5), abs(p.y - 0.5)) for p in vistos) / ocupa
|
||||||
|
|
||||||
|
|
||||||
|
def luz(nome, posicao, lado, potencia, cor=(1, 1, 1)):
|
||||||
|
dados = bpy.data.lights.new(nome, "AREA")
|
||||||
|
dados.shape = "SQUARE"
|
||||||
|
dados.size = lado
|
||||||
|
dados.energy = potencia
|
||||||
|
dados.color = cor
|
||||||
|
obj = bpy.data.objects.new(nome, dados)
|
||||||
|
bpy.context.scene.collection.objects.link(obj)
|
||||||
|
obj.location = posicao
|
||||||
|
olhar_para_a_origem(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def ligar_cycles(cena):
|
||||||
|
"""Cycles na GPU com OptiX quando a máquina tem; senão segue na CPU."""
|
||||||
|
cena.render.engine = "CYCLES"
|
||||||
|
cena.cycles.use_denoising = True
|
||||||
|
prefs = bpy.context.preferences.addons["cycles"].preferences
|
||||||
|
try:
|
||||||
|
prefs.compute_device_type = "OPTIX"
|
||||||
|
except TypeError:
|
||||||
|
return
|
||||||
|
prefs.get_devices_for_type("OPTIX")
|
||||||
|
for aparelho in prefs.devices:
|
||||||
|
aparelho.use = aparelho.type == "OPTIX"
|
||||||
|
if any(aparelho.use for aparelho in prefs.devices):
|
||||||
|
cena.cycles.device = "GPU"
|
||||||
|
cena.cycles.denoiser = "OPTIX"
|
||||||
|
|
||||||
|
|
||||||
|
def sair(cena, caminho, lado, formato, modo, amostras, transparente):
|
||||||
|
cena.render.resolution_x, cena.render.resolution_y = lado
|
||||||
|
cena.render.film_transparent = transparente
|
||||||
|
cena.render.image_settings.file_format = formato
|
||||||
|
cena.render.image_settings.color_mode = modo
|
||||||
|
cena.render.image_settings.quality = 84
|
||||||
|
cena.cycles.samples = amostras
|
||||||
|
cena.render.filepath = caminho
|
||||||
|
bpy.ops.render.render(write_still=True)
|
||||||
|
|
||||||
|
|
||||||
|
def luz_de_estudio(cena):
|
||||||
|
fundo = cena.world.node_tree.nodes["Background"]
|
||||||
|
fundo.inputs["Color"].default_value = (0.02, 0.022, 0.02, 1)
|
||||||
|
fundo.inputs["Strength"].default_value = 1.0
|
||||||
|
# três tons por bloco: topo claro (principal, quase a pino), frente média (preenchimento baixo e frontal) e a lateral
|
||||||
|
# que a câmera vê na sombra; o contraluz recorta a silhueta por trás
|
||||||
|
luz("principal", (6, -10, 20), 14, 7000)
|
||||||
|
luz("preenchimento", (-10, -18, 3), 18, 2000, (0.92, 0.96, 1.0))
|
||||||
|
luz("contraluz", (-8, 12, 10), 10, 2000, (0.85, 1.0, 0.75))
|
||||||
|
|
||||||
|
|
||||||
|
def render_final(colecoes, materiais):
|
||||||
|
cena = bpy.context.scene
|
||||||
|
ligar_cycles(cena)
|
||||||
|
luz_de_estudio(cena)
|
||||||
|
|
||||||
|
def so(nome):
|
||||||
|
for outro, col in colecoes.items():
|
||||||
|
col.hide_render = col.hide_viewport = outro != nome
|
||||||
|
|
||||||
|
# prévias finais dos quatro estados, na câmera das prévias rápidas
|
||||||
|
for nome in ESTADOS:
|
||||||
|
so(nome)
|
||||||
|
sair(cena, os.path.join(SAIDA, f"final-{nome}.png"), (800, 600), "PNG", "RGB", 64, False)
|
||||||
|
|
||||||
|
camera = bpy.data.objects.new("camera-poster", bpy.data.cameras.new("camera-poster"))
|
||||||
|
cena.collection.objects.link(camera)
|
||||||
|
camera.data.lens = 85
|
||||||
|
cena.camera = camera
|
||||||
|
|
||||||
|
# pôsteres dos estados: quase de frente como as prévias, mas de lente longa e com a peça em uns 85% do quadro
|
||||||
|
cena.render.resolution_x, cena.render.resolution_y = 1200, 900 # o enquadramento usa a proporção do quadro
|
||||||
|
for nome in ESTADOS[1:]:
|
||||||
|
so(nome)
|
||||||
|
enquadrar(camera, [obj for obj in colecoes[nome].objects if not obj.hide_render], (-6, -25, 3), 0.85)
|
||||||
|
sair(cena, os.path.join(PUBLICO, f"estado-{nome}.webp"), (1200, 900), "WEBP", "RGBA", 256, True)
|
||||||
|
|
||||||
|
# pôster: a marca em três quartos, fundo transparente
|
||||||
|
camera.location = Vector((-0.42, -0.86, 0.2)).normalized() * 36
|
||||||
|
olhar_para_a_origem(camera)
|
||||||
|
so("marca")
|
||||||
|
sair(cena, os.path.join(PUBLICO, "hero-poster.webp"), (1200, 1200), "WEBP", "RGBA", 256, True)
|
||||||
|
|
||||||
|
# matcaps: uma esfera de cada material, de frente, em vista ortográfica
|
||||||
|
so(None)
|
||||||
|
malha = bpy.data.meshes.new("esfera")
|
||||||
|
bm = bmesh.new()
|
||||||
|
bmesh.ops.create_uvsphere(bm, u_segments=96, v_segments=48, radius=1)
|
||||||
|
bm.to_mesh(malha)
|
||||||
|
bm.free()
|
||||||
|
malha.shade_smooth()
|
||||||
|
esfera = bpy.data.objects.new("esfera", malha)
|
||||||
|
cena.collection.objects.link(esfera)
|
||||||
|
frente = bpy.data.objects.new("camera-matcap", bpy.data.cameras.new("camera-matcap"))
|
||||||
|
cena.collection.objects.link(frente)
|
||||||
|
frente.data.type = "ORTHO"
|
||||||
|
frente.data.ortho_scale = 2.0
|
||||||
|
frente.location = (0, -12, 0)
|
||||||
|
olhar_para_a_origem(frente)
|
||||||
|
cena.camera = frente
|
||||||
|
malha.materials.append(None)
|
||||||
|
# a esfera de frente pega mais luz de preenchimento que as faces do pôster: a porcelana sai 0,3 ponto de exposição
|
||||||
|
# abaixo, para a cena em tempo real ter o mesmo branco do pôster (a lima já bate)
|
||||||
|
for tom, nome, exposicao in ((0, "porcelana", -0.3), (1, "lima", 0.0)):
|
||||||
|
malha.materials[0] = materiais[tom]
|
||||||
|
cena.view_settings.exposure = exposicao
|
||||||
|
sair(cena, os.path.join(PUBLICO, f"matcap-{nome}.webp"), (256, 256), "WEBP", "RGB", 128, False)
|
||||||
|
cena.view_settings.exposure = 0.0
|
||||||
|
bpy.data.objects.remove(esfera)
|
||||||
|
bpy.data.objects.remove(frente)
|
||||||
|
|
||||||
|
cena.camera = camera
|
||||||
|
so("marca")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- autoconferência
|
||||||
|
|
||||||
|
|
||||||
|
def falhar(msg):
|
||||||
|
print(f"FALHOU: {msg}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def conferir(tris):
|
||||||
|
with open(JSON, "rb") as f:
|
||||||
|
bruto = f.read()
|
||||||
|
import json
|
||||||
|
|
||||||
|
doc = json.loads(bruto)
|
||||||
|
n = doc["n"]
|
||||||
|
dados = np.array(doc["dados"], dtype=float)
|
||||||
|
if dados.size != len(ESTADOS) * n * PASSO:
|
||||||
|
falhar(f"dados com {dados.size} números, esperado {len(ESTADOS) * n * PASSO} (mesmo N em todos os estados)")
|
||||||
|
dados = dados.reshape(len(ESTADOS), n, PASSO)
|
||||||
|
if n > 320:
|
||||||
|
falhar(f"N={n} > 320")
|
||||||
|
if n * tris > 60000:
|
||||||
|
falhar(f"N x triângulos = {n * tris} > 60000")
|
||||||
|
if os.path.getsize(GLB) > 40 * 1024:
|
||||||
|
falhar(f"bloco.glb com {os.path.getsize(GLB)} bytes > 40 KB")
|
||||||
|
estados = [(f"estado-{nome}.webp", 90) for nome in ESTADOS[1:]]
|
||||||
|
for nome, limite in (("hero-poster.webp", 90), *estados, ("matcap-porcelana.webp", 30), ("matcap-lima.webp", 30)):
|
||||||
|
tamanho = os.path.getsize(os.path.join(PUBLICO, nome))
|
||||||
|
if tamanho > limite * 1024:
|
||||||
|
falhar(f"{nome} com {tamanho} bytes > {limite} KB")
|
||||||
|
if len(gzip.compress(bruto)) > 40 * 1024:
|
||||||
|
falhar("blocos.json em gzip > 40 KB")
|
||||||
|
|
||||||
|
meia = np.array(CAIXA) / 2 + 1e-3
|
||||||
|
for nome, e in zip(ESTADOS, dados):
|
||||||
|
vis = e[e[:, 3] > 0]
|
||||||
|
ocultos = n - len(vis)
|
||||||
|
if ocultos > 0.15 * n:
|
||||||
|
falhar(f"{nome}: {ocultos} ocultos > 15% de {n}")
|
||||||
|
if np.any(np.abs(vis[:, :3]) + vis[:, 3:6] / 2 > meia):
|
||||||
|
falhar(f"{nome}: sai da caixa {CAIXA}")
|
||||||
|
if nome != "marca":
|
||||||
|
if np.any(np.abs(vis[:, 3] - vis[:, 4]) > 1e-3) or np.any(np.abs(vis[:, 3] - vis[:, 5]) > 1e-3):
|
||||||
|
falhar(f"{nome}: blocos não cúbicos")
|
||||||
|
if vis[:, 6].sum() > 0.25 * len(vis):
|
||||||
|
falhar(f"{nome}: lima em {int(vis[:, 6].sum())} de {len(vis)} visíveis > 25%")
|
||||||
|
|
||||||
|
# marca: em cada camada, os blocos lima ladrilham os quatro retângulos com tolerância
|
||||||
|
# de 0.5 unidade do SVG: cada bloco dentro de um retângulo, sem sobreposição e com a
|
||||||
|
# área de cada retângulo preenchida (folga de 0.5 em todo o perímetro).
|
||||||
|
tol = 0.5
|
||||||
|
marca = dados[0]
|
||||||
|
area_poligono = abs(sum(
|
||||||
|
a[0] * b[1] - b[0] * a[1] for a, b in zip(POLIGONO, POLIGONO[1:] + POLIGONO[:1])
|
||||||
|
)) / 2
|
||||||
|
for z in np.unique(marca[:, 2]):
|
||||||
|
camada = marca[marca[:, 2] == z]
|
||||||
|
u = camada[:, 0] / ESCALA + CU
|
||||||
|
v = -camada[:, 1] / ESCALA + CV
|
||||||
|
lu = camada[:, 3] / ESCALA
|
||||||
|
lv = camada[:, 4] / ESCALA
|
||||||
|
caixas = [(u[b] - lu[b] / 2, v[b] - lv[b] / 2, u[b] + lu[b] / 2, v[b] + lv[b] / 2)
|
||||||
|
for b in np.flatnonzero(camada[:, 6] == 1)]
|
||||||
|
areas = [0.0] * len(LIMAS)
|
||||||
|
for c in caixas:
|
||||||
|
dentro = [r for r, (u0, v0, u1, v1) in enumerate(LIMAS)
|
||||||
|
if c[0] >= u0 - tol and c[1] >= v0 - tol and c[2] <= u1 + tol and c[3] <= v1 + tol]
|
||||||
|
if not dentro:
|
||||||
|
falhar(f"marca z={z}: bloco lima {c} fora dos retângulos lima")
|
||||||
|
areas[dentro[0]] += (c[2] - c[0]) * (c[3] - c[1])
|
||||||
|
for (u0, v0, u1, v1), area in zip(LIMAS, areas):
|
||||||
|
if abs(area - (u1 - u0) * (v1 - v0)) > tol * 2 * (u1 - u0 + v1 - v0):
|
||||||
|
falhar(f"marca z={z}: retângulo lima {(u0, v0, u1, v1)} coberto com área {area:.0f}")
|
||||||
|
for a in range(len(caixas)):
|
||||||
|
for b in range(a):
|
||||||
|
p, q = caixas[a], caixas[b]
|
||||||
|
if min(p[2], q[2]) - max(p[0], q[0]) > tol and min(p[3], q[3]) - max(p[1], q[1]) > tol:
|
||||||
|
falhar(f"marca z={z}: blocos lima sobrepostos {p} {q}")
|
||||||
|
brancos = camada[:, 6] == 0
|
||||||
|
area = float((lu[brancos] * lv[brancos]).sum())
|
||||||
|
if abs(area - area_poligono) > 0.01 * area_poligono:
|
||||||
|
falhar(f"marca z={z}: área branca {area:.0f} != polígono {area_poligono:.0f}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
os.makedirs(PUBLICO, exist_ok=True)
|
||||||
|
os.makedirs(SAIDA, exist_ok=True)
|
||||||
|
|
||||||
|
marca = estado_marca()
|
||||||
|
n = len(marca)
|
||||||
|
estados = [completar(b, n) for b in (marca, estado_tela(), estado_integracoes(), estado_dados())]
|
||||||
|
numeros = ",".join(fmt(v) for e in estados for b in e for v in b)
|
||||||
|
lista = ",".join(f'"{e}"' for e in ESTADOS)
|
||||||
|
with open(JSON, "w") as f:
|
||||||
|
f.write(f'{{"n":{n},"passo":{PASSO},"estados":[{lista}],"dados":[{numeros}]}}')
|
||||||
|
|
||||||
|
bloco = criar_bloco()
|
||||||
|
bloco.data.calc_loop_triangles()
|
||||||
|
tris = len(bloco.data.loop_triangles)
|
||||||
|
exportar_glb(bloco)
|
||||||
|
|
||||||
|
bpy.context.scene.collection.objects.unlink(bloco) # a malha fica só nas instâncias
|
||||||
|
colecoes, materiais = montar_cena(estados, bloco.data)
|
||||||
|
bpy.data.objects.remove(bloco)
|
||||||
|
renderizar(colecoes)
|
||||||
|
render_final(colecoes, materiais)
|
||||||
|
bpy.context.preferences.filepaths.save_version = 0 # sem blocos.blend1
|
||||||
|
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(SAIDA, "blocos.blend"))
|
||||||
|
|
||||||
|
conferir(tris)
|
||||||
|
for nome, e in zip(ESTADOS, estados):
|
||||||
|
vis = [b for b in e if b[3] > 0]
|
||||||
|
print(f"{nome}: {len(vis)} visíveis, {sum(b[6] for b in vis)} lima", flush=True)
|
||||||
|
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
|
||||||
|
print(f"OK blocos={n} tris={n * tris}", flush=True)
|
||||||
|
sys.exit(0) # sai já, para o OK ser a última linha
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/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"
|
||||||
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 803 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,355 @@
|
|||||||
|
"""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]]()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/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
|
||||||
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,477 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 468 B |
@@ -0,0 +1,341 @@
|
|||||||
|
"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s.
|
||||||
|
|
||||||
|
Rodar da raiz do repositório: blender -b --python 3d/pecas.py
|
||||||
|
Renderiza os quadros em 3d/out/pecas/<nome>/ e o ffmpeg do sistema gera em public/3d/
|
||||||
|
peca-<nome>.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from mathutils import Matrix, Vector
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from blocos import ( # noqa: E402
|
||||||
|
LIMAS,
|
||||||
|
PUBLICO,
|
||||||
|
SAIDA,
|
||||||
|
criar_bloco,
|
||||||
|
criar_materiais,
|
||||||
|
estado_marca,
|
||||||
|
falhar,
|
||||||
|
ligar_cycles,
|
||||||
|
luz_de_estudio,
|
||||||
|
sair,
|
||||||
|
svg_para_mundo,
|
||||||
|
)
|
||||||
|
|
||||||
|
QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0
|
||||||
|
LADO = (640, 400)
|
||||||
|
TINTA = "0x0a0a0a" # --ink
|
||||||
|
AMOSTRAS = 64
|
||||||
|
LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000
|
||||||
|
|
||||||
|
|
||||||
|
def encaixe(f, inicio, duracao):
|
||||||
|
"""0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto."""
|
||||||
|
t = min(max((f - inicio) / duracao, 0), 1)
|
||||||
|
return 1 - (1 - t) ** 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- peças
|
||||||
|
# Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f.
|
||||||
|
# Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1.
|
||||||
|
|
||||||
|
|
||||||
|
def sistema(novo):
|
||||||
|
# planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho
|
||||||
|
for c in range(7):
|
||||||
|
for r in range(5):
|
||||||
|
novo((c - 3, 2 - r, 0), 1, int(r == 0))
|
||||||
|
# as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas
|
||||||
|
barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))]
|
||||||
|
|
||||||
|
def pose(f):
|
||||||
|
for k, pilha in enumerate(barras):
|
||||||
|
sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8)
|
||||||
|
for j, obj in enumerate(pilha):
|
||||||
|
obj.location.z = (1 + j) * sobe
|
||||||
|
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
def whatsapp(novo):
|
||||||
|
# celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela
|
||||||
|
for c in range(9):
|
||||||
|
for r in range(16):
|
||||||
|
borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15))
|
||||||
|
canto_de_dentro = c in (1, 7) and r in (1, 14)
|
||||||
|
if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima
|
||||||
|
novo((c - 4, 0, 7.5 - r), 1, 0)
|
||||||
|
novo((-2, 0, 6.5), 1, 1) # foto do contato
|
||||||
|
for i in range(4):
|
||||||
|
novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto
|
||||||
|
|
||||||
|
# balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo)
|
||||||
|
p = 0.7
|
||||||
|
|
||||||
|
def balao(k, esquerda, tom):
|
||||||
|
borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga
|
||||||
|
sentido = 1 if esquerda else -1
|
||||||
|
topo = 2.95 - 3.15 * k
|
||||||
|
blocos = []
|
||||||
|
for i in range(6):
|
||||||
|
for j in range(3):
|
||||||
|
if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2):
|
||||||
|
continue
|
||||||
|
alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p))
|
||||||
|
blocos.append((novo(alvo, p, tom), alvo))
|
||||||
|
return blocos, Vector((borda, 0, topo - 2.5 * p))
|
||||||
|
|
||||||
|
baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)]
|
||||||
|
pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro
|
||||||
|
|
||||||
|
def pose(f):
|
||||||
|
some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo
|
||||||
|
for (blocos, rabo), chega in zip(baloes, (2, 14, 44)):
|
||||||
|
s = encaixe(f, chega, 8) * (1 - some)
|
||||||
|
for obj, alvo in blocos:
|
||||||
|
obj.location = rabo + (alvo - rabo) * s
|
||||||
|
obj.scale = (p * max(s, 0.001),) * 3
|
||||||
|
obj.hide_render = s <= 0
|
||||||
|
aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3))
|
||||||
|
for d, obj in enumerate(pontos):
|
||||||
|
pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro
|
||||||
|
s = aparece * 0.55 * (1 + 0.45 * pulso)
|
||||||
|
obj.scale = (max(s, 0.001),) * 3
|
||||||
|
obj.hide_render = s <= 0
|
||||||
|
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
def quatrocentos(novo):
|
||||||
|
# o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar
|
||||||
|
x0, y1 = svg_para_mundo(*LIMAS[2][:2])
|
||||||
|
x1, y0 = svg_para_mundo(*LIMAS[2][2:])
|
||||||
|
solto = []
|
||||||
|
for x, y, z, sx, sy, sz, tom in estado_marca():
|
||||||
|
obj = novo((x, -z, y), (sx, sz, sy), tom)
|
||||||
|
if tom and x0 < x < x1 and y0 < y < y1:
|
||||||
|
solto.append((obj, Vector((x, -z, y))))
|
||||||
|
centro = sum((alvo for _, alvo in solto), Vector()) / len(solto)
|
||||||
|
|
||||||
|
def pose(f):
|
||||||
|
if f < 32:
|
||||||
|
queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera
|
||||||
|
d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda))
|
||||||
|
giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai
|
||||||
|
else:
|
||||||
|
d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8))))
|
||||||
|
giro = Matrix.Identity(3)
|
||||||
|
for obj, alvo in solto:
|
||||||
|
obj.location = centro + giro @ (alvo - centro) + d
|
||||||
|
obj.rotation_euler = giro.to_euler()
|
||||||
|
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
def selo_conferido(novo, x0, z0, y, passo):
|
||||||
|
"""Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda."""
|
||||||
|
desenho = ("....#", "...##", "#.##.", "###..", ".#...")
|
||||||
|
vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"}
|
||||||
|
blocos = []
|
||||||
|
for c in range(7):
|
||||||
|
for r in range(7):
|
||||||
|
if (c, r) not in vazado:
|
||||||
|
alvo = Vector((x0 + c * passo, y, z0 - r * passo))
|
||||||
|
blocos.append((novo(alvo, passo, 1), alvo))
|
||||||
|
return blocos
|
||||||
|
|
||||||
|
|
||||||
|
def fiscal(novo):
|
||||||
|
# nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1
|
||||||
|
linhas = {2: 7, 4: 9, 6: 6, 8: 8}
|
||||||
|
for c in range(11):
|
||||||
|
for r in range(16):
|
||||||
|
if not 1 <= c <= linhas.get(r, 0):
|
||||||
|
novo((c - 5, 0, 7.5 - r), 1, 0)
|
||||||
|
selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V
|
||||||
|
de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente
|
||||||
|
para_cima = Vector((6, -4, 20))
|
||||||
|
|
||||||
|
def pose(f):
|
||||||
|
sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando
|
||||||
|
d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai
|
||||||
|
for obj, alvo in selo:
|
||||||
|
obj.location = alvo + d
|
||||||
|
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
REVISAO_CAMERA = (-0.25, -0.95, 0.15)
|
||||||
|
|
||||||
|
|
||||||
|
def revisao(novo):
|
||||||
|
# janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo
|
||||||
|
linhas = {4: 8, 6: 10, 8: 6, 10: 9}
|
||||||
|
# três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro)
|
||||||
|
fora = {
|
||||||
|
(3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)),
|
||||||
|
(5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)),
|
||||||
|
(7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)),
|
||||||
|
}
|
||||||
|
soltos = []
|
||||||
|
for c in range(13):
|
||||||
|
for r in range(13):
|
||||||
|
if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0):
|
||||||
|
obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0)
|
||||||
|
if (c, r) in fora:
|
||||||
|
desloc, giro = fora[(c, r)]
|
||||||
|
soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro)))
|
||||||
|
|
||||||
|
# lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita
|
||||||
|
lupa = []
|
||||||
|
for dx in range(-2, 3):
|
||||||
|
for dz in range(-2, 3):
|
||||||
|
if 1.5 < math.hypot(dx, dz) <= 2.6:
|
||||||
|
lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz))))
|
||||||
|
for k in range(3):
|
||||||
|
d = (3.0 + 0.95 * k) / math.sqrt(2)
|
||||||
|
cabo = novo((0, 0, 0), 1, 1)
|
||||||
|
cabo.rotation_euler = (0, math.pi / 4, 0)
|
||||||
|
lupa.append((cabo, Vector((d, 0, -d))))
|
||||||
|
lima = lupa[0][0].material_slots[0].material
|
||||||
|
porcelana = soltos[0][0].material_slots[0].material
|
||||||
|
|
||||||
|
# a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro
|
||||||
|
rumo = Vector(REVISAO_CAMERA).normalized()
|
||||||
|
|
||||||
|
def na_frente(p):
|
||||||
|
return Vector(p) + rumo * (3.5 / -rumo.y)
|
||||||
|
|
||||||
|
a, b, c = (alvo for _, alvo, _, _ in soltos)
|
||||||
|
caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)),
|
||||||
|
(25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip
|
||||||
|
chegadas = (9, 21, 33)
|
||||||
|
selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita
|
||||||
|
de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro
|
||||||
|
|
||||||
|
def lupa_em(f):
|
||||||
|
for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]):
|
||||||
|
if f <= f1:
|
||||||
|
t = min(max((f - f0) / (f1 - f0), 0), 1)
|
||||||
|
return p0.lerp(p1, t * t * (3 - 2 * t))
|
||||||
|
return caminho[-1][1]
|
||||||
|
|
||||||
|
def pose(f):
|
||||||
|
centro = lupa_em(f)
|
||||||
|
for obj, desvio in lupa:
|
||||||
|
obj.location = centro + desvio
|
||||||
|
for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)):
|
||||||
|
s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar
|
||||||
|
obj.location = alvo + desloc * (1 - s)
|
||||||
|
obj.rotation_euler = giro * (1 - s)
|
||||||
|
obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana
|
||||||
|
d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3)
|
||||||
|
for obj, alvo in selo:
|
||||||
|
obj.location = alvo + d
|
||||||
|
|
||||||
|
return pose
|
||||||
|
|
||||||
|
|
||||||
|
# nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada)
|
||||||
|
PECAS = [
|
||||||
|
("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36),
|
||||||
|
("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56),
|
||||||
|
("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0),
|
||||||
|
("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36),
|
||||||
|
("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- ffmpeg
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg(*args):
|
||||||
|
fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"]
|
||||||
|
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def codificar(pasta, nome, poster):
|
||||||
|
destino = os.path.join(PUBLICO, f"peca-{nome}")
|
||||||
|
# quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores)
|
||||||
|
compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p"
|
||||||
|
entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"]
|
||||||
|
cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
|
||||||
|
ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1",
|
||||||
|
f"{destino}.webm") # fmt: skip
|
||||||
|
ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart",
|
||||||
|
f"{destino}.mp4") # fmt: skip
|
||||||
|
ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80",
|
||||||
|
f"{destino}.webp") # fmt: skip
|
||||||
|
for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)):
|
||||||
|
tamanho = os.path.getsize(f"{destino}.{ext}")
|
||||||
|
print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True)
|
||||||
|
if tamanho > limite:
|
||||||
|
falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Blender
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
cena = bpy.context.scene
|
||||||
|
cena.view_settings.view_transform = "Standard"
|
||||||
|
cena.world = bpy.data.worlds.new("fundo")
|
||||||
|
cena.render.use_persistent_data = True
|
||||||
|
ligar_cycles(cena)
|
||||||
|
luz_de_estudio(cena)
|
||||||
|
|
||||||
|
bloco = criar_bloco()
|
||||||
|
cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias
|
||||||
|
malha = bloco.data
|
||||||
|
bpy.data.objects.remove(bloco)
|
||||||
|
materiais = criar_materiais()
|
||||||
|
malha.materials.append(materiais[0])
|
||||||
|
|
||||||
|
camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera"))
|
||||||
|
cena.collection.objects.link(camera)
|
||||||
|
camera.data.lens = 85
|
||||||
|
cena.camera = camera
|
||||||
|
|
||||||
|
colecoes = {}
|
||||||
|
for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS:
|
||||||
|
col = bpy.data.collections.new(nome)
|
||||||
|
cena.collection.children.link(col)
|
||||||
|
colecoes[nome] = col
|
||||||
|
|
||||||
|
def novo(posicao, escala, tom, col=col):
|
||||||
|
obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha)
|
||||||
|
col.objects.link(obj)
|
||||||
|
obj.location = posicao
|
||||||
|
obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3
|
||||||
|
obj.material_slots[0].link = "OBJECT"
|
||||||
|
obj.material_slots[0].material = materiais[tom]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
pose = peca(novo)
|
||||||
|
for outro, c in colecoes.items():
|
||||||
|
c.hide_render = outro != nome
|
||||||
|
direcao = Vector(direcao).normalized()
|
||||||
|
camera.location = Vector(alvo) + direcao * distancia
|
||||||
|
camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler()
|
||||||
|
|
||||||
|
pasta = os.path.join(SAIDA, "pecas", nome)
|
||||||
|
os.makedirs(pasta, exist_ok=True)
|
||||||
|
for f in range(QUADROS):
|
||||||
|
pose(f)
|
||||||
|
sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True)
|
||||||
|
codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png"))
|
||||||
|
|
||||||
|
ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK
|
||||||
|
print(f"OK pecas={len(PECAS)}", flush=True)
|
||||||
|
sys.exit(0) # sai já, para o OK ser a última linha
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
|
After Width: | Height: | Size: 432 KiB |
|
After Width: | Height: | Size: 522 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 553 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
# DESIGN.md
|
||||||
|
|
||||||
|
Registro das decisões de desenho do evandro.dev.br. O código (`app/globals.css`, componentes) é a fonte dos valores; este arquivo diz o porquê.
|
||||||
|
|
||||||
|
## Conceito
|
||||||
|
|
||||||
|
```
|
||||||
|
Ideia: os blocos da marca são as peças com que se monta o site ou o sistema de cada cliente
|
||||||
|
Metáfora: kit de blocos de encaixe, como uma maquete
|
||||||
|
Assinatura: no hero, os blocos da marca se soltam e se remontam com a rolagem em tela, integrações e dados
|
||||||
|
Emoção: nos três primeiros segundos, "isto foi montado peça por peça"
|
||||||
|
Verbo: encaixar: chega rápido, assenta curto, sem quique
|
||||||
|
Quadro: a marca em porcelana com os quatro cubos verdes ao lado do título
|
||||||
|
```
|
||||||
|
|
||||||
|
Âncora: o símbolo é feito de blocos (`public/brand/mark-dark.svg`); o conceito não serve para outra marca.
|
||||||
|
|
||||||
|
## Paleta
|
||||||
|
|
||||||
|
| Papel | Token | Valor |
|
||||||
|
|---|---|---|
|
||||||
|
| Fundo escuro, tinta | `--ink` | `#0a0a0a` |
|
||||||
|
| Fundo claro | `--paper` | `#f4f2ed` |
|
||||||
|
| Fundo claro, segundo plano | `--paper-dark` | `#eceae4` |
|
||||||
|
| Acento | `--slash` | `#ccff00` |
|
||||||
|
| Texto secundário | `--meta` | `#4a4a4a` |
|
||||||
|
| Controles que abrem o WhatsApp | `--whatsapp` | `#25d366` |
|
||||||
|
|
||||||
|
O acento aparece em pouca área: um trecho do título, um botão, os cubos da marca. Verde sobre preto é a identidade da marca desde 2026-09, não uma escolha de tendência.
|
||||||
|
|
||||||
|
## Tipografia
|
||||||
|
|
||||||
|
- Display e corpo: Space Grotesk (`--font-display`).
|
||||||
|
- Rótulos, números e código: Geist Mono (`--font-mono`), 13 px, sem caixa alta, `letter-spacing` de 0,02em.
|
||||||
|
|
||||||
|
## Forma
|
||||||
|
|
||||||
|
Cantos retos por padrão (blocos, molduras, botões do hero). Raio aparece em poucos lugares: formulário de contato, botão do WhatsApp e indicadores circulares.
|
||||||
|
|
||||||
|
## Movimento
|
||||||
|
|
||||||
|
Só CSS e Three.js direto. Sem biblioteca de animação (GSAP, Motion e React Three Fiber saíram em 2026-10-14 por desempenho).
|
||||||
|
|
||||||
|
| Uso | Duração | Curva |
|
||||||
|
|---|---|---|
|
||||||
|
| Entrada de componente | 280 ms | `--ease-out` |
|
||||||
|
| Saída de componente | 180 ms | `--ease-in` |
|
||||||
|
| Entrada do título e do hero | 850 a 1100 ms | `--ease-out` |
|
||||||
|
| Troca de página | 600 ms; a capa compartilhada viaja em 500 ms | cortina em degraus de 8 colunas; `--ease-out` na capa |
|
||||||
|
| Detalhes de bloco (cursor, cortina) | conforme o caso | `steps()` |
|
||||||
|
|
||||||
|
Regras:
|
||||||
|
- Animam só `transform` (e `translate`, `scale`), `opacity`, `clip-path` e `mask`. Única exceção: a altura das perguntas frequentes ao abrir e fechar.
|
||||||
|
- O texto está no HTML desde o primeiro quadro; a animação nunca esconde conteúdo de quem não a recebe.
|
||||||
|
- Coisa grande se move devagar, detalhe se move rápido; escalonamento entre 20 e 80 ms.
|
||||||
|
- Com `prefers-reduced-motion: reduce` não há cena, rolagem suave nem animação; o conteúdo aparece inteiro.
|
||||||
|
|
||||||
|
## Resposta ao ponteiro e ao foco
|
||||||
|
|
||||||
|
Um tratamento por papel, sempre entrando pela esquerda em 280 ms com `--ease-out`; com movimento reduzido a troca é instantânea.
|
||||||
|
|
||||||
|
| Papel | Tratamento |
|
||||||
|
|---|---|
|
||||||
|
| Botão verde | um bloco cor de papel cobre o botão e o texto continua escuro; sobre fundo claro o bloco é escuro e o texto inverte |
|
||||||
|
| Botão de contorno | o mesmo bloco: translúcido no fundo escuro, escuro no fundo claro (o texto inverte) |
|
||||||
|
| Link de texto e item do menu | uma barra de 2 px cresce sob o texto |
|
||||||
|
| Item da vitrine | uma barra verde entra pela esquerda |
|
||||||
|
| Tocar vídeo | o círculo cresce e fica verde |
|
||||||
|
| Logo | os quatro quadrados verdes sobem em sequência e assentam |
|
||||||
|
|
||||||
|
"Falar sobre meu projeto" sempre leva ao formulário. Controle que abre o WhatsApp se chama "Chamar no WhatsApp".
|
||||||
|
|
||||||
|
## Cena 3D
|
||||||
|
|
||||||
|
Fonte em `3d/blocos.py` (Blender), saída em `public/3d/`. Um bloco chanfrado, repetido, com quatro estados: `marca`, `tela`, `integracoes`, `dados`.
|
||||||
|
|
||||||
|
| Nível (`sceneTier()` em `lib/device.ts`) | Quem recebe | O que recebe |
|
||||||
|
|---|---|---|
|
||||||
|
| `full` | computador com GPU, mais de 4 núcleos e mais de 4 GB | cena em tempo real guiada pela rolagem |
|
||||||
|
| `lite` | celular com 6 núcleos ou mais e 4 GB ou mais | cena em tempo real leve, que troca de estado sozinha |
|
||||||
|
| `static` | o resto, movimento reduzido, economia de dados, ou quem não segura 28 ms por quadro | pôster renderizado no Blender |
|
||||||
|
|
||||||
|
O pôster é o primeiro quadro de todos os níveis e o LCP continua sendo o título.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
web
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
- **Principal:** dono ou gerente de pequena ou média empresa de Joinville e região (comércio, serviços, indústria pequena). Não é técnico, chega pelo Google ou por indicação, decide sozinho e prefere falar pelo WhatsApp. Quer um site que traga pedidos ou um sistema para a operação.
|
||||||
|
- **Secundário:** recrutadores e empresas de tecnologia avaliando o Evandro como desenvolvedor. O site também precisa mostrar a eles o nível técnico do trabalho.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Site de Evandro Fonseca Junior, engenheiro da computação de Garuva, SC, que vende sites e sistemas sob medida. O site existe para levar o visitante a uma conversa pelo WhatsApp sobre o projeto dele. Sucesso é conversa iniciada com o tipo de projeto já identificado.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
"Você fala com quem desenvolve." A mesma pessoa entende o negócio, decide a estrutura e programa. Cuida de tela, dados e equipamentos (integração com hardware e IoT, experiência na Apolo Energia desde 2022). Usa IA como ferramenta e responde pelo resultado.
|
||||||
|
|
||||||
|
## Operating Context
|
||||||
|
|
||||||
|
- A conversa começa pelo WhatsApp, com a mensagem já montada pelo formulário; nada é enviado automaticamente.
|
||||||
|
- Ofertas atuais: site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema feito com IA (`/ia`).
|
||||||
|
- Páginas de serviço em `/servicos` e casos em `/cases`.
|
||||||
|
|
||||||
|
## Capabilities and Constraints
|
||||||
|
|
||||||
|
- Sem preço e sem prazo no site: isso se combina na conversa.
|
||||||
|
- O título é o primeiro conteúdo a pintar (LCP); o site funciona sem JavaScript e com movimento reduzido.
|
||||||
|
- 3D e vídeo só onde o aparelho aguenta; quem não aguenta recebe a versão estática.
|
||||||
|
- Métricas de navegação só com consentimento.
|
||||||
|
|
||||||
|
## Brand Commitments
|
||||||
|
|
||||||
|
- Nome: Evandro.dev.br. Símbolo de blocos (`public/brand/mark-dark.svg`), verde `#ccff00` sobre preto.
|
||||||
|
- Voz: português direto, frase curta, sem jargão, sem promessa que não dá para provar.
|
||||||
|
|
||||||
|
## Evidence on Hand
|
||||||
|
|
||||||
|
- Clientes reais: SEV Moda Íntima, Lavação 047, SC Plus Aquicultura, AS Serviços, Julia Beauty, com vídeos e capturas em `public/videos/`.
|
||||||
|
- 5 anos de experiência, calculados a partir de `public/portfolio-data.json`.
|
||||||
|
- Casos de falha em sistemas feitos com IA, com fonte, em `app/ia/page.tsx`.
|
||||||
|
- Não há depoimento com número, prêmio nem métrica de resultado de cliente: não inventar.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
1. Cada afirmação tem fonte ou prova à vista; o que não foi provado é dito como não provado.
|
||||||
|
2. Mostrar trabalho real antes de falar de tecnologia.
|
||||||
|
3. O visitante leigo entende em três segundos o que é oferecido e como falar.
|
||||||
|
4. Desempenho e acessibilidade vêm antes do efeito.
|
||||||
|
5. A IA aparece como ferramenta sob responsabilidade de um engenheiro, nunca como inimiga de quem contrata.
|
||||||
@@ -1,205 +1,125 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# Evandro.dev.br: software sob medida
|
<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">
|
||||||
|
|
||||||
Site comercial de Evandro Fonseca Junior para projetos de software sob medida: sistemas empresariais, aplicativos e integrações, com cases e contato direto por WhatsApp.
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[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)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Requisitos
|
| 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"> |
|
||||||
|
|
||||||
- Node.js `22.x`
|
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).
|
||||||
- pnpm `9.15.9`, fixado em `package.json#packageManager`
|
|
||||||
|
|
||||||
O projeto usa Next.js `16.3.3`, patch de segurança que corrige [GHSA-p293-qw3h-jr36](https://github.com/advisories/GHSA-p293-qw3h-jr36) e [GHSA-2xp9-vwfh-vxw4](https://github.com/advisories/GHSA-2xp9-vwfh-vxw4). A atualização substitui `16.3.0`, afetada por esses avisos. As auditorias de produção e desenvolvimento continuam sendo verificadas na CI.
|
## 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>
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| `/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/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 |
|
||||||
|
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf` |
|
||||||
|
| `/privacidade` | Privacidade e consentimento de analytics |
|
||||||
|
|
||||||
|
```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)
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Início rápido
|
## Início rápido
|
||||||
|
|
||||||
|
Requisitos: Node.js `22.x` (`engines`), pnpm `9.15.9` (`packageManager`) e, só para `pnpm test:e2e`, o Chromium do Playwright.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
corepack [email protected] install --frozen-lockfile
|
corepack [email protected] install --frozen-lockfile
|
||||||
corepack [email protected] dev
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
O servidor de desenvolvimento usa Turbopack. Para executar o artefato de produção:
|
A instalação é sempre com `--frozen-lockfile`, igual ao CI e ao Nixpacks.
|
||||||
|
|
||||||
```bash
|
> [!IMPORTANT]
|
||||||
corepack [email protected] build
|
> 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`.
|
||||||
corepack [email protected] start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scripts
|
Validação completa, na mesma ordem do CI:
|
||||||
|
|
||||||
| Comando | 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` | Gera os tipos de rota com `next typegen` e 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`. |
|
|
||||||
|
|
||||||
## Vitrine de vídeos dos projetos
|
|
||||||
|
|
||||||
Cada projeto com site gravado aparece de três formas na seção Projetos da home. Tudo mora em `app/components/showcase/`, os dados em `lib/project-showcase.ts` e os estilos em `showcase.css`.
|
|
||||||
|
|
||||||
- **Cards.** O vídeo de rolagem fica numa moldura de navegador com o endereço real do site. Só um vídeo toca por vez: o do card sob o mouse (ou com foco) e, sem mouse, o mais perto do centro da tela. Nada é baixado até o card tocar pela primeira vez, e um botão pausa os vídeos que se mexem sozinhos. Clicar na moldura abre o vídeo ampliado (`<dialog>` nativo, com troca computador/celular que mantém o ponto do site, capítulos clicáveis e controles próprios).
|
|
||||||
- **Vitrine animada.** Logo abaixo dos cards, uma cena presa na rolagem (`position: sticky`) em que o site de cada projeto rola dentro de um notebook, com um celular ao lado, conforme o visitante rola a página. Os capítulos de cada projeto (`chapters` em `lib/project-showcase.ts`, marcados nos quadros dos vídeos) nomeiam o trecho na tela e mantêm notebook e celular no mesmo ponto do site. Só o projeto em cena e os vizinhos baixam vídeo, e nada baixa com "economizar dados" ligado. Com `prefers-reduced-motion` ou sem JavaScript ela some e os cards continuam mostrando os posters.
|
|
||||||
- **Capa de compartilhamento.** `/cases/<id>` usa o poster do projeto como fundo da imagem Open Graph (`lib/og-card.tsx`), quando existe.
|
|
||||||
|
|
||||||
Arquivos de cada projeto em `public/videos/<id>/`:
|
|
||||||
|
|
||||||
| Arquivo | Para quê |
|
|
||||||
| --- | --- |
|
|
||||||
| `desktop.{webm,mp4,webp}` e `mobile.{webm,mp4,webp}` | cards e vídeo ampliado (até 3 MB cada vídeo) |
|
|
||||||
| `scrub-desktop.mp4` | vitrine animada: quadro-chave a cada 6 quadros, sem B-frames, para o tempo mudar a cada quadro da rolagem (o celular da moldura mostra só o pôster) |
|
|
||||||
| `og.jpg` | capa 1200x630 de compartilhamento |
|
|
||||||
|
|
||||||
Para gravar ou regravar um projeto, use `scripts/record-project-videos.mjs`; para gerar os `scrub-*.mp4`, `scripts/encode-scrub-video.mjs` (as instruções de instalação do Playwright e do ffmpeg estão no cabeçalho de cada script). Depois de gravar, marque os capítulos do projeto em `lib/project-showcase.ts`.
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Área | Pacotes usados |
|
|
||||||
| --- | --- |
|
|
||||||
| Aplicação | Next.js 16.3.3, React 19.1.0, React DOM 19.1.0 e TypeScript 5.9.2. |
|
|
||||||
| Estilos e composição | Tailwind CSS 4, `clsx` e `tailwind-merge`. |
|
|
||||||
| Movimento | Motion 12 e GSAP 3. |
|
|
||||||
| 3D e gráficos | Three.js, React Three Fiber e Drei. |
|
|
||||||
| Interface | Lucide React. |
|
|
||||||
| Qualidade | Biome, Jest, Testing Library e ambiente JSDOM. |
|
|
||||||
|
|
||||||
Todas as dependências diretas estão fixadas em versões exatas no manifesto; o lockfile é instalado sem atualização implícita.
|
|
||||||
|
|
||||||
## Rotas
|
|
||||||
|
|
||||||
| Rota | Conteúdo |
|
|
||||||
| --- | --- |
|
|
||||||
| `/` | Homepage comercial com hero, notebook 3D, projetos, soluções, processo de trabalho, engenharia com IA, critérios de entrega, perfil 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` | Página de destino comercial: sites, sistemas e aplicativos, contato direto no WhatsApp, cases, processo e acesso aos seis serviços. |
|
|
||||||
| `/servicos/criacao-de-sites` | Criação de sites. |
|
|
||||||
| `/servicos/engenharia-de-software` | Sistemas web e aplicativos mobile sob medida. |
|
|
||||||
| `/servicos/sob-medida` | Redirecionamento permanente (308) para `/servicos/engenharia-de-software`, preservando query string, UTMs e identificadores de clique. |
|
|
||||||
| `/servicos/consultoria-nextjs` | Consultoria Next.js. |
|
|
||||||
| `/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` | Arquitetura de software. |
|
|
||||||
| `/curriculo` | Currículo em HTML, com download de `/Profile.pdf`. |
|
|
||||||
|
|
||||||
O App Router também gera `/manifest.webmanifest`, `/robots.txt`, `/sitemap.xml`, `/opengraph-image`, `/icon.png`, `/apple-icon.png` 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. Na homepage, competências e experiência também ficam recolhidas. O header prioriza Soluções (`/#modalities`), Projetos (`/cases`), Como trabalho (`/#process`), Sobre (`/#about`) e Contato (`/#contact`), com ajuste de chegada após a definição do layout do notebook. Currículo e download do PDF são links secundários no final do rodapé.
|
|
||||||
|
|
||||||
## Comportamento da aplicação
|
|
||||||
|
|
||||||
### Conteúdo e project health
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
### Posicionamento comercial e uso de IA
|
|
||||||
|
|
||||||
A comunicação apresenta Evandro como responsável direto pelo projeto, sem sugerir equipe, disponibilidade garantida, prazo fixo ou certificação. Os cards de projetos reutilizam os desafios e resumos dos cases quando disponíveis. O antigo painel de telemetria fictícia foi removido; os critérios de qualidade não representam notas de auditoria.
|
|
||||||
|
|
||||||
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
|
|
||||||
|
|
||||||
`app/ia/page.tsx` (rota `/ia`) explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 (na seção recolhida "O que casos documentados ensinam") e links junto de cada afirmação:
|
|
||||||
|
|
||||||
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
|
|
||||||
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
|
|
||||||
- Base44: falha de autenticação identificada pela Wiz, corrigida em menos de 24 horas, sem evidência reportada de exploração maliciosa.
|
|
||||||
|
|
||||||
As obrigações dos pequenos agentes de tratamento são referenciadas à Resolução CD/ANPD nº 2/2022, arts. 6º, 12 e 13. Ao atualizar esse conteúdo, preservar datas, limites dos relatos e a distinção entre vulnerabilidade, exposição e incidente confirmado.
|
|
||||||
|
|
||||||
O formulário valida os campos no navegador e abre um rascunho no WhatsApp para revisão. Ele não envia a mensagem automaticamente e orienta a não informar senhas nem dados pessoais de clientes.
|
|
||||||
|
|
||||||
A seção de contato tem título estático único, canais diretos à esquerda e formulário à direita (empilhados no celular). A silhueta do WhatsApp aparece no contato direto, no cabeçalho do formulário e no botão verde. O formulário não se move no hover; campos mantêm rótulos, validação nativa e foco visível. O link direto dispensa preenchimento e o e-mail usa `mailto:`.
|
|
||||||
|
|
||||||
Na página `/servicos` e nos seis detalhamentos, `WhatsAppLink` oferece um link nativo com mensagem contextual, sem exigir o formulário da home. Funciona mesmo sem consentimento de analytics ou JavaScript; a medição depende da hidratação e da tag. A página principal prioriza as três intenções dos anúncios antes das especialidades técnicas e usa os cases existentes NutriScan e SC Plus como exemplos de trabalho, não como promessa de retorno comercial.
|
|
||||||
|
|
||||||
### 3D progressivo e movimento reduzido
|
|
||||||
|
|
||||||
O hero combina a mensagem comercial com três placas 3D: interface, integrações e dados. A interface usa a captura real do NutriScan, recortada acima do rodapé; as outras camadas são ilustrativas, não uma declaração da arquitetura do projeto. Ponteiro, arraste horizontal e quatro botões permitem explorar a composição. O toque vertical continua rolando a página. A renderização para quando os movimentos terminam, fora do viewport e com a aba oculta. Movimento reduzido ou falha gráfica mantêm a captura HTML, os controles e o link do case disponíveis.
|
|
||||||
|
|
||||||
O notebook 3D permanece em celulares, tablets e desktop: tampa, tela, abas e cursor seguem o progresso do scroll. No mobile, a câmera adapta o enquadramento e botões externos à tela do notebook permitem selecionar os projetos. A cena renderiza sob demanda e pausa fora do viewport ou com a página oculta. Movimento reduzido, falha de WebGL ou perda de contexto ativam a navegação HTML sem animação.
|
|
||||||
|
|
||||||
As quatro capturas desktop reais ficam em `public/assets/notebook/`, em WebP de 1600 × 1000. A troca aguarda a decodificação da próxima imagem, mantendo a anterior visível durante o carregamento ou uma falha.
|
|
||||||
|
|
||||||
Uma entrada normal na homepage começa no topo. A recarga também volta ao início, removendo o fragmento deixado por uma navegação anterior. Links diretos como `/#about` continuam chegando à seção após a estabilização do notebook; rolagem deliberada durante a abertura cancela esse ajuste. A política inicial de scroll é aplicada antes da hidratação, sem disputar com a rolagem suave dos links posteriores. Assim que a chegada se resolve, a restauração volta a pertencer ao navegador: Voltar e Avançar restauram a posição do histórico em vez de forçar o topo.
|
|
||||||
|
|
||||||
### Identidade visual
|
|
||||||
|
|
||||||
O header usa `--site-header-height`: 60px abaixo de 1280px e 68px a partir desse breakpoint. A mesma variável define a posição do menu, o respiro inicial do hero e a compensação das âncoras. O menu mobile tem fundo opaco; a barra fica levemente translúcida ao rolar. Header e hero usam Space Grotesk para leitura comercial, reservando a fonte monoespaçada aos detalhes técnicos das demais seções.
|
|
||||||
|
|
||||||
Os quatro arquivos recebidos estão preservados, com nomes descritivos, em `imagens/originais/`. As versões prontas para uso ficam em `public/brand/`:
|
|
||||||
|
|
||||||
| Arquivo | Aplicação |
|
|
||||||
| --- | --- |
|
|
||||||
| `wordmark-dark.webp` | Logo horizontal transparente, branca e lima, no cabeçalho escuro. |
|
|
||||||
| `wordmark-light.webp` | Logo horizontal transparente, escura e lima, no rodapé claro. |
|
|
||||||
| `stacked-light.webp` | Logo empilhada transparente, reservada para composições maiores em fundo claro. |
|
|
||||||
| `mark-dark.svg` / `mark-light.svg` | Símbolo vetorial para fundos escuros e claros, traçado a partir do original limpo. |
|
|
||||||
| `mark-dark.png` | Símbolo de 512px incorporado aos cards Open Graph de todas as rotas. |
|
|
||||||
| `icon-192.png` | Ícone de instalação do manifesto. |
|
|
||||||
|
|
||||||
`app/icon.png` (512px), `app/apple-icon.png` (180px) e `app/favicon.ico` (16–256px) usam o símbolo sobre fundo escuro. As variantes mantêm as formas originais; o recorte transparente com bordas danificadas permanece arquivado, sem uso na interface.
|
|
||||||
|
|
||||||
### Contato
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Consentimento de analytics
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
O único loader `gtag.js` configura GA4 `G-QTT9QT0T3S` e Google Ads `AW-18432694647`. Os IDs `GT-M3K4CKQT` e `GT-NBBR3PC2` informados no painel são identificadores das tags, não instalações adicionais. A integração segue https://developers.google.com/tag-platform/gtagjs/configure. `ad_storage`, `ad_user_data` e `ad_personalization` permanecem negados, com `ads_data_redaction` ativo. A tag só é montada em produção.
|
|
||||||
|
|
||||||
### Medição do contato e Google Ads
|
|
||||||
|
|
||||||
`lib/analytics.ts` envia `whatsapp_open` exclusivamente ao GA4 após o acionamento de um link ou a submissão válida do formulário. O evento representa **tentativa de abrir uma conversa**, não mensagem recebida, lead confirmado ou venda. O contato não espera a resposta do Analytics. Eventos recusados ou ocorridos antes do consentimento não são guardados para envio posterior.
|
|
||||||
|
|
||||||
Parâmetros enviados: `service` (rótulo estático), `placement` (posição do CTA) e `page_path`. A URL do evento não inclui query, fragmento ou referenciador; rotas dinâmicas são agrupadas como `/servicos/[slug]` e `/cases/[slug]`. Nome, e-mail, mensagem digitada e URL de destino do WhatsApp não entram nesse evento. A distinção do serviço usa o parâmetro `service`.
|
|
||||||
|
|
||||||
Posições: `service_hub_hero`, `service_hub_intent`, `service_hub_footer`, `service_detail_hero`, `service_detail_footer`, `contact_direct` e `contact_form`. Isso permite comparar entradas de contato pelo tipo de serviço e pela posição na página. Cliques pelo menu de contexto, bloqueadores e falhas de rede podem não ser medidos.
|
|
||||||
|
|
||||||
**Configuração externa ainda necessária:** verificar `whatsapp_open` no GA4, registrá-lo como evento principal, vincular a propriedade à conta Ads correta e importar essa ação de conversão. Conferir a ação existente antes para evitar dupla contagem; não importar simultaneamente um clique automático como a mesma conversão. Para analisar os parâmetros em Explorações, cadastrar dimensões personalizadas de escopo de evento para `service` e `placement`. Nenhum rótulo de conversão Ads foi inventado e nenhuma configuração da conta foi alterada. A medição otimiza intenção de contato, não vendas.
|
|
||||||
|
|
||||||
Também revisar as opções de medição otimizada da propriedade, sobretudo eventos automáticos de cliques externos e formulários, pois essa configuração não é controlada por este código. Referência dos eventos: https://developers.google.com/analytics/devguides/collection/ga4/events.
|
|
||||||
|
|
||||||
Verificação local: build de produção, navegação responsiva, redirecionamento com parâmetros e requisição real `whatsapp_open` gerada pela tag. Requisições de coleta foram interceptadas antes do envio para não contaminar os relatórios; isso **não comprova recebimento no GA4 nem atribuição no Ads**.
|
|
||||||
|
|
||||||
## Estrutura
|
|
||||||
|
|
||||||
```text
|
|
||||||
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 modelo 3D
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI e deploy
|
|
||||||
|
|
||||||
O workflow único em `.github/workflows/ci.yml` roda em cada `push` e `pull_request` com permissão `contents: read`. 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. O passo de typecheck gera os tipos de rota antes do `tsc`: `next-env.d.ts` não é versionado e, em uma árvore recém-clonada, é ele que declara os módulos de imagem importados pelo código.
|
|
||||||
|
|
||||||
O Nixpacks 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.
|
|
||||||
|
|
||||||
## Validação local
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
corepack [email protected] install --frozen-lockfile
|
corepack [email protected] install --frozen-lockfile
|
||||||
@@ -210,3 +130,40 @@ corepack [email protected] build
|
|||||||
corepack [email protected] audit --prod --audit-level=high
|
corepack [email protected] audit --prod --audit-level=high
|
||||||
corepack [email protected] audit --dev --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`.
|
||||||
|
|
||||||
|
Outros scripts: `pnpm format` (Biome), `pnpm test:watch` (Jest em modo watch) e `pnpm check:projects` (atualiza `data/project-health.json`).
|
||||||
|
|
||||||
|
## Configuração
|
||||||
|
|
||||||
|
Não há `.env.example`, e nenhuma variável é necessária para rodar localmente. O código lê:
|
||||||
|
|
||||||
|
| 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>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O React estável do Jest não exporta <ViewTransition> (só o canary do Next). Para conferir as props dele:
|
||||||
|
* `jest.mock("react", () => jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe());`
|
||||||
|
* O dublê põe `name`, `share` e `default` num <div> em volta dos filhos (data-vt-name, data-vt-share, data-vt-default).
|
||||||
|
*/
|
||||||
|
export function reactWithProbe() {
|
||||||
|
const actual = jest.requireActual("react");
|
||||||
|
function ViewTransition({
|
||||||
|
children,
|
||||||
|
name,
|
||||||
|
share,
|
||||||
|
default: fallback,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
name?: string;
|
||||||
|
share?: string;
|
||||||
|
default?: string;
|
||||||
|
}) {
|
||||||
|
return actual.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
"data-vt-name": name,
|
||||||
|
"data-vt-share": share,
|
||||||
|
"data-vt-default": fallback,
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...actual, ViewTransition };
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ describe("opengraph-image Image", () => {
|
|||||||
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
|
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
|
||||||
expect(
|
expect(
|
||||||
all.filter(({ contentType }) => contentType === "image/jpeg"),
|
all.filter(({ contentType }) => contentType === "image/jpeg"),
|
||||||
).toHaveLength(5);
|
).toHaveLength(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: unknown slug -> notFound() and no image", async () => {
|
it("path: unknown slug -> notFound() and no image", async () => {
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
|||||||
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
||||||
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
|
// o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos)
|
||||||
const contacts = screen.getAllByRole("link", {
|
const contacts = screen.getAllByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(contacts).toHaveLength(1);
|
expect(contacts).toHaveLength(1);
|
||||||
expect(contacts[0]).toBeVisible();
|
expect(contacts[0]).toBeVisible();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const clientCaseIds = [
|
|||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
"sc-plus",
|
"sc-plus",
|
||||||
"as-servicos",
|
"as-servicos",
|
||||||
|
"julia-beauty",
|
||||||
];
|
];
|
||||||
const labCaseIds = [
|
const labCaseIds = [
|
||||||
"nutriscan",
|
"nutriscan",
|
||||||
@@ -61,8 +62,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
|
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
|
||||||
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
* ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
||||||
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos.
|
* `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos.
|
||||||
*/
|
*/
|
||||||
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||||
const hrefs = screen
|
const hrefs = screen
|
||||||
@@ -71,7 +72,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
|||||||
expect(hrefs).not.toContain("/#contact");
|
expect(hrefs).not.toContain("/#contact");
|
||||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||||
|
|
||||||
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
|
const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp });
|
||||||
expect(ctas).toHaveLength(1);
|
expect(ctas).toHaveLength(1);
|
||||||
for (const cta of ctas) {
|
for (const cta of ctas) {
|
||||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||||
@@ -86,15 +87,21 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it("exposes the eight public cases, the two new client cases among them", async () => {
|
it("exposes the nine public cases, the new client cases among them", async () => {
|
||||||
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
|
||||||
expect(staticSlugs).toHaveLength(8);
|
expect(staticSlugs).toHaveLength(9);
|
||||||
expect(new Set(staticSlugs).size).toBe(8);
|
expect(new Set(staticSlugs).size).toBe(9);
|
||||||
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
|
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
|
||||||
expect(staticSlugs).toContain("sev-modaintima");
|
expect(staticSlugs).toContain("sev-modaintima");
|
||||||
expect(staticSlugs).toContain("lavacao-047");
|
expect(staticSlugs).toContain("lavacao-047");
|
||||||
|
expect(staticSlugs).toContain("julia-beauty");
|
||||||
|
|
||||||
for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) {
|
for (const id of [
|
||||||
|
"sev-modaintima",
|
||||||
|
"lavacao-047",
|
||||||
|
"as-servicos",
|
||||||
|
"julia-beauty",
|
||||||
|
]) {
|
||||||
const caseStudy = getCase(id);
|
const caseStudy = getCase(id);
|
||||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||||
const { metaTitle: title, metaDescription: description } = caseStudy;
|
const { metaTitle: title, metaDescription: description } = caseStudy;
|
||||||
@@ -172,13 +179,13 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
|||||||
const capabilitiesSection = sectionHeadings[4].closest("section");
|
const capabilitiesSection = sectionHeadings[4].closest("section");
|
||||||
expect(
|
expect(
|
||||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const closingSection = sectionHeadings[5].closest("section");
|
const closingSection = sectionHeadings[5].closest("section");
|
||||||
expect(closingSection).toContainElement(
|
expect(closingSection).toContainElement(
|
||||||
within(closingSection as HTMLElement).getByRole("link", {
|
within(closingSection as HTMLElement).getByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const capability of caseStudy.capabilities ?? []) {
|
for (const capability of caseStudy.capabilities ?? []) {
|
||||||
@@ -364,10 +371,10 @@ it("writes what the client can do now right after 'Como ficou', with no contact
|
|||||||
expect(items.length).toBeLessThanOrEqual(3);
|
expect(items.length).toBeLessThanOrEqual(3);
|
||||||
// sem número inventado
|
// sem número inventado
|
||||||
expect(items.join(" ")).not.toMatch(/\d/);
|
expect(items.join(" ")).not.toMatch(/\d/);
|
||||||
// o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos
|
// o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos
|
||||||
expect(
|
expect(
|
||||||
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
within(capabilitiesSection as HTMLElement).queryByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
const closingSection = screen
|
const closingSection = screen
|
||||||
@@ -379,7 +386,7 @@ it("writes what the client can do now right after 'Como ficou', with no contact
|
|||||||
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
|
expect(capabilitiesSection?.nextElementSibling).toBe(closingSection);
|
||||||
expect(
|
expect(
|
||||||
within(closingSection as HTMLElement).getAllByRole("link", {
|
within(closingSection as HTMLElement).getAllByRole("link", {
|
||||||
name: buttonLabels.contact,
|
name: buttonLabels.whatsapp,
|
||||||
}),
|
}),
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -470,7 +477,7 @@ it("shows at most two related services, as cards under the contact button", asyn
|
|||||||
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
// o botão do fecho (o único) vem antes dos cartões
|
// o botão do fecho (o único) vem antes dos cartões
|
||||||
const contact = screen.getByRole("link", { name: buttonLabels.contact });
|
const contact = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
||||||
expect(isBefore(contact, cards[0])).toBe(true);
|
expect(isBefore(contact, cards[0])).toBe(true);
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
@@ -680,6 +687,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
|||||||
"lavacao-047",
|
"lavacao-047",
|
||||||
"sc-plus",
|
"sc-plus",
|
||||||
"as-servicos",
|
"as-servicos",
|
||||||
|
"julia-beauty",
|
||||||
]);
|
]);
|
||||||
const clientLinks = listedIds.map((id) => {
|
const clientLinks = listedIds.map((id) => {
|
||||||
const card = container
|
const card = container
|
||||||
@@ -740,7 +748,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
|||||||
expect(hrefs).not.toContain("/#contact");
|
expect(hrefs).not.toContain("/#contact");
|
||||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||||
expect(hrefs).toContain("/servicos");
|
expect(hrefs).toContain("/servicos");
|
||||||
const cta = screen.getByRole("link", { name: buttonLabels.contact });
|
const cta = screen.getByRole("link", { name: buttonLabels.whatsapp });
|
||||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||||
|
|
||||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
|
* Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem
|
||||||
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto"
|
* botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp"
|
||||||
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
|
* seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => {
|
|||||||
for (const item of capabilities) {
|
for (const item of capabilities) {
|
||||||
expect(screen.getByText(item)).toBeVisible();
|
expect(screen.getByText(item)).toBeVisible();
|
||||||
}
|
}
|
||||||
// o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção
|
// o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção
|
||||||
expect(screen.queryByRole("link")).toBeNull();
|
expect(screen.queryByRole("link")).toBeNull();
|
||||||
expect(container.querySelectorAll("a")).toHaveLength(0);
|
expect(container.querySelectorAll("a")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content";
|
|||||||
/**
|
/**
|
||||||
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
|
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam).
|
||||||
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
|
* Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois
|
||||||
* "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece.
|
* "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece.
|
||||||
*/
|
*/
|
||||||
export function CaseCapabilitiesSection({
|
export function CaseCapabilitiesSection({
|
||||||
caseStudy,
|
caseStudy,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("CaseContactSection", () => {
|
|||||||
name: "Quer um projeto parecido com este?",
|
name: "Quer um projeto parecido com este?",
|
||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
expect(cta).toHaveAttribute("target", "_blank");
|
expect(cta).toHaveAttribute("target", "_blank");
|
||||||
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||||
expect(messageOf(cta)).toContain(
|
expect(messageOf(cta)).toContain(
|
||||||
@@ -48,7 +48,7 @@ describe("CaseContactSection", () => {
|
|||||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
||||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||||
expect(messageOf(cta)).toContain(
|
expect(messageOf(cta)).toContain(
|
||||||
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
||||||
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
|
* O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também:
|
||||||
* (e no botão do cabeçalho): `similarProjectSubject`.
|
* `similarProjectSubject`.
|
||||||
*/
|
*/
|
||||||
const contactCopy = {
|
const contactCopy = {
|
||||||
client: { title: "Quer um projeto parecido com este?" },
|
client: { title: "Quer um projeto parecido com este?" },
|
||||||
@@ -47,7 +47,7 @@ export function CaseContactSection({
|
|||||||
placement="case_detail"
|
placement="case_detail"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||||
Ver outros cases
|
Ver outros cases
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import { caseStudies } from "@/lib/site-content";
|
||||||
import { CaseCover, coverSizes } from "./CaseCover";
|
import { CaseCover, coverSizes } from "./CaseCover";
|
||||||
import { getCaseMedia } from "./case-media";
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { coverTransitionProps } from "./cover-transition";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
|
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster),
|
||||||
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
|
* os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`)
|
||||||
|
* e o pôster como elemento compartilhado da troca de página.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||||
|
|
||||||
|
// o dublê do <ViewTransition> põe as props num <div> para o teste ler
|
||||||
|
jest.mock("react", () =>
|
||||||
|
jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(),
|
||||||
|
);
|
||||||
|
|
||||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||||
const props = {
|
const props = {
|
||||||
id: "sev-modaintima",
|
id: "sev-modaintima",
|
||||||
@@ -22,6 +30,23 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("CaseCover", () => {
|
describe("CaseCover", () => {
|
||||||
|
it("path: capture found -> the poster is the shared element of the page change (case-<id>, only the pair animates)", () => {
|
||||||
|
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
|
||||||
|
render(<CaseCover {...props} />);
|
||||||
|
|
||||||
|
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
|
||||||
|
const transition = image.parentElement;
|
||||||
|
expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`);
|
||||||
|
expect(transition).toHaveAttribute("data-vt-share", "case-cover");
|
||||||
|
expect(transition).toHaveAttribute("data-vt-default", "none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => {
|
||||||
|
const names = caseStudies.map(({ id }) => coverTransitionProps(id).name);
|
||||||
|
for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
});
|
||||||
|
|
||||||
it("path: no desktop capture -> renders nothing", () => {
|
it("path: no desktop capture -> renders nothing", () => {
|
||||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||||
const { container } = render(<CaseCover {...props} />);
|
const { container } = render(<CaseCover {...props} />);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { ViewTransition } from "react";
|
||||||
import {
|
import {
|
||||||
BrowserFrame,
|
BrowserFrame,
|
||||||
displayUrl,
|
displayUrl,
|
||||||
} from "@/app/components/showcase/BrowserFrame";
|
} from "@/app/components/showcase/BrowserFrame";
|
||||||
import { getCaseMedia } from "./case-media";
|
import { getCaseMedia } from "./case-media";
|
||||||
|
import { coverTransitionProps } from "./cover-transition";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
|
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
|
||||||
@@ -54,14 +56,16 @@ export function CaseCover({
|
|||||||
return (
|
return (
|
||||||
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
<BrowserFrame url={displayUrl({ demoUrl, title })}>
|
||||||
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
|
||||||
<Image
|
<ViewTransition {...coverTransitionProps(id)}>
|
||||||
src={media.poster}
|
<Image
|
||||||
alt={`Captura de tela do site ${title}`}
|
src={media.poster}
|
||||||
fill
|
alt={`Captura de tela do site ${title}`}
|
||||||
sizes={sizes}
|
fill
|
||||||
{...priorityProps}
|
sizes={sizes}
|
||||||
className="object-cover object-top"
|
{...priorityProps}
|
||||||
/>
|
className="object-cover object-top"
|
||||||
|
/>
|
||||||
|
</ViewTransition>
|
||||||
</div>
|
</div>
|
||||||
</BrowserFrame>
|
</BrowserFrame>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export function CaseHero({
|
|||||||
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}
|
||||||
<p className="ds-eyebrow">
|
<p className="ds-eyebrow">
|
||||||
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
|
{isClient ? "Case de cliente" : "Projeto de laboratório"}
|
||||||
{caseStudy.title}
|
|
||||||
</p>
|
</p>
|
||||||
<h1 className="ds-section-title text-balance text-white">
|
<h1 className="ds-section-title text-balance text-white">
|
||||||
{caseStudy.headline}
|
{caseStudy.headline}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function CaseMediaSection({
|
|||||||
className={secondaryButtonDark}
|
className={secondaryButtonDark}
|
||||||
href={caseStudy.demoUrl}
|
href={caseStudy.demoUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
{buttonLabels.site}
|
{buttonLabels.site}
|
||||||
<span className="sr-only"> {caseStudy.title}</span>
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
|||||||
@@ -27,9 +27,18 @@ describe("CasePlayer", () => {
|
|||||||
name: `Reproduzir o vídeo do site ${title} no computador`,
|
name: `Reproduzir o vídeo do site ${title} no computador`,
|
||||||
});
|
});
|
||||||
expect(button).toHaveAttribute("type", "button");
|
expect(button).toHaveAttribute("type", "button");
|
||||||
// só botão de contato é limão: o de play é escuro, com contorno branco
|
// em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior
|
||||||
expect(button.innerHTML).not.toContain("--slash");
|
const circle = button.querySelector("[aria-hidden='true']");
|
||||||
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
|
const restClasses = (circle?.className ?? "")
|
||||||
|
.split(" ")
|
||||||
|
.filter((name) => !name.startsWith("group-"));
|
||||||
|
expect(restClasses.join(" ")).not.toContain("--slash");
|
||||||
|
expect(circle).toHaveClass(
|
||||||
|
"group-hover:scale-108",
|
||||||
|
"group-hover:bg-[var(--slash)]",
|
||||||
|
"group-focus-visible:scale-108",
|
||||||
|
"group-focus-visible:bg-[var(--slash)]",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
|
||||||
|
|||||||
@@ -78,9 +78,10 @@ export function CasePlayer({
|
|||||||
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
|
||||||
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
|
||||||
>
|
>
|
||||||
|
{/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
|
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-[scale] duration-[280ms] ease-[var(--ease-out)] group-hover:scale-108 group-hover:border-[var(--slash)] group-hover:bg-[var(--slash)] group-hover:text-[var(--ink)] group-focus-visible:scale-108 group-focus-visible:border-[var(--slash)] group-focus-visible:bg-[var(--slash)] group-focus-visible:text-[var(--ink)] motion-reduce:transition-none sm:size-16"
|
||||||
>
|
>
|
||||||
<Play className="ml-1 size-6" fill="currentColor" />
|
<Play className="ml-1 size-6" fill="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* A capa como elemento compartilhado da troca de página: com o mesmo nome na lista (home, /cases) e no topo do case,
|
||||||
|
* ela viaja de um lugar ao outro (classe case-cover em globals.css). Só a troca entre as duas anima
|
||||||
|
* (`default="none"`). Duas instâncias com o mesmo nome na mesma página cancelam a transição: cada página mostra
|
||||||
|
* a capa de um case uma vez só. Fica fora de CaseCover porque a lista da home é componente de cliente, e
|
||||||
|
* CaseCover lê as capturas só no servidor.
|
||||||
|
*/
|
||||||
|
export function coverTransitionProps(id: string) {
|
||||||
|
return { name: `case-${id}`, share: "case-cover", default: "none" } as const;
|
||||||
|
}
|
||||||
@@ -43,11 +43,14 @@ describe("CasesPage: carregamento das capas", () => {
|
|||||||
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
||||||
const { container } = render(<CasesPage />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
|
// sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade
|
||||||
const decorative = Array.from(
|
const decorative = Array.from(
|
||||||
container.querySelectorAll<HTMLImageElement>("img"),
|
container.querySelectorAll<HTMLImageElement>("img"),
|
||||||
).filter((image) => image.getAttribute("alt") === "");
|
).filter((image) => image.getAttribute("alt") === "");
|
||||||
expect(decorative).toHaveLength(0);
|
expect(decorative).toHaveLength(1);
|
||||||
|
expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp");
|
||||||
|
expect(decorative[0]).toHaveAttribute("loading", "lazy");
|
||||||
|
expect(decorative[0]).not.toHaveAttribute("fetchpriority");
|
||||||
|
|
||||||
const [first, ...others] = coverImages(container);
|
const [first, ...others] = coverImages(container);
|
||||||
expect(first).toHaveAttribute("loading", "eager");
|
expect(first).toHaveAttribute("loading", "eager");
|
||||||
|
|||||||
@@ -77,11 +77,27 @@ describe("CasesPage: cases de cliente", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus and AS Serviços", () => {
|
it("path: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => {
|
||||||
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
|
const wide = clientCasesForListing.map((caseStudy) =>
|
||||||
|
clientCard(container, caseStudy.id).className.includes(
|
||||||
|
"min-[1025px]:col-span-2",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const odd = clientCasesForListing.length % 2 === 1;
|
||||||
|
expect(wide).toEqual(
|
||||||
|
clientCasesForListing.map(
|
||||||
|
(_, index) => odd && index === clientCasesForListing.length - 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: contact -> 'Chamar no WhatsApp', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => {
|
||||||
const { container } = render(<CasesPage />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
screen.getByRole("link", { name: buttonLabels.whatsapp }),
|
||||||
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
||||||
const clientTitles = Array.from(
|
const clientTitles = Array.from(
|
||||||
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
||||||
@@ -94,21 +110,28 @@ describe("CasesPage: cases de cliente", () => {
|
|||||||
"Lavação 047",
|
"Lavação 047",
|
||||||
"SC Plus Aquicultura",
|
"SC Plus Aquicultura",
|
||||||
"AS Serviços",
|
"AS Serviços",
|
||||||
|
"Julia Beauty",
|
||||||
]);
|
]);
|
||||||
// um botão de contato só na página: o do fecho
|
// um botão de contato só na página: o do fecho
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole("link", { name: buttonLabels.contact }),
|
screen.getAllByRole("link", { name: buttonLabels.whatsapp }),
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CasesPage: topo sem mosaico", () => {
|
describe("CasesPage: topo sem mosaico", () => {
|
||||||
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
|
it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => {
|
||||||
const { container } = render(<CasesPage />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.querySelector("img")).toBeNull();
|
const images = hero?.querySelectorAll("img") ?? [];
|
||||||
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
|
expect(images).toHaveLength(1);
|
||||||
|
expect(images[0].getAttribute("src")).toContain("estado-tela.webp");
|
||||||
|
// decorativa e só de 1024 px em diante
|
||||||
|
expect(images[0].closest('[aria-hidden="true"]')).toHaveClass(
|
||||||
|
"hidden",
|
||||||
|
"lg:block",
|
||||||
|
);
|
||||||
expect(hero?.className).toContain("md:!pb-sp-12");
|
expect(hero?.className).toContain("md:!pb-sp-12");
|
||||||
// o título e o subtítulo não repetem a mesma frase
|
// o título e o subtítulo não repetem a mesma frase
|
||||||
const heading = within(hero as HTMLElement).getByRole("heading", {
|
const heading = within(hero as HTMLElement).getByRole("heading", {
|
||||||
@@ -129,7 +152,7 @@ describe("CasesPage: topo sem mosaico", () => {
|
|||||||
const { container } = render(<CasesPage />);
|
const { container } = render(<CasesPage />);
|
||||||
|
|
||||||
const hero = container.querySelector("section");
|
const hero = container.querySelector("section");
|
||||||
expect(hero?.querySelector("img")).toBeNull();
|
expect(hero?.querySelectorAll("img")).toHaveLength(1);
|
||||||
const firstCover = clientCard(
|
const firstCover = clientCard(
|
||||||
container,
|
container,
|
||||||
clientCasesForListing[0].id,
|
clientCasesForListing[0].id,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
textLink,
|
textLink,
|
||||||
} from "@/app/components/button-styles";
|
} from "@/app/components/button-styles";
|
||||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
|
import { PagePiece } from "@/app/components/PagePiece";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "@/lib/site-content";
|
} from "@/lib/site-content";
|
||||||
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
||||||
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||||
import { compactHero } from "./_components/hero-styles";
|
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
|
||||||
|
|
||||||
const pageUrl = `${siteUrl}/cases`;
|
const pageUrl = `${siteUrl}/cases`;
|
||||||
const pageTitle = "Cases: sites e sistemas para empresas";
|
const pageTitle = "Cases: sites e sistemas para empresas";
|
||||||
@@ -42,45 +43,60 @@ export const metadata: Metadata = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
|
/**
|
||||||
|
* Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case.
|
||||||
|
* `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita.
|
||||||
|
*/
|
||||||
function ClientCaseCard({
|
function ClientCaseCard({
|
||||||
caseStudy,
|
caseStudy,
|
||||||
priority = false,
|
priority = false,
|
||||||
|
wide = false,
|
||||||
}: {
|
}: {
|
||||||
caseStudy: CaseStudyContent;
|
caseStudy: CaseStudyContent;
|
||||||
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
wide?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className="ds-card !h-auto">
|
<article
|
||||||
|
className={`ds-card !h-auto ${
|
||||||
|
// a partir de 1025 px, onde .ds-grid-2 (globals.css) passa a duas colunas; o "!" vence o display:flex de .ds-card,
|
||||||
|
// que fica fora das camadas do Tailwind
|
||||||
|
wide
|
||||||
|
? "min-[1025px]:col-span-2 min-[1025px]:!grid min-[1025px]:grid-cols-2 min-[1025px]:items-center min-[1025px]:gap-x-sp-8 min-[1025px]:[&>:first-child]:mb-0"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<CaseCoverLink
|
<CaseCoverLink
|
||||||
id={caseStudy.id}
|
id={caseStudy.id}
|
||||||
title={caseStudy.title}
|
title={caseStudy.title}
|
||||||
demoUrl={caseStudy.demoUrl}
|
demoUrl={caseStudy.demoUrl}
|
||||||
priority={priority}
|
priority={priority}
|
||||||
/>
|
/>
|
||||||
<div className="ds-kicker">{caseStudy.title}</div>
|
<div>
|
||||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
<div className="ds-kicker">{caseStudy.title}</div>
|
||||||
<p className="ds-copy">{caseStudy.summary}</p>
|
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
<p className="ds-copy">{caseStudy.summary}</p>
|
||||||
<HoverPrefetchLink
|
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||||
href={`/cases/${caseStudy.id}`}
|
<HoverPrefetchLink
|
||||||
className={secondaryButtonLight}
|
href={`/cases/${caseStudy.id}`}
|
||||||
>
|
className={secondaryButtonLight}
|
||||||
{buttonLabels.case}
|
|
||||||
<span className="sr-only"> {caseStudy.title}</span>
|
|
||||||
</HoverPrefetchLink>
|
|
||||||
{caseStudy.demoUrl ? (
|
|
||||||
<a
|
|
||||||
href={caseStudy.demoUrl}
|
|
||||||
className={textLink}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
>
|
||||||
{buttonLabels.site}
|
{buttonLabels.case}
|
||||||
<span className="sr-only"> {caseStudy.title}</span>
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
</a>
|
</HoverPrefetchLink>
|
||||||
) : null}
|
{caseStudy.demoUrl ? (
|
||||||
|
<a
|
||||||
|
href={caseStudy.demoUrl}
|
||||||
|
className={textLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{buttonLabels.site}
|
||||||
|
<span className="sr-only"> {caseStudy.title}</span>
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -144,15 +160,14 @@ export default function CasesPage() {
|
|||||||
<section
|
<section
|
||||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||||
>
|
>
|
||||||
<div className="ds-section-shell">
|
<div className={`ds-section-shell ${heroWithMediaGrid}`}>
|
||||||
<div className="max-w-[860px]">
|
<div className="min-w-0 max-w-[860px]">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
items={[
|
items={[
|
||||||
{ label: "Início", href: "/" },
|
{ label: "Início", href: "/" },
|
||||||
{ label: "Cases", href: "/cases" },
|
{ label: "Cases", href: "/cases" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<p className="ds-eyebrow">Trabalhos entregues</p>
|
|
||||||
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
|
||||||
Veja o que já fiz para empresas.
|
Veja o que já fiz para empresas.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -160,6 +175,7 @@ export default function CasesPage() {
|
|||||||
{pageLead}
|
{pageLead}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<PagePiece image="/3d/estado-tela.webp" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -169,11 +185,12 @@ export default function CasesPage() {
|
|||||||
Projetos de clientes
|
Projetos de clientes
|
||||||
</h2>
|
</h2>
|
||||||
<div className="ds-grid-2 mt-sp-8 items-start">
|
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||||
{clientCasesForListing.map((caseStudy, index) => (
|
{clientCasesForListing.map((caseStudy, index, all) => (
|
||||||
<ClientCaseCard
|
<ClientCaseCard
|
||||||
key={caseStudy.id}
|
key={caseStudy.id}
|
||||||
caseStudy={caseStudy}
|
caseStudy={caseStudy}
|
||||||
priority={index === 0}
|
priority={index === 0}
|
||||||
|
wide={index === all.length - 1 && all.length % 2 === 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +228,7 @@ export default function CasesPage() {
|
|||||||
placement="cases_hub_footer"
|
placement="cases_hub_footer"
|
||||||
className={primaryButton}
|
className={primaryButton}
|
||||||
>
|
>
|
||||||
{buttonLabels.contact}
|
{buttonLabels.whatsapp}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
||||||
{buttonLabels.allServices}
|
{buttonLabels.allServices}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit
|
|||||||
expect(none.container).toBeEmptyDOMElement();
|
expect(none.container).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives every client link a touch target of at least 44 px (min-h-11)", () => {
|
it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => {
|
||||||
render(<ClientStrip clients={clients} />);
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
for (const link of screen.getAllByRole("link")) {
|
for (const link of screen.getAllByRole("link")) {
|
||||||
expect(link).toHaveClass("min-h-11");
|
expect(link).toHaveClass("min-h-11", "sm:min-h-6");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,3 +107,30 @@ it("keeps the years number in the display font (the mono font is not downloaded
|
|||||||
|
|
||||||
expect(screen.getByText("5").className).not.toContain("font-mono");
|
expect(screen.getByText("5").className).not.toContain("font-mono");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => {
|
||||||
|
render(
|
||||||
|
<ClientStrip
|
||||||
|
clients={[
|
||||||
|
{
|
||||||
|
id: "sev-modaintima",
|
||||||
|
name: "SEV Moda Íntima",
|
||||||
|
sector: "loja virtual",
|
||||||
|
},
|
||||||
|
{ id: "lavacao-047", name: "Lavação 047" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sector = screen.getByText("loja virtual");
|
||||||
|
// 13 px e apagado, e só de 640 px em diante (abaixo, só os nomes)
|
||||||
|
expect(sector).toHaveClass("text-[13px]", "max-sm:hidden");
|
||||||
|
const link = screen.getByRole("link", { name: "SEV Moda Íntima" });
|
||||||
|
expect(link).not.toContainElement(sector);
|
||||||
|
expect(link.parentElement).toContainElement(sector);
|
||||||
|
expect(link.parentElement).toHaveClass("flex-col");
|
||||||
|
// o nome continua levando ao case
|
||||||
|
expect(link).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
|
const plain = screen.getByRole("link", { name: "Lavação 047" });
|
||||||
|
expect(plain.parentElement?.childElementCount).toBe(1);
|
||||||
|
});
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
export type ClientLink = { id: string; name: string };
|
export type ClientLink = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */
|
||||||
|
sector?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
|
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
|
||||||
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
|
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
|
||||||
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
|
* De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso,
|
||||||
|
* só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a
|
||||||
|
* altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham
|
||||||
|
* pela linha dos nomes (`sm:items-start`).
|
||||||
*
|
*
|
||||||
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
|
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
|
||||||
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
|
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
|
||||||
@@ -30,12 +38,19 @@ export default function ClientStrip({
|
|||||||
...clients.map((client) => ({
|
...clients.map((client) => ({
|
||||||
key: client.id,
|
key: client.id,
|
||||||
node: (
|
node: (
|
||||||
<HoverPrefetchLink
|
<span className="flex flex-col">
|
||||||
href={`/cases/${client.id}`}
|
<HoverPrefetchLink
|
||||||
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
|
href={`/cases/${client.id}`}
|
||||||
>
|
className="inline-flex min-h-11 items-center self-start font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] sm:min-h-6"
|
||||||
{client.name}
|
>
|
||||||
</HoverPrefetchLink>
|
{client.name}
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
{client.sector && (
|
||||||
|
<span className="text-[13px] leading-[18px] max-sm:hidden">
|
||||||
|
{client.sector}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
...(years
|
...(years
|
||||||
@@ -57,14 +72,16 @@ export default function ClientStrip({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
|
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] sm:items-start ${className}`.trim()}
|
||||||
>
|
>
|
||||||
{clients.length > 0 && <p>Já entregue para:</p>}
|
{clients.length > 0 && <p>Já entregue para:</p>}
|
||||||
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
|
{/* de 640 px em diante a lista fica ao lado do rótulo enquanto sobram 24rem, e quebra por dentro; menos que isso,
|
||||||
|
ela desce para baixo do rótulo e usa a largura toda */}
|
||||||
|
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1 sm:min-w-[min(24rem,100%)] sm:flex-1 sm:basis-0 sm:items-start sm:gap-y-sp-2">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<li
|
<li
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
|
className={`flex items-center pr-4 sm:items-start${index > 0 ? " -ml-4" : ""}`}
|
||||||
>
|
>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Contact: opções do tipo de projeto", () => {
|
describe("Contact: opções do tipo de projeto", () => {
|
||||||
it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
|
it("lists the five offers by their names and keeps 'Ainda preciso definir' first and selected", () => {
|
||||||
render(<Contact />);
|
render(<Contact />);
|
||||||
|
|
||||||
const options = within(subjectSelect()).getAllByRole("option");
|
const options = within(subjectSelect()).getAllByRole("option");
|
||||||
@@ -32,10 +32,12 @@ describe("Contact: opções do tipo de projeto", () => {
|
|||||||
"Ainda preciso definir",
|
"Ainda preciso definir",
|
||||||
"Site que traz pedidos pelo WhatsApp",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Atendimento no WhatsApp com IA sob controle",
|
||||||
"Aplicativos",
|
"Aplicativos",
|
||||||
"Integrações e automações",
|
"Integrações e automações",
|
||||||
"Evolução de um sistema existente",
|
"Evolução de um sistema existente",
|
||||||
"Revisão de sistema que já existe",
|
"Revisão de sistema que já existe",
|
||||||
|
"Nota fiscal pronta para a reforma tributária",
|
||||||
]);
|
]);
|
||||||
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
|
||||||
});
|
});
|
||||||
@@ -61,7 +63,9 @@ describe("Contact: tipo escolhido pela oferta", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["site", "Site que traz pedidos pelo WhatsApp"],
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
["sistema", "Sistema para tirar a operação da planilha"],
|
["sistema", "Sistema para tirar a operação da planilha"],
|
||||||
|
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
|
||||||
["revisao", "Revisão de sistema que já existe"],
|
["revisao", "Revisão de sistema que já existe"],
|
||||||
|
["fiscal", "Nota fiscal pronta para a reforma tributária"],
|
||||||
])("the offer event with %s selects %s", (kind, option) => {
|
])("the offer event with %s selects %s", (kind, option) => {
|
||||||
render(<Contact />);
|
render(<Contact />);
|
||||||
|
|
||||||
@@ -156,7 +160,9 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["site", "Site que traz pedidos pelo WhatsApp"],
|
["site", "Site que traz pedidos pelo WhatsApp"],
|
||||||
["sistema", "Sistema para tirar a operação da planilha"],
|
["sistema", "Sistema para tirar a operação da planilha"],
|
||||||
|
["whatsapp", "Atendimento no WhatsApp com IA sob controle"],
|
||||||
["revisao", "Revisão de sistema que já existe"],
|
["revisao", "Revisão de sistema que já existe"],
|
||||||
|
["fiscal", "Nota fiscal pronta para a reforma tributária"],
|
||||||
])(
|
])(
|
||||||
"after the offer %s the form says 'Você escolheu: <name>'",
|
"after the offer %s the form says 'Você escolheu: <name>'",
|
||||||
(kind, name) => {
|
(kind, name) => {
|
||||||
@@ -201,6 +207,14 @@ describe("Contact: você escolheu e o WhatsApp direto", () => {
|
|||||||
expect(directMessage()).toContain(
|
expect(directMessage()).toContain(
|
||||||
"Quero conversar sobre a revisão de um sistema que já existe.",
|
"Quero conversar sobre a revisão de um sistema que já existe.",
|
||||||
);
|
);
|
||||||
|
choose("whatsapp");
|
||||||
|
expect(directMessage()).toContain(
|
||||||
|
"Quero conversar sobre um atendimento no WhatsApp com IA sob controle.",
|
||||||
|
);
|
||||||
|
choose("fiscal");
|
||||||
|
expect(directMessage()).toContain(
|
||||||
|
"Quero conversar sobre a adaptação da nota fiscal à reforma tributária.",
|
||||||
|
);
|
||||||
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
|
// quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem
|
||||||
await user.selectOptions(
|
await user.selectOptions(
|
||||||
subjectSelect(),
|
subjectSelect(),
|
||||||
|
|||||||
@@ -265,11 +265,11 @@ export default function Contact() {
|
|||||||
service={directSubject}
|
service={directSubject}
|
||||||
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
className="group flex min-h-20 items-center gap-sp-4 rounded-sm py-sp-5 text-white no-underline"
|
||||||
>
|
>
|
||||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#25d366]/10 text-[#25d366]">
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[var(--whatsapp)]/10 text-[var(--whatsapp)]">
|
||||||
<WhatsAppMark className="h-6 w-6" />
|
<WhatsAppMark className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 font-display">
|
<span className="min-w-0 flex-1 font-display">
|
||||||
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
|
<span className="block text-[15px] font-semibold text-white group-hover:text-[var(--whatsapp)]">
|
||||||
Prefere conversar direto?
|
Prefere conversar direto?
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block text-[15px] text-white/65">
|
<span className="mt-1 block text-[15px] text-white/65">
|
||||||
@@ -277,7 +277,7 @@ export default function Contact() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ArrowUpRight
|
<ArrowUpRight
|
||||||
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[#25d366]"
|
className="h-5 w-5 shrink-0 text-white/50 group-hover:text-[var(--whatsapp)]"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
@@ -310,7 +310,7 @@ export default function Contact() {
|
|||||||
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
className="min-w-0 rounded-2xl border border-white/12 bg-white/[0.035] p-sp-5 sm:p-sp-8"
|
||||||
>
|
>
|
||||||
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
|
<div className="mb-sp-6 flex items-start gap-sp-4 border-b border-white/10 pb-sp-6">
|
||||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[#25d366] text-[#071b10]">
|
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-[var(--whatsapp)] text-[#071b10]">
|
||||||
<WhatsAppMark className="h-7 w-7" />
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -412,7 +412,7 @@ export default function Contact() {
|
|||||||
</Field>
|
</Field>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="cv-btn flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[#25d366] px-sp-4 py-sp-4 text-[#071b10] transition-colors hover:bg-[#54e58b] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#25d366] motion-reduce:transition-none"
|
className="cv-btn cv-fill flex min-h-14 w-full items-center justify-center gap-sp-3 rounded-lg bg-[var(--whatsapp)] px-sp-4 py-sp-4 text-[#071b10] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--whatsapp)] motion-reduce:transition-none"
|
||||||
>
|
>
|
||||||
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
<WhatsAppMark className="h-6 w-6 shrink-0" />
|
||||||
Continuar no WhatsApp
|
Continuar no WhatsApp
|
||||||
@@ -444,9 +444,9 @@ export default function Contact() {
|
|||||||
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
|
||||||
<a
|
<a
|
||||||
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
|
href={buildWhatsAppUrl(DIRECT_MESSAGE)}
|
||||||
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[#25d366] px-sp-4 font-semibold text-[#071b10] no-underline"
|
className="inline-flex min-h-12 items-center justify-center rounded-lg bg-[var(--whatsapp)] px-sp-4 font-semibold text-[#071b10] no-underline"
|
||||||
>
|
>
|
||||||
Conversar no WhatsApp
|
Chamar no WhatsApp
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${CONTACT_EMAIL}`}
|
href={`mailto:${CONTACT_EMAIL}`}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function Faq({
|
|||||||
{answered.map((item) => (
|
{answered.map((item) => (
|
||||||
<details
|
<details
|
||||||
key={item.question}
|
key={item.question}
|
||||||
className="group border-t border-[var(--border)] last:border-b"
|
className="ds-faq-item group border-t border-[var(--border)] last:border-b"
|
||||||
>
|
>
|
||||||
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
|
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
|
||||||
{item.question}
|
{item.question}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { textLink } from "./button-styles";
|
||||||
|
import { isFiscalNoticeActive } from "./fiscal-notice";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
/** A data não muda enquanto a página está aberta: nada a observar. */
|
||||||
|
const subscribe = () => () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faixa datada da nota fiscal, acima dos cartões da home. Quem a coloca na página (WorkModalities) só a renderiza dentro
|
||||||
|
* do prazo; aqui o navegador confere a data de novo, porque a home é gerada no build: depois do prazo ela some sem um
|
||||||
|
* build novo. Na hidratação vale o valor do servidor (visível), e só depois a data do navegador: sem aviso de hidratação.
|
||||||
|
*/
|
||||||
|
export function FiscalNotice({ href }: Readonly<{ href: string }>) {
|
||||||
|
const active = useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
() => isFiscalNoticeActive(new Date()),
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 flex flex-col gap-sp-1 border-y border-[var(--border)] py-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
|
||||||
|
<p className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
|
||||||
|
<strong className="font-semibold text-[var(--ink)]">
|
||||||
|
Seu sistema emite nota fiscal?
|
||||||
|
</strong>{" "}
|
||||||
|
Os optantes do Simples Nacional entram na reforma tributária em 1º de
|
||||||
|
janeiro de 2027.
|
||||||
|
</p>
|
||||||
|
<HoverPrefetchLink
|
||||||
|
href={href}
|
||||||
|
className={`${textLink} shrink-0 self-start text-[var(--ink-80)]`}
|
||||||
|
>
|
||||||
|
Ver o que muda
|
||||||
|
</HoverPrefetchLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ class FakeObserver {
|
|||||||
const hero = () => document.getElementById("hero-contact-cta") as Element;
|
const hero = () => document.getElementById("hero-contact-cta") as Element;
|
||||||
const contact = () => document.getElementById("contact") as Element;
|
const contact = () => document.getElementById("contact") as Element;
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetNavigation();
|
resetNavigation();
|
||||||
@@ -221,7 +221,7 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
||||||
it("path: the bar publishes its height while open and removes it once a choice is made", async () => {
|
it("path: the bar marks <html> while open, which hides the button by CSS, and drops the mark once a choice is made", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||||
@@ -237,12 +237,16 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
|||||||
);
|
);
|
||||||
const refuse = await screen.findByRole("button", { name: "Recusar" });
|
const refuse = await screen.findByRole("button", { name: "Recusar" });
|
||||||
|
|
||||||
// o botão flutuante usa a variável publicada pela barra para subir
|
// a barra publica a altura (o menu do celular a usa) e marca o <html>; o botão flutuante some por CSS com a marca
|
||||||
expect(
|
expect(
|
||||||
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
).toBe("72px");
|
).toBe("72px");
|
||||||
expect(floatingLink()?.parentElement?.className).toContain(
|
expect(document.documentElement).toHaveAttribute(
|
||||||
"var(--cookie-bar-height,0px)",
|
"data-cookie-bar",
|
||||||
|
"open",
|
||||||
|
);
|
||||||
|
expect(floatingLink()?.parentElement).toHaveClass(
|
||||||
|
"[[data-cookie-bar=open]_&]:hidden",
|
||||||
);
|
);
|
||||||
|
|
||||||
await user.click(refuse);
|
await user.click(refuse);
|
||||||
@@ -250,6 +254,7 @@ describe("FloatingWhatsApp com a barra de cookies aberta", () => {
|
|||||||
expect(
|
expect(
|
||||||
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
|
expect(document.documentElement).not.toHaveAttribute("data-cookie-bar");
|
||||||
expect(floatingLink()).toBeInTheDocument();
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
} finally {
|
} finally {
|
||||||
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
Reflect.deleteProperty(HTMLElement.prototype, "offsetHeight");
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class FakeObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
const messageOf = (link: HTMLElement | null) =>
|
const messageOf = (link: HTMLElement | null) =>
|
||||||
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
|
new URL(link?.getAttribute("href") ?? "").searchParams.get("text");
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function setVisibility(hero: boolean, contact: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const floatingLink = () =>
|
const floatingLink = () =>
|
||||||
screen.queryByRole("link", { name: "Conversar no WhatsApp" });
|
screen.queryByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
global.IntersectionObserver =
|
global.IntersectionObserver =
|
||||||
@@ -112,25 +112,14 @@ it("shows right away on a page that has neither the hero button nor the contact
|
|||||||
expect(floatingLink()).toBeInTheDocument();
|
expect(floatingLink()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lifts itself by the height of the open cookie bar", () => {
|
it("hides by CSS while the cookie bar is open (the data-cookie-bar mark on <html>), instead of sitting on top of it", () => {
|
||||||
render(<Page />);
|
|
||||||
setVisibility(false, false);
|
|
||||||
|
|
||||||
expect(floatingLink()?.parentElement?.className).toContain(
|
|
||||||
"var(--cookie-bar-height,0px)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
|
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
setVisibility(false, false);
|
setVisibility(false, false);
|
||||||
|
|
||||||
const className = floatingLink()?.parentElement?.className ?? "";
|
const className = floatingLink()?.parentElement?.className ?? "";
|
||||||
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
|
expect(className).toContain("[[data-cookie-bar=open]_&]:hidden");
|
||||||
expect(className).toContain("transition-transform");
|
// não sobe mais pela altura da barra: some enquanto ela está aberta
|
||||||
expect(className).toContain("motion-reduce:transition-none");
|
expect(className).not.toContain("--cookie-bar-height");
|
||||||
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
|
|
||||||
expect(className).not.toContain("transition-[bottom]");
|
|
||||||
expect(className).toContain(
|
expect(className).toContain(
|
||||||
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import { type WhatsAppContexts, whatsAppSubjectForPath } from "@/lib/whatsapp";
|
|||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
import { WhatsAppMark } from "./WhatsAppMark";
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
/** Botão principal do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
|
/** Link "Chamar no WhatsApp" do hero e seção de contato: enquanto qualquer um está na tela, o botão flutuante some. */
|
||||||
export const HERO_CTA_ID = "hero-contact-cta";
|
export const HERO_CTA_ID = "hero-contact-cta";
|
||||||
const CONTACT_SECTION_ID = "contact";
|
const CONTACT_SECTION_ID = "contact";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
|
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
|
||||||
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
|
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
|
||||||
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
|
* contato da home) e enquanto a barra de cookies está aberta (o `data-cookie-bar` que GoogleAnalytics põe no <html>):
|
||||||
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
|
* os dois disputavam a base da tela. O rodapé reserva no fim da página o espaço que ele ocupa.
|
||||||
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
|
* Fica num <aside> com nome (região de contato rápido para leitores de tela).
|
||||||
*/
|
*/
|
||||||
export default function FloatingWhatsApp({
|
export default function FloatingWhatsApp({
|
||||||
contexts,
|
contexts,
|
||||||
@@ -61,15 +61,15 @@ export default function FloatingWhatsApp({
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="Contato rápido"
|
aria-label="Contato rápido"
|
||||||
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
|
className="ds-enter-pop fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] md:hidden [[data-cookie-bar=open]_&]:hidden"
|
||||||
>
|
>
|
||||||
<WhatsAppLink
|
<WhatsAppLink
|
||||||
placement="floating"
|
placement="floating"
|
||||||
service={subject}
|
service={subject}
|
||||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-[#25d366] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[#54e58b] motion-reduce:transition-none"
|
className="flex h-14 w-14 items-center justify-center rounded-full bg-[var(--whatsapp)] text-[#071b10] shadow-[0_8px_24px_rgba(0,0,0,0.45)] transition-colors hover:bg-[var(--whatsapp-hover)] motion-reduce:transition-none"
|
||||||
>
|
>
|
||||||
<WhatsAppMark className="h-7 w-7" />
|
<WhatsAppMark className="h-7 w-7" />
|
||||||
<span className="sr-only">Conversar no WhatsApp</span>
|
<span className="sr-only">Chamar no WhatsApp</span>
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,20 +39,22 @@ describe("Footer contact and navigation", () => {
|
|||||||
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers WhatsApp in the contact column, in a new tab", () => {
|
it("offers WhatsApp in the contact column, in a new tab, named like every control that opens it", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const contact = screen.getByRole("region", { name: "Contato" });
|
const contact = screen.getByRole("region", { name: "Contato" });
|
||||||
const whatsApp = within(contact).getByRole("link", { name: "WhatsApp" });
|
const whatsApp = within(contact).getByRole("link", {
|
||||||
|
name: "Chamar no WhatsApp",
|
||||||
|
});
|
||||||
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
expect(whatsApp).toHaveAttribute("target", "_blank");
|
expect(whatsApp).toHaveAttribute("target", "_blank");
|
||||||
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists the six services from the services hub as same-tab links", () => {
|
it("lists the eight services from the services hub as same-tab links", () => {
|
||||||
render(<Footer serviceLinks={serviceHubLinks} />);
|
render(<Footer serviceLinks={serviceHubLinks} />);
|
||||||
|
|
||||||
expect(serviceHubLinks).toHaveLength(6);
|
expect(serviceHubLinks).toHaveLength(8);
|
||||||
const services = screen.getByRole("navigation", { name: "Serviços" });
|
const services = screen.getByRole("navigation", { name: "Serviços" });
|
||||||
const links = within(services).getAllByRole("link");
|
const links = within(services).getAllByRole("link");
|
||||||
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
expect(links.map((link) => link.getAttribute("href"))).toEqual(
|
||||||
@@ -64,7 +66,7 @@ describe("Footer contact and navigation", () => {
|
|||||||
for (const link of links) expect(link).not.toHaveAttribute("target");
|
for (const link of links) expect(link).not.toHaveAttribute("target");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("links Cases, Privacidade and a discreet Currículo, with no PDF download", () => {
|
it("links Cases, the /ia page by its menu label, Privacidade and a discreet Currículo, with no PDF download", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const more = screen.getByRole("navigation", { name: "Mais" });
|
const more = screen.getByRole("navigation", { name: "Mais" });
|
||||||
@@ -74,7 +76,7 @@ describe("Footer contact and navigation", () => {
|
|||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
within(more).getByRole("link", {
|
within(more).getByRole("link", {
|
||||||
name: "Revisão de sistema que já existe",
|
name: "Revisão de app com IA",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/ia");
|
).toHaveAttribute("href", "/ia");
|
||||||
expect(
|
expect(
|
||||||
@@ -145,11 +147,11 @@ describe("Footer social links", () => {
|
|||||||
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
|
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => {
|
it("names /ia like the menu and the page, and no old 'app feito com IA' name", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: "Revisão de sistema que já existe" }),
|
screen.getByRole("link", { name: "Revisão de app com IA" }),
|
||||||
).toHaveAttribute("href", "/ia");
|
).toHaveAttribute("href", "/ia");
|
||||||
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
|
expect(screen.queryByText(/app feito com IA/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import {
|
|||||||
trackEmailClick,
|
trackEmailClick,
|
||||||
trackSocialClick,
|
trackSocialClick,
|
||||||
} from "@/lib/analytics";
|
} from "@/lib/analytics";
|
||||||
import { offerLabels } from "@/lib/offer-labels";
|
|
||||||
import {
|
import {
|
||||||
CONTACT_EMAIL,
|
CONTACT_EMAIL,
|
||||||
|
IA_PAGE_LABEL,
|
||||||
INSTAGRAM_URL,
|
INSTAGRAM_URL,
|
||||||
LINKEDIN_URL,
|
LINKEDIN_URL,
|
||||||
type ServiceLink,
|
type ServiceLink,
|
||||||
@@ -18,7 +18,7 @@ import { Logo } from "./Logo";
|
|||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
const headingClassName =
|
const headingClassName =
|
||||||
"font-mono text-[12px] font-medium uppercase tracking-[0.12em] text-[var(--meta)]";
|
"font-mono text-[13px] font-medium tracking-[0.02em] text-[var(--meta)]";
|
||||||
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
|
// min-h-11 no toque; no desktop os links ficam mais juntos para o rodapé não crescer à toa
|
||||||
const linkClassName =
|
const linkClassName =
|
||||||
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
|
"inline-flex min-h-11 items-center text-left font-display text-[15px] leading-6 text-[var(--ink-80)] md:min-h-8 underline-offset-4 hover:text-[var(--ink)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)]";
|
||||||
@@ -48,7 +48,7 @@ export default function Footer({
|
|||||||
placement="footer"
|
placement="footer"
|
||||||
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
|
className={`${linkClassName} font-semibold !text-[var(--ink)] focus-visible:!ring-[var(--ink)] focus-visible:!ring-offset-[var(--paper)]`}
|
||||||
>
|
>
|
||||||
WhatsApp
|
Chamar no WhatsApp
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -118,7 +118,7 @@ export default function Footer({
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
||||||
{offerLabels.revisao.name}
|
{IA_PAGE_LABEL}
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("GoogleAnalytics consent", () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
expect(screen.getByRole("button", { name: "Recusar" })).toBeVisible();
|
||||||
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
||||||
/Usamos métricas de navegação do Google só se você permitir\./,
|
/Métricas de navegação do Google, só se você permitir\./,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
screen.queryByTestId("google-analytics-script"),
|
screen.queryByTestId("google-analytics-script"),
|
||||||
|
|||||||
@@ -231,16 +231,18 @@ function GoogleAnalyticsConsent() {
|
|||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
if (event.key === "Escape" && preferencesOpen) closePreferences();
|
||||||
}}
|
}}
|
||||||
className="fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] sm:px-sp-6"
|
className="ds-enter fixed inset-x-0 bottom-0 z-[9980] border-t border-white/20 bg-[var(--ink)] px-sp-4 pt-sp-2 pb-[max(var(--sp-2),env(safe-area-inset-bottom))] text-white shadow-[0_-8px_24px_rgba(0,0,0,0.35)] [--enter-y:16px] sm:px-sp-6"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
|
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
|
||||||
|
{/* Até 72 px de altura em 390 px: três linhas de 13 px ao lado dos botões (15 px de 640 px em diante). */}
|
||||||
<p
|
<p
|
||||||
id="analytics-consent-description"
|
id="analytics-consent-description"
|
||||||
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
|
className="min-w-0 flex-1 font-display text-[13px] leading-[1.3] text-white/85 sm:text-[15px]"
|
||||||
>
|
>
|
||||||
Usamos métricas de navegação do Google só se você permitir.{" "}
|
Métricas de navegação do Google, só se você permitir.{" "}
|
||||||
|
{/* o preenchimento vertical dá ao link 24 px de alvo de toque sem engordar a linha */}
|
||||||
<a
|
<a
|
||||||
className="whitespace-nowrap underline underline-offset-2 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
className="whitespace-nowrap py-1 underline underline-offset-2 hover:text-white hover:decoration-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||||
href="/privacidade"
|
href="/privacidade"
|
||||||
>
|
>
|
||||||
Política de privacidade
|
Política de privacidade
|
||||||
@@ -257,14 +259,14 @@ function GoogleAnalyticsConsent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={acceptRef}
|
ref={acceptRef}
|
||||||
className={`${barButtonClassName} border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] hover:bg-white`}
|
className={`${barButtonClassName} cv-fill border-[#f4f2ed] bg-[#f4f2ed] text-[var(--ink)] [--block:var(--slash)]`}
|
||||||
onClick={acceptAnalytics}
|
onClick={acceptAnalytics}
|
||||||
>
|
>
|
||||||
Aceitar
|
Aceitar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${barButtonClassName} border-white/45 bg-transparent text-white hover:border-white`}
|
className={`${barButtonClassName} cv-outline-dark border-white/45 bg-transparent text-white`}
|
||||||
onClick={denyAnalytics}
|
onClick={denyAnalytics}
|
||||||
>
|
>
|
||||||
Recusar
|
Recusar
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ describe("Header", () => {
|
|||||||
within(navigation)
|
within(navigation)
|
||||||
.getAllByRole("link")
|
.getAllByRole("link")
|
||||||
.map((link) => link.textContent),
|
.map((link) => link.textContent),
|
||||||
).toEqual(["Cases", "Serviços", "Como trabalho", "Sobre", "IA"]);
|
).toEqual([
|
||||||
|
"Cases",
|
||||||
|
"Serviços",
|
||||||
|
"Como trabalho",
|
||||||
|
"Sobre",
|
||||||
|
"Revisão de app com IA",
|
||||||
|
]);
|
||||||
// "Projetos" saiu: duplicava "Cases"
|
// "Projetos" saiu: duplicava "Cases"
|
||||||
expect(
|
expect(
|
||||||
within(navigation).queryByRole("link", { name: "Projetos" }),
|
within(navigation).queryByRole("link", { name: "Projetos" }),
|
||||||
@@ -179,11 +185,15 @@ describe("Header", () => {
|
|||||||
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
|
expect(panel.className).toMatch(/h-\[calc\(100dvh-/);
|
||||||
const links = within(mobileNavigation).getAllByRole("link");
|
const links = within(mobileNavigation).getAllByRole("link");
|
||||||
for (const link of links.slice(0, 5)) {
|
for (const link of links.slice(0, 5)) {
|
||||||
expect(link).toHaveClass("text-[22px]", "min-h-14");
|
// sob o ponteiro e no foco, a barra que cresce sob o texto (globals.css)
|
||||||
|
expect(link).toHaveClass("text-[22px]", "min-h-14", "cv-bar");
|
||||||
}
|
}
|
||||||
const contact = links[5];
|
const contact = links[5];
|
||||||
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
|
expect(contact).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
expect(contact.parentElement).toHaveClass("mt-auto");
|
expect(contact.parentElement).toHaveClass("mt-auto");
|
||||||
|
// o botão verde ganha o bloco cor de papel, não só uma troca de cor
|
||||||
|
expect(contact).toHaveClass("cv-fill");
|
||||||
|
expect(contact.className).not.toMatch(/hover:bg-/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
|
it("uses next/link for every internal link and only prefetches on hover or focus", () => {
|
||||||
@@ -245,9 +255,13 @@ describe("Header", () => {
|
|||||||
render(<Header whatsAppContexts={contexts} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
const contact = screen.getByRole("link", {
|
const contact = screen.getByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
|
// "Falar sobre meu projeto" é só do formulário: o botão que abre o WhatsApp tem outro nome
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
expect(contact).toHaveAttribute("target", "_blank");
|
expect(contact).toHaveAttribute("target", "_blank");
|
||||||
expect(whatsAppHref(contact)).toContain(sentence);
|
expect(whatsAppHref(contact)).toContain(sentence);
|
||||||
},
|
},
|
||||||
@@ -258,7 +272,7 @@ describe("Header", () => {
|
|||||||
render(<Header whatsAppContexts={contexts} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
const contact = screen.getByRole("link", {
|
const contact = screen.getByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
expect(contact).toHaveAttribute("target", "_blank");
|
expect(contact).toHaveAttribute("target", "_blank");
|
||||||
@@ -274,7 +288,7 @@ describe("Header", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||||
const buttons = screen.getAllByRole("link", {
|
const buttons = screen.getAllByRole("link", {
|
||||||
name: "Falar sobre meu projeto",
|
name: "Chamar no WhatsApp",
|
||||||
});
|
});
|
||||||
expect(buttons).toHaveLength(2);
|
expect(buttons).toHaveLength(2);
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
@@ -295,9 +309,7 @@ describe("Header", () => {
|
|||||||
render(<Header whatsAppContexts={contexts} />);
|
render(<Header whatsAppContexts={contexts} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
whatsAppHref(
|
whatsAppHref(screen.getByRole("link", { name: "Chamar no WhatsApp" })),
|
||||||
screen.getByRole("link", { name: "Falar sobre meu projeto" }),
|
|
||||||
),
|
|
||||||
).toContain("Quero conversar sobre um projeto.");
|
).toContain("Quero conversar sobre um projeto.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -15,6 +15,8 @@ import WhatsAppLink from "./WhatsAppLink";
|
|||||||
|
|
||||||
const MENU_ID = "site-navigation-menu";
|
const MENU_ID = "site-navigation-menu";
|
||||||
const CTA_LABEL = "Falar sobre meu projeto";
|
const CTA_LABEL = "Falar sobre meu projeto";
|
||||||
|
/** Fora da home o botão abre o WhatsApp: "Falar sobre meu projeto" é sempre o formulário. */
|
||||||
|
const WHATSAPP_LABEL = "Chamar no WhatsApp";
|
||||||
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
|
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
|
||||||
const INERT_IDS = ["main-content", "site-footer"];
|
const INERT_IDS = ["main-content", "site-footer"];
|
||||||
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
|
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
|
||||||
@@ -55,7 +57,8 @@ function trapTab(event: KeyboardEvent, header: HTMLElement) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
|
||||||
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
|
* já com o assunto da página, com o nome "Chamar no WhatsApp"; na home e nas demais páginas leva ao formulário da home
|
||||||
|
* ("Falar sobre meu projeto").
|
||||||
*/
|
*/
|
||||||
function ContactCta({
|
function ContactCta({
|
||||||
className,
|
className,
|
||||||
@@ -78,7 +81,7 @@ function ContactCta({
|
|||||||
placement={placement}
|
placement={placement}
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
>
|
>
|
||||||
{CTA_LABEL}
|
{WHATSAPP_LABEL}
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -218,7 +221,7 @@ export function Header({
|
|||||||
<li key={item.href}>
|
<li key={item.href}>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
|
className="cv-bar inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline [--bar-x:var(--sp-3)] hover:text-white"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
@@ -230,7 +233,7 @@ export function Header({
|
|||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
<div className="hidden xl:block" data-menu-trap-skip="">
|
<div className="hidden xl:block" data-menu-trap-skip="">
|
||||||
<ContactCta
|
<ContactCta
|
||||||
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
className="cv-fill inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
|
||||||
placement="header"
|
placement="header"
|
||||||
viaWhatsApp={viaWhatsApp}
|
viaWhatsApp={viaWhatsApp}
|
||||||
subject={subject}
|
subject={subject}
|
||||||
@@ -262,7 +265,7 @@ export function Header({
|
|||||||
{mobileMenuOpen ? (
|
{mobileMenuOpen ? (
|
||||||
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
|
||||||
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
|
||||||
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
|
<div className="ds-enter absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] [--enter-y:-8px] lg:px-sp-8 xl:hidden">
|
||||||
<div className="ds-section-shell flex flex-1 flex-col">
|
<div className="ds-section-shell flex flex-1 flex-col">
|
||||||
<nav
|
<nav
|
||||||
id={MENU_ID}
|
id={MENU_ID}
|
||||||
@@ -274,7 +277,7 @@ export function Header({
|
|||||||
<li key={item.href} className="border-b border-white/10">
|
<li key={item.href} className="border-b border-white/10">
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline transition-colors hover:text-white"
|
className="cv-bar flex min-h-14 items-center font-display text-[22px] font-medium normal-case leading-tight tracking-normal text-white/85 no-underline [--bar-y:100%] hover:text-white"
|
||||||
onClick={closeMobileMenu}
|
onClick={closeMobileMenu}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -284,7 +287,7 @@ export function Header({
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="mt-auto pt-sp-8">
|
<div className="mt-auto pt-sp-8">
|
||||||
<ContactCta
|
<ContactCta
|
||||||
className="flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
|
className="cv-fill flex min-h-14 w-full items-center justify-center rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[18px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline"
|
||||||
placement="header_menu"
|
placement="header_menu"
|
||||||
viaWhatsApp={viaWhatsApp}
|
viaWhatsApp={viaWhatsApp}
|
||||||
subject={subject}
|
subject={subject}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Hero from "./Hero";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
|
* Testes de caminhos-base de Hero (complemento de Hero.test.tsx): anos de experiência (sem stats, sem anos, zero anos, com anos),
|
||||||
* links sociais (externo x mailto, com rastreio), faixa de clientes e endereço do trabalho em destaque.
|
* links sociais (externo x mailto, com rastreio) e faixa de clientes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
jest.mock("./cinematic/HeroField", () => ({
|
jest.mock("./cinematic/HeroField", () => ({
|
||||||
@@ -107,25 +107,46 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
|
|||||||
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
expect(screen.queryByText("Já entregue para:")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: workUrl given -> the browser bar of the featured work shows it", () => {
|
it("path: the WhatsApp link carries the id that hides the floating WhatsApp; the form button does not", () => {
|
||||||
render(<Hero workUrl={{ text: "lavacao.example.com", secure: true }} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("hero-work")).toHaveTextContent(
|
|
||||||
"lavacao.example.com",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: workUrl omitted -> the browser bar shows no address", () => {
|
|
||||||
render(<Hero />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("hero-work")).not.toHaveTextContent(".com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the main button carries the id that hides the floating WhatsApp", () => {
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
|
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
|
||||||
).toHaveAttribute("id", "hero-contact-cta");
|
).toHaveAttribute("id", "hero-contact-cta");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
|
||||||
|
).not.toHaveAttribute("id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Hero: contato", () => {
|
||||||
|
it("path: 'Chamar no WhatsApp' -> a whatsapp_open from the hero, with the home subject", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
link.addEventListener("click", (event) => event.preventDefault());
|
||||||
|
fireEvent.click(link, { button: 0 });
|
||||||
|
|
||||||
|
expect(window.gtag).toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.objectContaining({
|
||||||
|
placement: "hero",
|
||||||
|
service: "um site ou sistema para a minha empresa",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: 'Falar sobre meu projeto' -> the form, with no WhatsApp event", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
const link = screen.getByRole("link", { name: /Falar sobre meu projeto/ });
|
||||||
|
link.addEventListener("click", (event) => event.preventDefault());
|
||||||
|
fireEvent.click(link, { button: 0 });
|
||||||
|
|
||||||
|
expect(link).toHaveAttribute("href", "/#contact-form");
|
||||||
|
expect(window.gtag).not.toHaveBeenCalledWith(
|
||||||
|
"event",
|
||||||
|
"whatsapp_open",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -36,49 +36,76 @@ describe("Hero", () => {
|
|||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
|
it("sends the main button to the contact form, keeps 'Ver projetos entregues' secondary and opens WhatsApp from a quiet link after them", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
const contactLink = screen.getByRole("link", {
|
const contactLink = screen.getByRole("link", {
|
||||||
name: /falar sobre meu projeto/i,
|
name: /falar sobre meu projeto/i,
|
||||||
});
|
});
|
||||||
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
|
||||||
expect(contactLink.getAttribute("href")).toMatch(
|
expect(contactLink).toHaveAttribute("href", "/#contact-form");
|
||||||
/^https:\/\/wa\.me\/\d+\?/,
|
expect(contactLink).not.toHaveAttribute("target");
|
||||||
);
|
// o botão primário é o único com o fundo limão (e o bloco cor de papel no ponteiro); o secundário é contorno
|
||||||
expect(contactLink).toHaveAttribute("target", "_blank");
|
|
||||||
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
|
|
||||||
const text = new URL(
|
|
||||||
contactLink.getAttribute("href") ?? "",
|
|
||||||
).searchParams.get("text");
|
|
||||||
expect(text).toBe(
|
|
||||||
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
|
|
||||||
);
|
|
||||||
// o botão primário é o único com o fundo limão; o secundário é contorno
|
|
||||||
expect(contactLink.className).toContain("bg-[var(--slash)]");
|
expect(contactLink.className).toContain("bg-[var(--slash)]");
|
||||||
|
expect(contactLink).toHaveClass("cv-fill");
|
||||||
|
expect(contactLink.className).not.toMatch(/hover:bg-/);
|
||||||
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
const projects = screen.getByRole("link", { name: /ver projetos/i });
|
||||||
expect(projects).toHaveAttribute("href", "/cases");
|
expect(projects).toHaveAttribute("href", "/cases");
|
||||||
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
expect(projects.className).not.toContain("bg-[var(--slash)]");
|
||||||
|
expect(projects).toHaveClass("cv-outline-dark");
|
||||||
expect(projects).not.toHaveAttribute("target");
|
expect(projects).not.toHaveAttribute("target");
|
||||||
|
|
||||||
|
const whatsApp = screen.getByRole("link", { name: "Chamar no WhatsApp" });
|
||||||
|
expect(whatsApp.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
|
||||||
|
expect(whatsApp).toHaveAttribute("target", "_blank");
|
||||||
|
expect(whatsApp).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
const text = new URL(whatsApp.getAttribute("href") ?? "").searchParams.get(
|
||||||
|
"text",
|
||||||
|
);
|
||||||
|
expect(text).toBe(
|
||||||
|
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
|
||||||
|
);
|
||||||
|
expect(whatsApp.className).not.toContain("bg-[var(--slash)]");
|
||||||
|
expect(whatsApp.querySelector("svg")).toHaveAttribute(
|
||||||
|
"aria-hidden",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
// a ordem do Tab: os dois botões e depois o link
|
||||||
|
expect(
|
||||||
|
contactLink.compareDocumentPosition(whatsApp) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
projects.compareDocumentPosition(whatsApp) &
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
it("keeps the headline as one readable sentence despite the per-word animation", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||||
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.",
|
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights only the short stretch 'pedidos pelo WhatsApp' in lime", () => {
|
it("highlights only 'sob medida' in lime, kept on one line under one continuous marker block", () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
const highlighted = Array.from(
|
const highlighted = Array.from(
|
||||||
screen.getByRole("heading", { level: 1 }).querySelectorAll(".hero-word"),
|
heading.querySelectorAll<HTMLElement>(".hero-word"),
|
||||||
)
|
).filter((word) => word.className.includes("text-[var(--slash)]"));
|
||||||
.filter((word) => word.className.includes("text-[var(--slash)]"))
|
expect(highlighted.map((word) => word.textContent)).toEqual([
|
||||||
.map((word) => word.textContent);
|
"sob",
|
||||||
expect(highlighted).toEqual(["pedidos", "pelo", "WhatsApp"]);
|
"medida",
|
||||||
|
]);
|
||||||
|
// um trecho só, que não quebra entre as duas palavras e leva o marcador; cada palavra mantém a sua entrada
|
||||||
|
const marks = heading.querySelectorAll(".hero-mark");
|
||||||
|
expect(marks).toHaveLength(1);
|
||||||
|
expect(marks[0]).toHaveClass("whitespace-nowrap");
|
||||||
|
expect(marks[0].textContent).toBe("sob medida");
|
||||||
|
for (const word of highlighted) expect(marks[0]).toContainElement(word);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
|
it("keeps the single LinkedIn profile and the e-mail, with no GitHub link", () => {
|
||||||
@@ -106,11 +133,22 @@ describe("Hero", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
|
"Do site institucional ao sistema que a sua equipe usa todo dia. Você fala com quem desenvolve.",
|
||||||
),
|
),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
// o primeiro passo, logo abaixo dos botões
|
||||||
|
const step = screen.getByText(
|
||||||
|
"Começa com uma conversa para entender o seu caso. A proposta vem por escrito.",
|
||||||
|
);
|
||||||
|
expect(step.className).toContain("text-[15px]");
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
|
screen
|
||||||
|
.getByRole("link", { name: "Chamar no WhatsApp" })
|
||||||
|
.compareDocumentPosition(step) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
// o botão flutuante do WhatsApp observa este id: some enquanto o link do hero está à vista
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Chamar no WhatsApp" }),
|
||||||
).toHaveAttribute("id", "hero-contact-cta");
|
).toHaveAttribute("id", "hero-contact-cta");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,27 +174,15 @@ describe("Hero", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the work column with lazy posters that stay out of small screens", () => {
|
it("shows the block piece as a decorative poster: hidden from assistive tech, with an empty alt", () => {
|
||||||
render(<Hero clients={clients} />);
|
const { container } = render(<Hero clients={clients} />);
|
||||||
|
|
||||||
const work = screen.getByTestId("hero-work");
|
const piece = container.querySelector(".hero-piece");
|
||||||
expect(work).toHaveClass("hidden", "lg:block");
|
expect(piece).not.toBeNull();
|
||||||
const images = work.querySelectorAll("img");
|
expect(piece).toHaveAttribute("aria-hidden", "true");
|
||||||
expect(images).toHaveLength(2);
|
const image = piece?.querySelector("img");
|
||||||
for (const image of images) {
|
expect(image?.getAttribute("src")).toContain("hero-poster");
|
||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("alt", "");
|
||||||
}
|
|
||||||
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
|
|
||||||
const original = (image: Element) =>
|
|
||||||
new URL(
|
|
||||||
image.getAttribute("src") ?? "",
|
|
||||||
"http://localhost",
|
|
||||||
).searchParams.get("url");
|
|
||||||
expect(original(images[0])).toBe("/videos/lavacao-047/desktop.webp");
|
|
||||||
expect(images[0]).toHaveAccessibleName(
|
|
||||||
"Site da Lavação 047 aberto no computador",
|
|
||||||
);
|
|
||||||
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a wide card to the SEV case below the buttons on small screens", () => {
|
it("shows a wide card to the SEV case below the buttons on small screens", () => {
|
||||||
@@ -173,13 +199,27 @@ describe("Hero", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
|
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
|
||||||
render(<Hero />);
|
const { container } = render(<Hero />);
|
||||||
|
|
||||||
const label = screen.getByText(
|
const label = container.querySelector(".hero-kicker") as HTMLElement;
|
||||||
/Evandro Fonseca Junior · Engenharia de software/,
|
expect(label.textContent).toBe(
|
||||||
|
"Evandro Fonseca Junior · Engenharia de software",
|
||||||
);
|
);
|
||||||
expect(label.className).toContain("font-display");
|
expect(label.className).toContain("font-display");
|
||||||
expect(label.className).not.toContain("font-mono");
|
expect(label.className).not.toContain("font-mono");
|
||||||
|
// sem caixa alta nem espaçamento largo
|
||||||
|
expect(label.className).not.toMatch(/uppercase|tracking-\[0\.1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only 'Engenharia de software' in the label below 1024 px, where it shares the line with the piece", () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Evandro Fonseca Junior/)).toHaveClass(
|
||||||
|
"max-lg:hidden",
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Engenharia de software/).className).not.toContain(
|
||||||
|
"hidden",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
|
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import { type CSSProperties, Fragment } from "react";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||||
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
||||||
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
import { HOME_SUBJECT } from "@/lib/whatsapp";
|
||||||
|
import { textLinkDark } from "./button-styles";
|
||||||
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
import ClientStrip, { type ClientLink } from "./ClientStrip";
|
||||||
import HeroField from "./cinematic/HeroField";
|
import HeroField from "./cinematic/HeroField";
|
||||||
|
import { CONTACT_FORM_HASH } from "./contact-subject";
|
||||||
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
||||||
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
import HeroWork from "./HeroWork";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { HERO_CAPTIONS } from "./hero-captions";
|
import { HERO_CAPTIONS } from "./hero-captions";
|
||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{
|
{
|
||||||
@@ -33,28 +37,58 @@ export type HeroStats = {
|
|||||||
years?: number;
|
years?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** O destaque em limão fica num trecho curto: "pedidos pelo WhatsApp" (palavras 5 a 7). */
|
/** O destaque em limão fica num trecho curto: "sob medida" (palavras 3 e 4). */
|
||||||
const ACCENT = new Set([5, 6, 7]);
|
const ACCENT = new Set([3, 4]);
|
||||||
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
|
|
||||||
|
type TitleWord = { word: string; key: string; index: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. As palavras
|
||||||
|
* do destaque seguidas viram um trecho só: elas nunca se separam numa quebra de linha, e o bloco do marcador as cobre
|
||||||
|
* de uma vez.
|
||||||
|
*/
|
||||||
const TITLE =
|
const TITLE =
|
||||||
"Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha."
|
"Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((word, index) => ({
|
.reduce<{ key: string; accent: boolean; words: TitleWord[] }[]>(
|
||||||
word,
|
(runs, word, index) => {
|
||||||
// a mesma palavra se repete ("e"), então a posição entra na chave
|
const accent = ACCENT.has(index);
|
||||||
key: `${word}-${index}`,
|
// a mesma palavra se repete ("e"), então a posição entra na chave
|
||||||
accent: ACCENT.has(index),
|
const entry = { word, key: `${word}-${index}`, index };
|
||||||
}));
|
const last = runs.at(-1);
|
||||||
|
if (accent && last?.accent) last.words.push(entry);
|
||||||
|
else runs.push({ key: entry.key, accent, words: [entry] });
|
||||||
|
return runs;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
function TitleWords({
|
||||||
|
words,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
words: TitleWord[];
|
||||||
|
accent: boolean;
|
||||||
|
}) {
|
||||||
|
return words.map(({ word, key, index }, position) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
{position > 0 && " "}
|
||||||
|
<span
|
||||||
|
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
|
||||||
|
style={{ "--i": index } as CSSProperties}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export default function Hero({
|
export default function Hero({
|
||||||
stats,
|
stats,
|
||||||
clients,
|
clients,
|
||||||
workUrl,
|
|
||||||
}: {
|
}: {
|
||||||
stats?: HeroStats;
|
stats?: HeroStats;
|
||||||
clients?: ClientLink[];
|
clients?: ClientLink[];
|
||||||
/** Endereço mostrado na barra do navegador de HeroWork (o domínio próprio do site da SEV Moda Íntima). */
|
|
||||||
workUrl?: HeroWorkUrl;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -63,8 +97,9 @@ export default function Hero({
|
|||||||
>
|
>
|
||||||
<div className="hero-stage">
|
<div className="hero-stage">
|
||||||
<div className="hero-glow" aria-hidden="true" />
|
<div className="hero-glow" aria-hidden="true" />
|
||||||
<HeroField />
|
|
||||||
<div className="hero-scrim" aria-hidden="true" />
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
|
{/* a cena 3D fica acima do véu (o mesmo brilho do pôster) e abaixo das legendas e do texto */}
|
||||||
|
<HeroField />
|
||||||
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
<ol className="hero-captions">
|
<ol className="hero-captions">
|
||||||
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
||||||
@@ -81,38 +116,48 @@ export default function Hero({
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
|
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
|
||||||
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
|
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)]">
|
||||||
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
|
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
|
||||||
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
|
{/* Abaixo de 1024 px o rótulo divide a linha com a peça: fica só a profissão, e o nome volta na tela larga. */}
|
||||||
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
<p className="hero-in hero-kicker mb-sp-4 flex items-center gap-sp-3 font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]">
|
||||||
Evandro Fonseca Junior · Engenharia de software
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-2 w-2 shrink-0 bg-[var(--slash)]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="max-lg:hidden">Evandro Fonseca Junior · </span>
|
||||||
|
Engenharia de software
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
{/* Sem opacity:0: as palavras pintam no primeiro quadro (LCP); a entrada anima só posição e foco. */}
|
||||||
{/* No desktop o título ocupa até 54% da largura; a coluna HeroWork fica com 40%, à direita. */}
|
{/* No desktop o texto fica na coluna da esquerda (--hero-col, em globals.css); a peça (.hero-piece) vem logo à direita. */}
|
||||||
<h1 className="hero-title max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em] lg:max-w-[54%]">
|
<h1 className="hero-title hero-col max-w-[16em] text-[clamp(34px,min(8.4vw,5.2svh+14px),84px)] font-semibold leading-[1.02] tracking-[-0.045em]">
|
||||||
{TITLE.map(({ word, key, accent }, index) => (
|
{TITLE.map(({ key, accent, words }) => (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
<span
|
{accent ? (
|
||||||
className={`hero-word${accent ? " text-[var(--slash)]" : ""}`}
|
// o marcador (::after) vive no trecho e não em cada palavra: um bloco contínuo sobre "sob medida"
|
||||||
style={{ "--i": index } as CSSProperties}
|
<span
|
||||||
>
|
className="hero-mark whitespace-nowrap"
|
||||||
{word}
|
style={{ "--i": words[0].index } as CSSProperties}
|
||||||
</span>{" "}
|
>
|
||||||
|
<TitleWords words={words} accent />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<TitleWords words={words} accent={false} />
|
||||||
|
)}{" "}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
|
<p className="hero-in hero-col mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.4s]">
|
||||||
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
|
Do site institucional ao sistema que a sua equipe usa todo dia. Você
|
||||||
fala com quem desenvolve.
|
fala com quem desenvolve.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
|
<div className="hero-in hero-col hero-actions mt-sp-5 flex flex-col gap-sp-3 [--d:.48s] sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
|
{/* O formulário, como o botão do cabeçalho; quem prefere o WhatsApp tem o link logo depois dos botões. */}
|
||||||
<WhatsAppLink
|
<HoverPrefetchLink
|
||||||
id={HERO_CTA_ID}
|
href={`/${CONTACT_FORM_HASH}`}
|
||||||
service={HOME_SUBJECT}
|
className="cv-fill group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline sm:px-sp-6 sm:text-[16px]"
|
||||||
placement="hero"
|
|
||||||
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
|
|
||||||
>
|
>
|
||||||
Falar sobre meu projeto
|
Falar sobre meu projeto
|
||||||
<svg
|
<svg
|
||||||
@@ -125,28 +170,59 @@ export default function Hero({
|
|||||||
>
|
>
|
||||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</WhatsAppLink>
|
</HoverPrefetchLink>
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href="/cases"
|
href="/cases"
|
||||||
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
|
className="cv-outline-dark inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline sm:text-[16px]"
|
||||||
>
|
>
|
||||||
Ver projetos entregues
|
Ver projetos entregues
|
||||||
</HoverPrefetchLink>
|
</HoverPrefetchLink>
|
||||||
|
{/* Abre o WhatsApp com o assunto da home; enquanto está à vista, o botão flutuante (que observa este id) some. */}
|
||||||
|
<WhatsAppLink
|
||||||
|
id={HERO_CTA_ID}
|
||||||
|
service={HOME_SUBJECT}
|
||||||
|
placement="hero"
|
||||||
|
className={`${textLinkDark} gap-sp-2 self-start sm:self-auto`}
|
||||||
|
>
|
||||||
|
<WhatsAppMark className="h-4 w-4 shrink-0" />
|
||||||
|
Chamar no WhatsApp
|
||||||
|
</WhatsAppLink>
|
||||||
</div>
|
</div>
|
||||||
|
{/* O primeiro passo, dito antes de a pessoa clicar: tira a dúvida de "e depois, o que acontece?". */}
|
||||||
|
<p className="hero-in hero-col mt-sp-3 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] [--d:.54s]">
|
||||||
|
Começa com uma conversa para entender o seu caso. A proposta vem por
|
||||||
|
escrito.
|
||||||
|
</p>
|
||||||
|
|
||||||
<ClientStrip
|
<ClientStrip
|
||||||
clients={clients}
|
clients={clients}
|
||||||
years={stats?.years}
|
years={stats?.years}
|
||||||
className="hero-in mt-sp-3 [--d:.75s]"
|
className="hero-in hero-col mt-sp-3 [--d:.6s]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HeroWork url={workUrl} />
|
<HeroWork />
|
||||||
|
|
||||||
|
{/* A marca em blocos: pôster renderizado no Blender, decorativo. Carrega junto com a página (eager, sem prioridade
|
||||||
|
alta). O título segue como o maior elemento medido (LCP) no celular e em tela larga: a imagem entra com
|
||||||
|
fade a partir de opacidade zero e some por `visibility` quando a cena assume (globals.css). */}
|
||||||
|
<div className="hero-piece hero-in [--d:.3s]" aria-hidden="true">
|
||||||
|
<Image
|
||||||
|
src="/3d/hero-poster.webp"
|
||||||
|
alt=""
|
||||||
|
width={1200}
|
||||||
|
height={1200}
|
||||||
|
sizes="(min-width: 1024px) min(56vw, 816px), 30vw"
|
||||||
|
loading="eager"
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5 lg:px-sp-12">
|
<div className="hero-foot relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-wrap items-end justify-between gap-sp-5 px-sp-6 pb-sp-5">
|
||||||
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
|
||||||
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo */}
|
{/* no celular, serviços e redes ficam no menu e no rodapé: assim o hero cabe numa tela e pode ficar fixo; no
|
||||||
<div className="max-lg:hidden">
|
computador ficam numa linha só, para o palco fixo caber em 1024x768 */}
|
||||||
|
<div className="flex-wrap items-center gap-x-sp-8 max-lg:hidden lg:flex">
|
||||||
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
<ul className="flex flex-wrap gap-x-sp-5 gap-y-sp-2 text-[14px] leading-[1.5] text-[rgba(244,242,237,.8)]">
|
||||||
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
|
{["Sites", "Sistemas web", "Aplicativos", "Integrações"].map(
|
||||||
(service) => (
|
(service) => (
|
||||||
@@ -185,7 +261,7 @@ export default function Hero({
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="hero-cue hidden items-center gap-sp-3 font-display text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
|
className="hero-cue hidden items-center gap-sp-3 font-display text-[13px] tracking-[0.02em] text-[rgba(244,242,237,.55)] xl:flex"
|
||||||
>
|
>
|
||||||
Role para mergulhar
|
Role para mergulhar
|
||||||
<span className="hero-cue-line" />
|
<span className="hero-cue-line" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import HeroWork from "./HeroWork";
|
import HeroWork from "./HeroWork";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
|
* O pôster do hero: pelo next/image com `sizes` do tamanho em que aparece, lazy e com prioridade baixa (o título é o
|
||||||
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
|
* LCP), no cartão largo e clicável da SEV para as telas abaixo de 1024 px. Em tela larga a coluna é da peça 3D.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const original = (image: Element) =>
|
const original = (image: Element) =>
|
||||||
@@ -12,23 +12,9 @@ const original = (image: Element) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
describe("HeroWork: pôsteres no tamanho certo", () => {
|
describe("HeroWork: pôsteres no tamanho certo", () => {
|
||||||
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
|
it("serves the SEV poster through the image optimizer, with a `sizes` that matches how it shows", () => {
|
||||||
render(<HeroWork />);
|
render(<HeroWork />);
|
||||||
|
|
||||||
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
|
|
||||||
expect(desktop).toHaveLength(2);
|
|
||||||
expect(original(desktop[0])).toBe("/videos/lavacao-047/desktop.webp");
|
|
||||||
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
|
|
||||||
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
|
|
||||||
expect(desktop[0]).toHaveAttribute(
|
|
||||||
"sizes",
|
|
||||||
"(min-width: 1400px) 500px, 36vw",
|
|
||||||
);
|
|
||||||
expect(desktop[1]).toHaveAttribute(
|
|
||||||
"sizes",
|
|
||||||
"(min-width: 1400px) 136px, 10vw",
|
|
||||||
);
|
|
||||||
|
|
||||||
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
|
// o cartão do celular usa o pôster de celular da SEV (o título em letras grandes cabe na faixa de 72 px), na largura
|
||||||
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
|
// da coluna (a tela menos as duas margens: cerca de 342 px num celular de 390 px), e não 100vw
|
||||||
const small = screen
|
const small = screen
|
||||||
@@ -55,23 +41,11 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
|
|||||||
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
|
expect((small.parentElement as HTMLElement).style.height).toBe("72px");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
|
|
||||||
render(<HeroWork />);
|
|
||||||
|
|
||||||
const [browser, phone] = within(
|
|
||||||
screen.getByTestId("hero-work"),
|
|
||||||
).getAllByRole("img");
|
|
||||||
expect(browser).toHaveAttribute("width", "1440");
|
|
||||||
expect(browser).toHaveAttribute("height", "900");
|
|
||||||
expect(phone).toHaveAttribute("width", "390");
|
|
||||||
expect(phone).toHaveAttribute("height", "844");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
|
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
|
||||||
const { container } = render(<HeroWork />);
|
const { container } = render(<HeroWork />);
|
||||||
|
|
||||||
const images = container.querySelectorAll("img");
|
const images = container.querySelectorAll("img");
|
||||||
expect(images).toHaveLength(3);
|
expect(images).toHaveLength(1);
|
||||||
for (const image of images) {
|
for (const image of images) {
|
||||||
expect(image).toHaveAttribute("loading", "lazy");
|
expect(image).toHaveAttribute("loading", "lazy");
|
||||||
expect(image).toHaveAttribute("fetchpriority", "low");
|
expect(image).toHaveAttribute("fetchpriority", "low");
|
||||||
@@ -82,13 +56,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroWork: cartão do celular", () => {
|
describe("HeroWork: cartão do celular", () => {
|
||||||
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
|
it("is the whole component: a wide card with the SEV poster, only below 1024px", () => {
|
||||||
render(<HeroWork />);
|
const { container } = render(<HeroWork />);
|
||||||
|
|
||||||
const column = screen.getByTestId("hero-work");
|
|
||||||
expect(column).toHaveClass("hidden", "lg:block");
|
|
||||||
|
|
||||||
const card = screen.getByTestId("hero-work-mobile");
|
const card = screen.getByTestId("hero-work-mobile");
|
||||||
|
expect(container.firstChild).toBe(card);
|
||||||
|
expect(container.childElementCount).toBe(1);
|
||||||
expect(card).toHaveClass("lg:hidden", "w-full");
|
expect(card).toHaveClass("lg:hidden", "w-full");
|
||||||
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
||||||
expect(card.querySelector(".ds-phone")).toBeNull();
|
expect(card.querySelector(".ds-phone")).toBeNull();
|
||||||
@@ -119,21 +92,4 @@ describe("HeroWork: cartão do celular", () => {
|
|||||||
|
|
||||||
expect(screen.getAllByRole("link")).toHaveLength(1);
|
expect(screen.getAllByRole("link")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
|
|
||||||
render(<HeroWork />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
|
|
||||||
expect(
|
|
||||||
within(screen.getByTestId("hero-work")).queryByRole("link"),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the address to the browser bar of the desktop frame", () => {
|
|
||||||
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { BrowserFrame } from "./showcase/BrowserFrame";
|
|
||||||
import { PhoneFrame } from "./showcase/PhoneFrame";
|
|
||||||
|
|
||||||
/** Endereço mostrado na barra do navegador (mesmo formato de displayUrl em showcase/BrowserFrame). */
|
/** O cartão mostra o site da SEV e leva ao case. */
|
||||||
export type HeroWorkUrl = { text: string; secure: boolean } | null;
|
|
||||||
|
|
||||||
const DESKTOP_PROJECT = "lavacao-047";
|
|
||||||
const DESKTOP_PROJECT_TITLE = "Lavação 047";
|
|
||||||
const MOBILE_PROJECT = "as-servicos";
|
|
||||||
/** No celular o cartão mostra o site da SEV e leva ao case; o navegador da tela larga, ao lado do título, mostra o da Lavação 047. */
|
|
||||||
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
const SMALL_SCREEN_PROJECT = "sev-modaintima";
|
||||||
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
|
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
|
||||||
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
|
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
|
||||||
@@ -28,95 +20,54 @@ const SMALL_CARD_IMAGE_HEIGHT = 72;
|
|||||||
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
|
const SMALL_CARD_IMAGE_POSITION = "object-[50%_57%]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da
|
* Trabalho real do hero, só abaixo de 1024 px: um cartão largo e clicável (a largura da coluna, cerca de 350 px num
|
||||||
* Lavação 047 no navegador (a barra de endereço só mostra domínio próprio; o da Lavação é de hospedagem, então ela
|
* celular) com o pôster do site da SEV, abaixo dos botões, que leva ao case da SEV; antes era uma moldura de celular
|
||||||
* fica com "pré-visualização") e o do aplicativo da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
|
* de 65 por 160 px, pequena demais para ver o que era. Em telas largas a coluna da direita é da peça 3D, e o trabalho
|
||||||
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
|
* real começa na seção Projetos.
|
||||||
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
|
* O pôster vai pelo next/image com `sizes` do tamanho em que aparece, em `loading="lazy"` e prioridade baixa: o título
|
||||||
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
|
* segue como o maior elemento da dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e em tela larga, onde o cartão fica
|
||||||
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
|
* escondido pelo breakpoint (display: none), a imagem nem é baixada.
|
||||||
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
|
|
||||||
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
|
|
||||||
*/
|
*/
|
||||||
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
export default function HeroWork() {
|
||||||
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
|
||||||
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
|
||||||
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<HoverPrefetchLink
|
||||||
<div
|
data-testid="hero-work-mobile"
|
||||||
data-testid="hero-work"
|
href={SMALL_SCREEN_HREF}
|
||||||
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
|
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="relative block w-full overflow-hidden bg-[var(--ink)]"
|
||||||
|
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
|
||||||
>
|
>
|
||||||
<BrowserFrame url={url} className="w-[88%]">
|
<Image
|
||||||
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
|
src={smallCard.poster}
|
||||||
<Image
|
alt=""
|
||||||
src={desktop.poster}
|
width={smallCard.width}
|
||||||
alt={`Site da ${DESKTOP_PROJECT_TITLE} aberto no computador`}
|
height={smallCard.height}
|
||||||
width={desktop.width}
|
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
|
||||||
height={desktop.height}
|
loading="lazy"
|
||||||
sizes="(min-width: 1400px) 500px, 36vw"
|
fetchPriority="low"
|
||||||
loading="lazy"
|
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
|
||||||
fetchPriority="low"
|
/>
|
||||||
className="block h-auto w-full"
|
</span>
|
||||||
/>
|
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
|
||||||
</BrowserFrame>
|
<span className="flex flex-col">
|
||||||
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
|
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
||||||
<PhoneFrame
|
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
|
||||||
className="right-0 bottom-[-12%] w-[26%]"
|
|
||||||
style={{ position: "absolute" }}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={phone.poster}
|
|
||||||
alt="Site da AS Serviços aberto no celular"
|
|
||||||
width={phone.width}
|
|
||||||
height={phone.height}
|
|
||||||
sizes="(min-width: 1400px) 136px, 10vw"
|
|
||||||
loading="lazy"
|
|
||||||
fetchPriority="low"
|
|
||||||
className="absolute inset-0 block h-full w-full object-cover object-top"
|
|
||||||
/>
|
|
||||||
</PhoneFrame>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HoverPrefetchLink
|
|
||||||
data-testid="hero-work-mobile"
|
|
||||||
href={SMALL_SCREEN_HREF}
|
|
||||||
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="relative block w-full overflow-hidden bg-[var(--ink)]"
|
|
||||||
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={smallCard.poster}
|
|
||||||
alt=""
|
|
||||||
width={smallCard.width}
|
|
||||||
height={smallCard.height}
|
|
||||||
sizes="(min-width: 1024px) 1px, calc(100vw - 48px)"
|
|
||||||
loading="lazy"
|
|
||||||
fetchPriority="low"
|
|
||||||
className={`absolute inset-0 block h-full w-full object-cover ${SMALL_CARD_IMAGE_POSITION}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
|
<svg
|
||||||
<span className="flex flex-col">
|
aria-hidden="true"
|
||||||
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
viewBox="0 0 16 16"
|
||||||
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
|
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
||||||
</span>
|
fill="none"
|
||||||
<svg
|
stroke="currentColor"
|
||||||
aria-hidden="true"
|
strokeWidth="2"
|
||||||
viewBox="0 0 16 16"
|
>
|
||||||
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
fill="none"
|
</svg>
|
||||||
stroke="currentColor"
|
</span>
|
||||||
strokeWidth="2"
|
</HoverPrefetchLink>
|
||||||
>
|
|
||||||
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</HoverPrefetchLink>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { Logo } from "./Logo";
|
||||||
|
|
||||||
|
describe("Logo", () => {
|
||||||
|
it("shows the text cutout with the same alt text and a decorative inline mark", () => {
|
||||||
|
const { container } = render(<Logo />);
|
||||||
|
|
||||||
|
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
|
||||||
|
expect(text.getAttribute("src")).toContain("wordmark-text-dark.webp");
|
||||||
|
expect(text).toHaveAttribute("width", "1600");
|
||||||
|
expect(text).toHaveAttribute("height", "330");
|
||||||
|
|
||||||
|
const mark = container.querySelector("svg");
|
||||||
|
expect(mark).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(mark?.querySelectorAll(".logo-bloco")).toHaveLength(4);
|
||||||
|
expect(screen.getAllByRole("img")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the caller's className and loading", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<Logo theme="light" loading="eager" className="w-40 sm:w-44" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstElementChild).toHaveClass("w-40", "sm:w-44");
|
||||||
|
const text = screen.getByRole("img", { name: "Evandro.dev.br" });
|
||||||
|
expect(text.getAttribute("src")).toContain("wordmark-text-light.webp");
|
||||||
|
expect(text).toHaveAttribute("loading", "eager");
|
||||||
|
expect(container.querySelector("svg path")).toHaveAttribute(
|
||||||
|
"fill",
|
||||||
|
"#00090b",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import "./logo.css";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
theme?: "dark" | "light";
|
theme?: "dark" | "light";
|
||||||
@@ -6,20 +7,56 @@ interface LogoProps {
|
|||||||
loading?: "eager" | "lazy";
|
loading?: "eager" | "lazy";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* O logo em duas partes sobre a caixa do wordmark-<tema>.webp (1600x330): o símbolo em SVG em linha, para os blocos
|
||||||
|
* lima poderem se mexer (logo.css), e o texto, que é o mesmo .webp com o símbolo apagado (wordmark-text-<tema>.webp,
|
||||||
|
* do mesmo tamanho, para passar pelo mesmo redimensionamento e dar a mesma altura de antes). As coordenadas do SVG
|
||||||
|
* são pixels do .webp original, medidas nas bordas das formas; cor e opacidade também vêm dele.
|
||||||
|
*/
|
||||||
|
const SHAPE_FILL = { dark: "#f4f3ee", light: "#00090b" } as const;
|
||||||
|
|
||||||
|
// em ordem de encaixe, de baixo para cima
|
||||||
|
const BLOCKS = [
|
||||||
|
{ x: 130.66, y: 110.25, width: 76.47, height: 86.05 },
|
||||||
|
{ x: 204.39, y: 43.9, width: 67.72, height: 69.39 },
|
||||||
|
{ x: 287.27, y: 3.28, width: 39.58, height: 40.82 },
|
||||||
|
{ x: 312.91, y: 79.72, width: 39.39, height: 39.76 },
|
||||||
|
];
|
||||||
|
|
||||||
export function Logo({
|
export function Logo({
|
||||||
theme = "dark",
|
theme = "dark",
|
||||||
className = "w-48",
|
className = "w-48",
|
||||||
loading = "lazy",
|
loading = "lazy",
|
||||||
}: Readonly<LogoProps>) {
|
}: Readonly<LogoProps>) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<span className={`relative block max-w-full ${className}`}>
|
||||||
src={`/brand/wordmark-${theme}.webp`}
|
<svg
|
||||||
alt="Evandro.dev.br"
|
aria-hidden="true"
|
||||||
width={1600}
|
focusable="false"
|
||||||
height={330}
|
viewBox="0 0 360 330"
|
||||||
sizes="240px"
|
preserveAspectRatio="none"
|
||||||
loading={loading}
|
className="absolute top-0 left-0 h-full w-[22.5%] overflow-visible"
|
||||||
className={`block h-auto max-w-full ${className}`}
|
>
|
||||||
/>
|
<path
|
||||||
|
fill={SHAPE_FILL[theme]}
|
||||||
|
fillOpacity={0.933}
|
||||||
|
d="M10.17 223.44H40.23V188.63H74.87V153.98H111.2V222.33H74.93V277.73H139.93V217.4H200.14V291.36H171.59V325.32H10.17Z"
|
||||||
|
/>
|
||||||
|
<g fill="#ccfa00" opacity={0.98}>
|
||||||
|
{BLOCKS.map((block) => (
|
||||||
|
<rect key={block.x} className="logo-bloco" {...block} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<Image
|
||||||
|
src={`/brand/wordmark-text-${theme}.webp`}
|
||||||
|
alt="Evandro.dev.br"
|
||||||
|
width={1600}
|
||||||
|
height={330}
|
||||||
|
sizes="240px"
|
||||||
|
loading={loading}
|
||||||
|
className="block h-auto w-full"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { act, render, waitFor } from "@testing-library/react";
|
||||||
|
import { OfferVideo } from "./OfferVideo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O vídeo da peça 3D toca quando aparece (o IntersectionObserver do jest.setup avisa logo depois de observar) e fica
|
||||||
|
* parado no pôster com movimento reduzido ou "Economizar dados".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const realMatchMedia = window.matchMedia;
|
||||||
|
|
||||||
|
let play: jest.SpyInstance;
|
||||||
|
let pause: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
play = jest
|
||||||
|
.spyOn(HTMLMediaElement.prototype, "play")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
pause = jest
|
||||||
|
.spyOn(HTMLMediaElement.prototype, "pause")
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
window.matchMedia = realMatchMedia;
|
||||||
|
Reflect.deleteProperty(navigator, "connection");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: in view -> plays, muted and in a loop", async () => {
|
||||||
|
const { container } = render(<OfferVideo name="whatsapp" />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(play).toHaveBeenCalled());
|
||||||
|
const video = container.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
expect(video.loop).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: reduced motion -> never plays, stays on the poster", () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: query === "(prefers-reduced-motion: reduce)",
|
||||||
|
media: query,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
}));
|
||||||
|
const { container } = render(<OfferVideo name="sistema" />);
|
||||||
|
|
||||||
|
// o aviso de "à vista" do IntersectionObserver chega num setTimeout
|
||||||
|
act(() => jest.runAllTimers());
|
||||||
|
expect(pause).toHaveBeenCalled();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(container.querySelector("video")).toHaveAttribute(
|
||||||
|
"poster",
|
||||||
|
"/3d/peca-sistema.webp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: save data -> never plays", () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
Object.defineProperty(navigator, "connection", {
|
||||||
|
configurable: true,
|
||||||
|
value: { saveData: true },
|
||||||
|
});
|
||||||
|
render(<OfferVideo name="sistema" />);
|
||||||
|
|
||||||
|
act(() => jest.runAllTimers());
|
||||||
|
expect(pause).toHaveBeenCalled();
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useInView } from "@/app/hooks/useInView";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import { useSaveData } from "./showcase/useShowcaseMedia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peça 3D em loop no cartão da oferta (`public/3d/peca-<nome>.{webm,mp4}`, 640x400, com o pôster `.webp`). É decorativa:
|
||||||
|
* o nome do cartão e a lista dizem o que é. Nada baixa antes de tocar (`preload="none"`); toca só enquanto está à vista e
|
||||||
|
* fica no pôster com movimento reduzido ou "Economizar dados".
|
||||||
|
*/
|
||||||
|
export function OfferVideo({ name }: Readonly<{ name: string }>) {
|
||||||
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
|
const inView = useInView(ref, { amount: 0.3, once: false });
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
const saveData = useSaveData();
|
||||||
|
const play = inView && !reducedMotion && !saveData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = ref.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (!play) {
|
||||||
|
video.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// o navegador pode recusar a reprodução (economia de energia, aba em segundo plano): o vídeo fica no pôster
|
||||||
|
video.play()?.catch(() => {});
|
||||||
|
}, [play]);
|
||||||
|
|
||||||
|
const src = `/3d/peca-${name}`;
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={ref}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
poster={`${src}.webp`}
|
||||||
|
width={640}
|
||||||
|
height={400}
|
||||||
|
// contain, não cover: o fundo do vídeo é o mesmo do quadro, e a peça nunca é cortada num quadro mais largo
|
||||||
|
className="block h-full w-full object-contain"
|
||||||
|
>
|
||||||
|
<source src={`${src}.webm`} type="video/webm" />
|
||||||
|
<source src={`${src}.mp4`} type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { OfferVideo } from "./OfferVideo";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peça de blocos na coluna da direita do topo de uma página interna (a grade é `heroWithMediaGrid`), só de 1024 px em
|
||||||
|
* diante. Decorativa: fora da leitura. A peça não mede nada: ocupa a altura que o texto ao lado já deu à linha (e um
|
||||||
|
* pouco da folga de cima e de baixo), então não empurra o texto nem aumenta o topo.
|
||||||
|
* `image`: imagem de fundo transparente (`/3d/estado-*.webp`, 1200x900). `loop`: o loop `public/3d/peca-<nome>`
|
||||||
|
* (640x400, fundo #0a0a0a, que some no topo escuro), pelo OfferVideo.
|
||||||
|
*/
|
||||||
|
export function PagePiece(
|
||||||
|
props: Readonly<{ image: string } | { loop: string }>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className="relative hidden self-stretch lg:block">
|
||||||
|
{/* object-contain: a peça inteira à vista; o loop chega com object-cover do OfferVideo e não pode perder as bordas */}
|
||||||
|
<div className="absolute inset-x-0 -top-sp-8 -bottom-sp-6 [&_video]:object-contain">
|
||||||
|
{"image" in props ? (
|
||||||
|
<Image
|
||||||
|
src={props.image}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="(min-width: 1024px) 40vw, 1px"
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OfferVideo name={props.loop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -178,7 +178,7 @@ describe("Section", () => {
|
|||||||
"ds-section-description",
|
"ds-section-description",
|
||||||
"ds-reveal",
|
"ds-reveal",
|
||||||
);
|
);
|
||||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
|
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-type");
|
||||||
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
|
// um <div>, não um <aside>: dentro de <main> o <aside> seria um landmark "complementary" fora do topo
|
||||||
const side = screen.getByText("lateral").parentElement;
|
const side = screen.getByText("lateral").parentElement;
|
||||||
expect(side?.tagName).toBe("DIV");
|
expect(side?.tagName).toBe("DIV");
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ describe("Section", () => {
|
|||||||
</Section>,
|
</Section>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow");
|
const eyebrow = screen.getByText("Sobre");
|
||||||
|
expect(eyebrow).toHaveClass("ds-eyebrow", "ds-type");
|
||||||
|
expect(eyebrow.style.getPropertyValue("--n")).toBe("5");
|
||||||
expect(container.querySelector(".ds-section-description")).toBeNull();
|
expect(container.querySelector(".ds-section-description")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
container.querySelector(".ds-section-header > .ds-reveal"),
|
container.querySelector(".ds-section-header > .ds-reveal"),
|
||||||
|
|||||||
@@ -45,7 +45,15 @@ export const Section = ({
|
|||||||
>
|
>
|
||||||
<div className="ds-section-meta">
|
<div className="ds-section-meta">
|
||||||
{eyebrow && (
|
{eyebrow && (
|
||||||
<p className="ds-eyebrow ds-reveal" style={stagger(0.02)}>
|
<p
|
||||||
|
className="ds-eyebrow ds-type"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...stagger(0.02),
|
||||||
|
"--n": eyebrow.length,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { WorkModalities } from "./WorkModalities";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
|
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
|
||||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (nome e "O que você recebe" vindos de
|
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as quatro ofertas (nome e "O que você recebe" vindos de
|
||||||
* `serviceOffers`, foto do case ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo" uma
|
* `serviceOffers`, foto do case, vídeo ou ilustração, botão que abre o formulário com o tipo escolhido, "Primeiro passo"
|
||||||
* vez só depois dos cartões), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
|
* uma vez só depois dos cartões), a faixa da nota fiscal acima deles, o estilo único de cartão, o texto de pelo menos
|
||||||
|
* 15 px e a lista compacta de etapas.
|
||||||
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
|
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -33,6 +34,18 @@ jest.mock("./Section", () => ({
|
|||||||
|
|
||||||
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
||||||
|
|
||||||
|
// o jsdom não toca vídeo: os cartões do sistema e do atendimento chamam play() ao aparecer
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
|
||||||
|
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const HOME_OFFERS = serviceOffers.filter(({ id }) => id !== "fiscal");
|
||||||
|
|
||||||
const original = (image: Element) =>
|
const original = (image: Element) =>
|
||||||
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||||
"url",
|
"url",
|
||||||
@@ -43,20 +56,20 @@ describe("WorkModalities: movimento", () => {
|
|||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||||
expect(reveals).toHaveLength(3);
|
expect(reveals).toHaveLength(4);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
reveals.map((element) => element.style.getPropertyValue("--reveal-y")),
|
||||||
).toEqual(["20px", "20px", "20px"]);
|
).toEqual(["20px", "20px", "20px", "20px"]);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) =>
|
reveals.map((element) =>
|
||||||
element.style.getPropertyValue("--reveal-duration"),
|
element.style.getPropertyValue("--reveal-duration"),
|
||||||
),
|
),
|
||||||
).toEqual(["0.75s", "0.75s", "0.75s"]);
|
).toEqual(["0.75s", "0.75s", "0.75s", "0.75s"]);
|
||||||
expect(
|
expect(
|
||||||
reveals.map((element) =>
|
reveals.map((element) =>
|
||||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
Number.parseFloat(element.style.getPropertyValue("--reveal-delay")),
|
||||||
),
|
),
|
||||||
).toEqual([0.08, 0.16, 0.24]);
|
).toEqual([0.08, 0.16, 0.24, 0.32]);
|
||||||
// o estado escondido vive no CSS: sem opacidade nem transformação inline
|
// o estado escondido vive no CSS: sem opacidade nem transformação inline
|
||||||
for (const element of reveals) {
|
for (const element of reveals) {
|
||||||
expect(element.style.opacity).toBe("");
|
expect(element.style.opacity).toBe("");
|
||||||
@@ -68,7 +81,7 @@ describe("WorkModalities: movimento", () => {
|
|||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const cards = container.querySelectorAll("article");
|
const cards = container.querySelectorAll("article");
|
||||||
expect(cards).toHaveLength(3);
|
expect(cards).toHaveLength(4);
|
||||||
for (const card of cards) {
|
for (const card of cards) {
|
||||||
expect(card).not.toHaveAttribute("tabindex");
|
expect(card).not.toHaveAttribute("tabindex");
|
||||||
expect(card.className).not.toContain("outline-none");
|
expect(card.className).not.toContain("outline-none");
|
||||||
@@ -87,7 +100,7 @@ describe("WorkModalities: movimento", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkModalities: as três ofertas", () => {
|
describe("WorkModalities: as quatro ofertas", () => {
|
||||||
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
|
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order, with the names of serviceOffers", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
@@ -98,7 +111,7 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
expect(articles).toHaveLength(3);
|
expect(articles).toHaveLength(4);
|
||||||
const titles = articles.map(
|
const titles = articles.map(
|
||||||
(article) =>
|
(article) =>
|
||||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||||
@@ -106,10 +119,24 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
expect(titles).toEqual([
|
expect(titles).toEqual([
|
||||||
"Site que traz pedidos pelo WhatsApp",
|
"Site que traz pedidos pelo WhatsApp",
|
||||||
"Sistema para tirar a operação da planilha",
|
"Sistema para tirar a operação da planilha",
|
||||||
|
"Atendimento no WhatsApp com IA sob controle",
|
||||||
"Revisão de sistema que já existe",
|
"Revisão de sistema que já existe",
|
||||||
]);
|
]);
|
||||||
// a fonte é uma só, a mesma de /servicos
|
// a fonte é uma só, a mesma de /servicos; a nota fiscal fica na faixa, sem cartão
|
||||||
expect(titles).toEqual(serviceOffers.map(({ name }) => name));
|
expect(titles).toEqual(HOME_OFFERS.map(({ name }) => name));
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
"Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: two columns from 1024 px up and one below it", () => {
|
||||||
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
|
const grid = container.querySelector("article")?.closest(".grid");
|
||||||
|
expect(grid).toHaveClass("grid-cols-1", "lg:grid-cols-2");
|
||||||
|
expect(grid?.className).not.toMatch(/grid-cols-3/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
|
it("path: the site offer cites the Lavação 047 and its deliverables are the ones of serviceOffers, the same list of /servicos", () => {
|
||||||
@@ -124,10 +151,10 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
).toEqual(serviceOffers[0].deliverables);
|
).toEqual(serviceOffers[0].deliverables);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row); the other two have an illustration of their own", () => {
|
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for half the row); the system, the WhatsApp and the review cards loop a video", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const [site, system, review] = Array.from(
|
const [site, system, whatsapp, review] = Array.from(
|
||||||
container.querySelectorAll("article"),
|
container.querySelectorAll("article"),
|
||||||
);
|
);
|
||||||
const siteImage = within(site).getByRole("img");
|
const siteImage = within(site).getByRole("img");
|
||||||
@@ -137,55 +164,114 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
);
|
);
|
||||||
expect(siteImage).toHaveAttribute(
|
expect(siteImage).toHaveAttribute(
|
||||||
"sizes",
|
"sizes",
|
||||||
"(min-width: 1024px) 33vw, 100vw",
|
"(min-width: 1024px) 50vw, 100vw",
|
||||||
);
|
);
|
||||||
expect(siteImage).toHaveAttribute("loading", "lazy");
|
expect(siteImage).toHaveAttribute("loading", "lazy");
|
||||||
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
|
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
|
||||||
expect(system.querySelector("img")).toBeNull();
|
for (const card of [system, whatsapp, review]) {
|
||||||
expect(review.querySelector("img")).toBeNull();
|
expect(card.querySelector("img")).toBeNull();
|
||||||
|
}
|
||||||
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
|
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
|
||||||
|
expect(site.querySelector("video")).toBeNull();
|
||||||
|
expect(review.querySelector("video")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the system and the review cards have no black box with an icon: each has an illustration in the same box as the site photo", () => {
|
it("path: the system, the WhatsApp and the review videos are decorative 3D pieces: muted loop, no preload, the poster first, webm then mp4", () => {
|
||||||
|
render(<WorkModalities />);
|
||||||
|
|
||||||
|
for (const id of ["sistema", "whatsapp", "revisao"]) {
|
||||||
|
const art = screen.getByTestId(`offer-art-${id}`);
|
||||||
|
const video = art.querySelector("video") as HTMLVideoElement;
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
expect(video).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(video).toHaveAttribute("tabindex", "-1");
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
expect(video.loop).toBe(true);
|
||||||
|
expect(video).toHaveAttribute("playsinline");
|
||||||
|
expect(video).toHaveAttribute("preload", "none");
|
||||||
|
expect(video).toHaveAttribute("poster", `/3d/peca-${id}.webp`);
|
||||||
|
expect(
|
||||||
|
Array.from(video.querySelectorAll("source")).map((source) => [
|
||||||
|
source.getAttribute("src"),
|
||||||
|
source.getAttribute("type"),
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
[`/3d/peca-${id}.webm`, "video/webm"],
|
||||||
|
[`/3d/peca-${id}.mp4`, "video/mp4"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
// a planilha em SVG saiu do cartão do sistema
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("offer-art-sistema").querySelector("svg"),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the WhatsApp video carries a decorative app badge in the top left corner: the WhatsApp mark in its green and the word, light on dark", () => {
|
||||||
|
render(<WorkModalities />);
|
||||||
|
|
||||||
|
const art = screen.getByTestId("offer-art-whatsapp");
|
||||||
|
expect(art.querySelector("video")?.parentElement).toHaveClass("relative");
|
||||||
|
const badge = within(art).getByText("WhatsApp");
|
||||||
|
expect(badge).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(badge).toHaveClass("absolute", "top-2", "left-2", "text-white");
|
||||||
|
expect(badge.className).toMatch(/bg-black\/\d+/);
|
||||||
|
expect(badge.className).not.toMatch(/rounded/);
|
||||||
|
const mark = badge.querySelector("svg");
|
||||||
|
expect(mark).not.toBeNull();
|
||||||
|
expect(mark?.getAttribute("class")).toContain("text-[var(--whatsapp)]");
|
||||||
|
// só o vídeo do atendimento leva o selo
|
||||||
|
for (const id of ["sistema", "revisao"]) {
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId(`offer-art-${id}`)).queryByText("WhatsApp"),
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: the report excerpt lies over the right edge of the review video, vertically centered, in a wide box, and below the frame in a narrow one, 20% larger (18 px)", () => {
|
||||||
|
render(<WorkModalities />);
|
||||||
|
|
||||||
|
const art = screen.getByTestId("offer-art-revisao");
|
||||||
|
expect(art).toHaveClass("@container", "relative");
|
||||||
|
const excerpt = within(art).getByRole("img");
|
||||||
|
// depois do quadro do vídeo, nunca dentro dele: na caixa estreita fica embaixo, sem cobrir a janela
|
||||||
|
expect(
|
||||||
|
excerpt.previousElementSibling?.querySelector("video"),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(excerpt).not.toHaveClass("absolute");
|
||||||
|
expect(excerpt).toHaveClass(
|
||||||
|
"@[29rem]:absolute",
|
||||||
|
"@[29rem]:inset-y-0",
|
||||||
|
"@[29rem]:right-0",
|
||||||
|
"@[29rem]:items-end",
|
||||||
|
"@[29rem]:justify-center",
|
||||||
|
);
|
||||||
|
for (const item of excerpt.querySelectorAll("li")) {
|
||||||
|
expect(item).toHaveClass("text-[18px]");
|
||||||
|
}
|
||||||
|
expect(within(excerpt).getByText("Exemplo ilustrativo")).toHaveClass(
|
||||||
|
"text-[18px]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: no black box with an icon: the photo and the videos sit in the same frame, in the same box", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
expect(screen.queryByTestId("offer-icon")).toBeNull();
|
expect(screen.queryByTestId("offer-icon")).toBeNull();
|
||||||
const boxes = ["site", "sistema", "revisao"].map((id) =>
|
const boxes = HOME_OFFERS.map(({ id }) =>
|
||||||
screen.getByTestId(`offer-art-${id}`),
|
screen.getByTestId(`offer-art-${id}`),
|
||||||
);
|
);
|
||||||
// as três caixas têm as mesmas classes, então o mesmo tamanho
|
// as quatro caixas e os quatro quadros têm as mesmas classes, então o mesmo tamanho
|
||||||
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
|
expect(new Set(boxes.map((box) => box.className)).size).toBe(1);
|
||||||
for (const box of boxes) {
|
const frames = boxes.map((box) => box.firstElementChild as HTMLElement);
|
||||||
expect(box.className).toContain("aspect-[16/10]");
|
expect(new Set(frames.map((frame) => frame.className)).size).toBe(1);
|
||||||
expect(box.className).toContain("md:aspect-[21/9]");
|
for (const frame of frames) {
|
||||||
expect(box.className).toContain("lg:aspect-[16/10]");
|
expect(frame.className).toContain("aspect-[16/10]");
|
||||||
expect(box.firstElementChild).not.toBeNull();
|
expect(frame.className).toContain("md:aspect-[21/9]");
|
||||||
|
expect(frame.className).toContain("lg:aspect-[16/10]");
|
||||||
|
expect(frame.querySelector("img, video")).not.toBeNull();
|
||||||
}
|
}
|
||||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
expect(container.querySelectorAll("article")).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the system illustration is a sheet -> arrow -> dashboard scheme in SVG, with three indicators, no text and no numbers", () => {
|
|
||||||
render(<WorkModalities />);
|
|
||||||
|
|
||||||
const art = screen.getByTestId("offer-art-sistema");
|
|
||||||
const scheme = within(art).getByRole("img");
|
|
||||||
expect(scheme.tagName.toLowerCase()).toBe("svg");
|
|
||||||
expect(scheme).toHaveAccessibleName(
|
|
||||||
"Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras",
|
|
||||||
);
|
|
||||||
expect(scheme.getAttribute("viewBox")).toBe("0 0 240 150");
|
|
||||||
expect(scheme.querySelector('[data-art="planilha"]')).not.toBeNull();
|
|
||||||
expect(scheme.querySelector('[data-art="seta"]')).not.toBeNull();
|
|
||||||
const panel = scheme.querySelector('[data-art="painel"]') as Element;
|
|
||||||
expect(panel).not.toBeNull();
|
|
||||||
// três indicadores genéricos: um quadro com um traço de rótulo e um bloco de valor cada, sem dígito algum
|
|
||||||
expect(panel.querySelectorAll("g").length).toBe(3);
|
|
||||||
expect(scheme.querySelector("text")).toBeNull();
|
|
||||||
expect(art.textContent).toBe("");
|
|
||||||
// nada de cor de destaque: o limão é só do contato
|
|
||||||
expect(art.innerHTML).not.toMatch(/ccff00|var\(--slash\)/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
|
it("path: the review illustration is an excerpt of the report model of /ia: three findings with a severity badge and the caption 'Exemplo ilustrativo'", () => {
|
||||||
render(<WorkModalities />);
|
render(<WorkModalities />);
|
||||||
|
|
||||||
@@ -206,26 +292,21 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
for (const severity of ["Alta", "Média"]) {
|
for (const severity of ["Alta", "Média"]) {
|
||||||
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
|
expect(within(art).getAllByText(severity).length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
// o texto da ilustração também tem 15 px, como o resto da seção
|
// o texto do recorte tem 18 px embaixo do vídeo e 15 px ao lado da janela; nunca abaixo dos 15 px do resto da seção
|
||||||
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
|
expect(art.innerHTML).not.toMatch(/text-\[(?:[0-9]|1[0-4])px\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the deliverables of the system and of the review come from serviceOffers, the same lists of /servicos", () => {
|
it("path: the deliverables of the system, the WhatsApp and the review come from serviceOffers, the same lists of /servicos", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const [, system, review] = Array.from(
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
container.querySelectorAll("article"),
|
HOME_OFFERS.forEach((offer, index) => {
|
||||||
);
|
expect(
|
||||||
expect(
|
within(articles[index])
|
||||||
within(system)
|
.getAllByRole("listitem")
|
||||||
.getAllByRole("listitem")
|
.map((item) => item.textContent),
|
||||||
.map((item) => item.textContent),
|
).toEqual(offer.deliverables);
|
||||||
).toEqual(serviceOffers[1].deliverables);
|
});
|
||||||
expect(
|
|
||||||
within(review)
|
|
||||||
.getAllByRole("listitem")
|
|
||||||
.map((item) => item.textContent),
|
|
||||||
).toEqual(serviceOffers[2].deliverables);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: every card says what you receive, with three or four items, once", () => {
|
it("path: every card says what you receive, with three or four items, once", () => {
|
||||||
@@ -241,7 +322,7 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: the first step shows once, below the three cards: a first conversation and, after it, the scope and the proposal in writing", () => {
|
it("path: the first step shows once, below the cards: a first conversation and, after it, the scope and the proposal in writing", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
for (const card of container.querySelectorAll("article")) {
|
for (const card of container.querySelectorAll("article")) {
|
||||||
@@ -262,13 +343,14 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
|
expect(label.parentElement?.textContent).not.toMatch(/R\$|dias|prazo de/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: each button opens the contact form card with the project type already chosen, all three with the same filled style", () => {
|
it("path: each button opens the contact form card with the project type already chosen, all four with the same filled style", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
const buttons = [
|
const buttons = [
|
||||||
["Começar pelo site", "/?tipo=site#contact-form"],
|
["Começar pelo site", "/?tipo=site#contact-form"],
|
||||||
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
|
["Começar pelo sistema", "/?tipo=sistema#contact-form"],
|
||||||
|
["Começar pelo atendimento", "/?tipo=whatsapp#contact-form"],
|
||||||
["Começar pela revisão", "/?tipo=revisao#contact-form"],
|
["Começar pela revisão", "/?tipo=revisao#contact-form"],
|
||||||
] as const;
|
] as const;
|
||||||
buttons.forEach(([name, href], index) => {
|
buttons.forEach(([name, href], index) => {
|
||||||
@@ -292,9 +374,19 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
expect(
|
expect(
|
||||||
within(articles[2]).getByRole("link", {
|
within(articles[2]).getByRole("link", {
|
||||||
|
name: "Ver como funciona o atendimento",
|
||||||
|
}),
|
||||||
|
).toHaveAttribute("href", "/servicos/atendimento-whatsapp-ia");
|
||||||
|
expect(
|
||||||
|
within(articles[3]).getByRole("link", {
|
||||||
name: "Ver como funciona a revisão",
|
name: "Ver como funciona a revisão",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/ia");
|
).toHaveAttribute("href", "/ia");
|
||||||
|
expect(
|
||||||
|
within(articles[2]).getByText(
|
||||||
|
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
|
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
|
||||||
@@ -306,19 +398,19 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
name: "Ver todos os serviços",
|
name: "Ver todos os serviços",
|
||||||
});
|
});
|
||||||
expect(services).toHaveAttribute("href", "/servicos");
|
expect(services).toHaveAttribute("href", "/servicos");
|
||||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
expect(container.querySelectorAll("article")).toHaveLength(4);
|
||||||
// o link fica fora dos cartões, depois da grade
|
// o link fica fora dos cartões, depois da grade
|
||||||
expect(services.closest("article")).toBeNull();
|
expect(services.closest("article")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen
|
screen
|
||||||
.getAllByRole("link")
|
.getAllByRole("link")
|
||||||
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
|
.filter((link) => link.getAttribute("href") === "/servicos"),
|
||||||
).toEqual([services]);
|
).toEqual([services]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkModalities: um só estilo de cartão", () => {
|
describe("WorkModalities: um só estilo de cartão", () => {
|
||||||
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
|
it("path: the four cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
@@ -393,3 +485,41 @@ describe("WorkModalities: sem prometer o que o dono não disse", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WorkModalities: a faixa da nota fiscal", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: up to 2027-03-31 a quiet line above the cards (no card, no colored box) says the Simples Nacional date and links to the page", () => {
|
||||||
|
jest.useFakeTimers({ now: new Date("2027-03-31T23:59:00-03:00") });
|
||||||
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
|
const text = screen.getByText(
|
||||||
|
/Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027\./,
|
||||||
|
);
|
||||||
|
expect(text.textContent).toBe(
|
||||||
|
"Seu sistema emite nota fiscal? Os optantes do Simples Nacional entram na reforma tributária em 1º de janeiro de 2027.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: "Ver o que muda" }),
|
||||||
|
).toHaveAttribute("href", "/servicos/nota-fiscal-reforma-tributaria");
|
||||||
|
const notice = text.parentElement as HTMLElement;
|
||||||
|
expect(notice.closest("article")).toBeNull();
|
||||||
|
expect(
|
||||||
|
notice.compareDocumentPosition(
|
||||||
|
container.querySelector("article") as Element,
|
||||||
|
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(notice.className).not.toMatch(/bg-|border-l-|ds-card|shadow/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("path: from 2027-04-01 on, no notice, and the four cards stay", () => {
|
||||||
|
jest.useFakeTimers({ now: new Date("2027-04-01T00:00:00-03:00") });
|
||||||
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Seu sistema emite nota fiscal\?/)).toBeNull();
|
||||||
|
expect(screen.queryByRole("link", { name: "Ver o que muda" })).toBeNull();
|
||||||
|
expect(container.querySelectorAll("article")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,28 +7,35 @@ import {
|
|||||||
} from "@/lib/site-content";
|
} from "@/lib/site-content";
|
||||||
import { primaryButton, textLink } from "./button-styles";
|
import { primaryButton, textLink } from "./button-styles";
|
||||||
import ContactOfferLink from "./ContactOfferLink";
|
import ContactOfferLink from "./ContactOfferLink";
|
||||||
|
import { FiscalNotice } from "./FiscalNotice";
|
||||||
|
import { isFiscalNoticeActive } from "./fiscal-notice";
|
||||||
import "./conversion.css";
|
import "./conversion.css";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { OfferVideo } from "./OfferVideo";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
|
import { WhatsAppMark } from "./WhatsAppMark";
|
||||||
|
|
||||||
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
|
/** O que só a home diz de cada oferta; o nome e "O que você recebe" vêm de `serviceOffers`, a fonte única que /servicos também usa. */
|
||||||
interface OfferExtras {
|
interface OfferExtras {
|
||||||
description: string;
|
description: string;
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz uma ilustração própria (ver OfferArt). */
|
/** Case cujo pôster ilustra a oferta; sem ele, o cartão traz um vídeo ou uma ilustração própria (ver OfferArt). */
|
||||||
poster?: { id: string; title: string };
|
poster?: { id: string; title: string };
|
||||||
moreLabel: string;
|
moreLabel: string;
|
||||||
moreHref: string;
|
moreHref: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ofertas com cartão na home; a da nota fiscal entra na faixa datada acima dos cartões (FiscalNotice). */
|
||||||
|
type HomeOfferId = Exclude<ServiceOffer["id"], "fiscal">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Os textos da home para as três ofertas, na ordem de `serviceOffers` (a do H1). Cada texto só diz o que o case ou a
|
* Os textos da home para as quatro ofertas, na ordem de `serviceOffers`. Cada texto só diz o que o case, a página de /ia
|
||||||
* página de /ia citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de catálogo e
|
* ou a página da oferta citados já sustentam; sem prazo nem preço. Só o site tem foto: a Lavação 047 é um site de
|
||||||
* tabela de preços, o que o texto descreve. O sistema e a revisão não têm um case que mostre o que descrevem (a vitrine
|
* catálogo e tabela de preços, o que o texto descreve. O sistema, o atendimento no WhatsApp e a revisão trazem uma peça
|
||||||
* da SEV é uma loja, não um painel), então cada um traz uma ilustração feita em HTML e SVG, do tamanho da foto.
|
* 3D em vídeo (OfferVideo); por cima, o atendimento tem o selo do aplicativo e a revisão, um recorte do relatório.
|
||||||
*/
|
*/
|
||||||
const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
|
const OFFER_EXTRAS: Record<HomeOfferId, OfferExtras> = {
|
||||||
site: {
|
site: {
|
||||||
description:
|
description:
|
||||||
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
|
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
|
||||||
@@ -44,6 +51,13 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
|
|||||||
moreLabel: "Ver case SEV Moda Íntima",
|
moreLabel: "Ver case SEV Moda Íntima",
|
||||||
moreHref: "/cases/sev-modaintima",
|
moreHref: "/cases/sev-modaintima",
|
||||||
},
|
},
|
||||||
|
whatsapp: {
|
||||||
|
description:
|
||||||
|
"Para quem atende e vende pelo WhatsApp e quer responder na hora sem deixar a IA inventar preço ou prazo.",
|
||||||
|
ctaLabel: "Começar pelo atendimento",
|
||||||
|
moreLabel: "Ver como funciona o atendimento",
|
||||||
|
moreHref: "/servicos/atendimento-whatsapp-ia",
|
||||||
|
},
|
||||||
revisao: {
|
revisao: {
|
||||||
description:
|
description:
|
||||||
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
|
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
|
||||||
@@ -53,6 +67,10 @@ const OFFER_EXTRAS: Record<ServiceOffer["id"], OfferExtras> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const homeOffers = serviceOffers.flatMap((offer) =>
|
||||||
|
offer.id === "fiscal" ? [] : [{ offer, extras: OFFER_EXTRAS[offer.id] }],
|
||||||
|
);
|
||||||
|
|
||||||
const PROCESS = [
|
const PROCESS = [
|
||||||
{
|
{
|
||||||
title: "Entender a operação",
|
title: "Entender a operação",
|
||||||
@@ -76,127 +94,6 @@ const PROCESS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Colunas e linhas da planilha do esquema do sistema (a primeira linha é o cabeçalho) e a largura de cada traço de dado. */
|
|
||||||
const SHEET_COLUMNS = [0, 1, 2];
|
|
||||||
const SHEET_DATA_ROWS = [0, 1, 2, 3, 4];
|
|
||||||
const SHEET_DASH_WIDTHS = [18, 12, 15];
|
|
||||||
/** Alturas das cinco barras do painel do esquema. */
|
|
||||||
const PANEL_BARS = [14, 26, 20, 34, 28];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ilustração do sistema: as linhas de uma planilha, uma seta e um painel com três indicadores e um gráfico de barras,
|
|
||||||
* sem números nem nomes de cliente. É um SVG com `viewBox` (cresce com o cartão) do mesmo tamanho da foto do site, só
|
|
||||||
* em traços claros sobre o fundo escuro, e não tem texto: o nome do cartão e a lista dizem o que é.
|
|
||||||
*/
|
|
||||||
function SystemArt() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
role="img"
|
|
||||||
aria-label="Esquema ilustrativo: as linhas de uma planilha viram um painel com três indicadores e um gráfico de barras"
|
|
||||||
viewBox="0 0 240 150"
|
|
||||||
className="block h-full w-full text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<g data-art="planilha" strokeOpacity="0.45" strokeWidth="1.5">
|
|
||||||
<rect x="14" y="30" width="80" height="92" />
|
|
||||||
<path d="M14 46h80M40.7 30v92M67.3 30v92" strokeWidth="1" />
|
|
||||||
</g>
|
|
||||||
<rect
|
|
||||||
x="15"
|
|
||||||
y="31"
|
|
||||||
width="78"
|
|
||||||
height="14"
|
|
||||||
fill="currentColor"
|
|
||||||
fillOpacity="0.16"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<g fill="currentColor" fillOpacity="0.5" stroke="none">
|
|
||||||
{SHEET_DATA_ROWS.flatMap((row) =>
|
|
||||||
SHEET_COLUMNS.map((column) => (
|
|
||||||
<rect
|
|
||||||
key={`${row}-${column}`}
|
|
||||||
x={19 + column * 26.6}
|
|
||||||
y={52 + row * 14.6}
|
|
||||||
width={
|
|
||||||
SHEET_DASH_WIDTHS[(row + column) % SHEET_DASH_WIDTHS.length]
|
|
||||||
}
|
|
||||||
height="4"
|
|
||||||
rx="1"
|
|
||||||
/>
|
|
||||||
)),
|
|
||||||
)}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<path
|
|
||||||
data-art="seta"
|
|
||||||
d="M104 76h24M119 66l11 10-11 10"
|
|
||||||
strokeOpacity="0.85"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<g data-art="painel">
|
|
||||||
<rect
|
|
||||||
x="140"
|
|
||||||
y="22"
|
|
||||||
width="92"
|
|
||||||
height="108"
|
|
||||||
rx="3"
|
|
||||||
fill="currentColor"
|
|
||||||
fillOpacity="0.06"
|
|
||||||
strokeOpacity="0.45"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
{[147, 175, 203].map((x) => (
|
|
||||||
<g key={x}>
|
|
||||||
<rect
|
|
||||||
x={x}
|
|
||||||
y="30"
|
|
||||||
width="25"
|
|
||||||
height="32"
|
|
||||||
strokeOpacity="0.35"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x={x + 4}
|
|
||||||
y="35"
|
|
||||||
width="12"
|
|
||||||
height="3"
|
|
||||||
fill="currentColor"
|
|
||||||
fillOpacity="0.5"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x={x + 4}
|
|
||||||
y="43"
|
|
||||||
width="16"
|
|
||||||
height="9"
|
|
||||||
fill="currentColor"
|
|
||||||
fillOpacity="0.92"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<path d="M147 118h81" strokeOpacity="0.3" strokeWidth="1" />
|
|
||||||
{PANEL_BARS.map((height, index) => (
|
|
||||||
<rect
|
|
||||||
key={height}
|
|
||||||
x={151 + index * 15}
|
|
||||||
y={118 - height}
|
|
||||||
width="10"
|
|
||||||
height={height}
|
|
||||||
fill="currentColor"
|
|
||||||
fillOpacity="0.6"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
|
* Três achados do "Modelo do relatório" de /ia (#ia-modelo), em versão curta para caber no cartão: a falha, em poucas
|
||||||
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
|
* palavras, e a gravidade. Como lá, são exemplos ilustrativos: não são de nenhum cliente nem de um app real.
|
||||||
@@ -209,28 +106,35 @@ const REPORT_EXCERPT = [
|
|||||||
|
|
||||||
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
|
/** O mesmo tamanho nos dois selos: a gravidade fica no texto, não só na cor ou no enchimento. */
|
||||||
const SEVERITY_BADGE =
|
const SEVERITY_BADGE =
|
||||||
"inline-flex min-w-12 shrink-0 items-center justify-center border border-[var(--ink)] px-1.5 font-display text-[15px] leading-5";
|
"inline-flex min-w-14 shrink-0 items-center justify-center border border-[var(--ink)] px-2 font-display text-[18px] @[29rem]:text-[15px] leading-6";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ilustração da revisão: um recorte do "Modelo do relatório" de /ia, com três linhas de achado, cada uma com o selo
|
* Recorte do "Modelo do relatório" de /ia junto do vídeo da revisão: três linhas de achado, cada uma com o selo da
|
||||||
* da gravidade, e a legenda "Exemplo ilustrativo". O texto tem 15 px também aqui; os achados são curtos para caberem
|
* gravidade, e a legenda "Exemplo ilustrativo", em 18 px. A peça 3D (a janela e a lupa) ocupa a metade esquerda do
|
||||||
* numa linha só no cartão mais estreito (três colunas em 1024 px).
|
* quadro, então numa caixa larga (528 px ou mais) o recorte fica por cima da metade direita, centrado na vertical. Numa
|
||||||
|
* caixa mais estreita (celular, e as duas colunas logo acima de 1024 px) ele não cabe ao lado da janela sem cobri-la:
|
||||||
|
* desce para baixo do quadro, no mesmo fundo escuro. A largura vem da caixa (container query), não da tela.
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
* Onde o trecho do relatório fica: ao lado da janela quando o quadro tem 29rem ou mais (a janela do vídeo acaba em 51%
|
||||||
|
* da largura); embaixo do vídeo quando o cartão ocupa a linha inteira e é estreito (celular); fora da tela entre 1024
|
||||||
|
* e 1279 px, em que os cartões já estão lado a lado mas o quadro é estreito demais e empilhar desalinharia os títulos.
|
||||||
*/
|
*/
|
||||||
function ReviewArt() {
|
function ReviewArt() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
|
aria-label="Exemplo ilustrativo de relatório da revisão: três achados, dois de gravidade alta e um de gravidade média"
|
||||||
className="flex h-full flex-col items-center justify-center gap-sp-2 px-sp-3"
|
className="pointer-events-none flex flex-col items-center gap-sp-2 px-sp-3 pt-sp-1 pb-sp-4 lg:max-xl:hidden @[29rem]:absolute @[29rem]:inset-y-0 @[29rem]:right-0 @[29rem]:items-end @[29rem]:justify-center @[29rem]:p-sp-4"
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="w-full max-w-[300px] divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-2 py-0.5 text-[var(--ink)]"
|
className="w-max max-w-full divide-y divide-[var(--border)] bg-[var(--paper)] px-sp-3 py-0.5 text-[var(--ink)]"
|
||||||
>
|
>
|
||||||
{REPORT_EXCERPT.map(({ finding, severity }) => (
|
{REPORT_EXCERPT.map(({ finding, severity }) => (
|
||||||
<li
|
<li
|
||||||
key={finding}
|
key={finding}
|
||||||
className="flex items-center justify-between gap-sp-2 py-0.5 font-display text-[15px] leading-6"
|
className="flex items-center justify-between gap-sp-4 py-1 font-display text-[18px] @[29rem]:text-[15px] leading-7"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">{finding}</span>
|
<span className="min-w-0">{finding}</span>
|
||||||
<span
|
<span
|
||||||
@@ -243,7 +147,7 @@ function ReviewArt() {
|
|||||||
</ul>
|
</ul>
|
||||||
<p
|
<p
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="font-display text-[15px] leading-5 text-white/75"
|
className="font-display text-[18px] @[29rem]:text-[15px] leading-6 text-white/80"
|
||||||
>
|
>
|
||||||
Exemplo ilustrativo
|
Exemplo ilustrativo
|
||||||
</p>
|
</p>
|
||||||
@@ -251,7 +155,11 @@ function ReviewArt() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Foto do case, quando há um; senão a ilustração própria da oferta. Sempre dentro da mesma caixa do mesmo tamanho. */
|
/** Classes do quadro da mídia, iguais nos quatro cartões: a foto ou o vídeo ocupam o quadro inteiro. */
|
||||||
|
const MEDIA_FRAME =
|
||||||
|
"relative aspect-[16/10] md:aspect-[21/9] lg:aspect-[16/10]";
|
||||||
|
|
||||||
|
/** Foto do case, quando há um; senão o vídeo da oferta, com o que vai junto dele. O quadro tem o mesmo tamanho em todos. */
|
||||||
function OfferArt({
|
function OfferArt({
|
||||||
offer,
|
offer,
|
||||||
extras,
|
extras,
|
||||||
@@ -262,21 +170,40 @@ function OfferArt({
|
|||||||
|
|
||||||
if (poster && extras.poster) {
|
if (poster && extras.poster) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<div className={MEDIA_FRAME}>
|
||||||
src={poster.poster}
|
<Image
|
||||||
alt={`Site ${extras.poster.title} aberto no computador`}
|
src={poster.poster}
|
||||||
width={poster.width}
|
alt={`Site ${extras.poster.title} aberto no computador`}
|
||||||
height={poster.height}
|
width={poster.width}
|
||||||
sizes="(min-width: 1024px) 33vw, 100vw"
|
height={poster.height}
|
||||||
loading="lazy"
|
sizes="(min-width: 1024px) 50vw, 100vw"
|
||||||
className="block h-full w-full object-cover object-top"
|
loading="lazy"
|
||||||
/>
|
className="block h-full w-full object-cover object-top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return offer.id === "sistema" ? <SystemArt /> : <ReviewArt />;
|
return (
|
||||||
|
<>
|
||||||
|
<div className={MEDIA_FRAME}>
|
||||||
|
<OfferVideo name={offer.id} />
|
||||||
|
{offer.id === "whatsapp" ? (
|
||||||
|
// de que aplicativo se trata; decorativo como o vídeo, porque o título do cartão já diz WhatsApp
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-sp-2 bg-black/70 px-sp-2 py-1 font-display text-[15px] leading-5 text-white @[33rem]:top-3 @[33rem]:left-3"
|
||||||
|
>
|
||||||
|
<WhatsAppMark className="h-4 w-4 text-[var(--whatsapp)]" />
|
||||||
|
WhatsApp
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{offer.id === "revisao" ? <ReviewArt /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Um único estilo de cartão para as três ofertas: foto ou ilustração, título com altura mínima, texto, o que você recebe, botão e link do case. */
|
/** Um único estilo de cartão para as quatro ofertas: foto ou vídeo, título com altura mínima, texto, o que você recebe, botão e link. */
|
||||||
function OfferCard({
|
function OfferCard({
|
||||||
offer,
|
offer,
|
||||||
extras,
|
extras,
|
||||||
@@ -285,7 +212,7 @@ function OfferCard({
|
|||||||
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
data-testid={`offer-art-${offer.id}`}
|
data-testid={`offer-art-${offer.id}`}
|
||||||
className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]"
|
className="@container relative overflow-hidden border border-[var(--border)] bg-[var(--ink)]"
|
||||||
>
|
>
|
||||||
<OfferArt offer={offer} extras={extras} />
|
<OfferArt offer={offer} extras={extras} />
|
||||||
</div>
|
</div>
|
||||||
@@ -346,16 +273,22 @@ function OfferCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkModalities() {
|
export function WorkModalities() {
|
||||||
|
const fiscal = serviceOffers.find(({ id }) => id === "fiscal");
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="modalities"
|
id="modalities"
|
||||||
eyebrow="Soluções"
|
eyebrow="Soluções"
|
||||||
title="Escolha por onde começar"
|
title="Escolha por onde começar"
|
||||||
description="Três pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
|
description="Quatro pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
|
||||||
disableChildrenAnimation
|
disableChildrenAnimation
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
|
{/* a faixa só existe até 31/03/2027: o servidor a deixa de fora depois disso, e FiscalNotice confere a data no navegador */}
|
||||||
{serviceOffers.map((offer, index) => (
|
{fiscal && isFiscalNoticeActive(new Date()) ? (
|
||||||
|
<FiscalNotice href={fiscal.href} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-2 lg:gap-sp-6">
|
||||||
|
{homeOffers.map(({ offer, extras }, index) => (
|
||||||
<Reveal
|
<Reveal
|
||||||
key={offer.id}
|
key={offer.id}
|
||||||
y={20}
|
y={20}
|
||||||
@@ -363,12 +296,12 @@ export function WorkModalities() {
|
|||||||
delay={0.08 * (index + 1)}
|
delay={0.08 * (index + 1)}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
>
|
>
|
||||||
<OfferCard offer={offer} extras={OFFER_EXTRAS[offer.id]} />
|
<OfferCard offer={offer} extras={extras} />
|
||||||
</Reveal>
|
</Reveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Uma vez só, depois dos três cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
|
{/* Uma vez só, depois dos cartões: o primeiro passo é o mesmo para qualquer oferta (uma conversa e, depois dela, escopo e proposta por escrito, sem prazo nem preço); a página de cada oferta diz o que se entende na conversa. */}
|
||||||
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
|
<div className="mt-sp-6 flex flex-col gap-sp-1 border-t border-[var(--border)] pt-sp-4 md:flex-row md:items-baseline md:gap-sp-4">
|
||||||
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
|
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
|
||||||
Primeiro passo
|
Primeiro passo
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
|
* Botões da home e das páginas de conversão. Verde-limão preenchido só para a ação de contato;
|
||||||
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
|
* as demais ações são contorno ou link de texto. Texto em sans, 15 a 16 px, sem caixa-alta.
|
||||||
|
* A resposta ao ponteiro e ao foco é uma por papel (cv-fill, cv-outline-*, cv-bar, em globals.css).
|
||||||
*/
|
*/
|
||||||
const base =
|
const base =
|
||||||
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline transition-colors motion-reduce:transition-none sm:text-[16px]";
|
"inline-flex min-h-12 items-center justify-center gap-sp-3 px-sp-5 py-sp-3 text-center font-display text-[15px] normal-case leading-6 tracking-normal no-underline sm:text-[16px]";
|
||||||
|
|
||||||
export const primaryButton = `${base} bg-[var(--slash)] font-semibold text-[var(--ink)] hover:bg-[#d9ff40]`;
|
export const primaryButton = `${base} cv-fill bg-[var(--slash)] font-semibold text-[var(--ink)]`;
|
||||||
|
|
||||||
/** Contorno sobre fundo escuro. */
|
/** Contorno sobre fundo escuro. */
|
||||||
export const secondaryButtonDark = `${base} border border-white/25 bg-transparent font-medium text-[#f4f2ed] hover:border-white/55`;
|
export const secondaryButtonDark = `${base} cv-outline-dark border border-white/25 bg-transparent font-medium text-[#f4f2ed]`;
|
||||||
|
|
||||||
/** Contorno sobre fundo claro. */
|
/** Contorno sobre fundo claro. */
|
||||||
export const secondaryButtonLight = `${base} border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)] hover:border-[var(--ink)]`;
|
export const secondaryButtonLight = `${base} cv-outline-light border border-[var(--ink-40)] bg-transparent font-medium text-[var(--ink)]`;
|
||||||
|
|
||||||
const textLinkBase =
|
const textLinkBase =
|
||||||
"inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 transition-colors motion-reduce:transition-none sm:text-[16px]";
|
"cv-bar inline-flex min-h-11 items-center font-display text-[15px] font-medium normal-case leading-6 tracking-normal underline underline-offset-4 sm:text-[16px]";
|
||||||
|
|
||||||
/** Link de texto sobre fundo claro. */
|
/** Link de texto sobre fundo claro. */
|
||||||
export const textLink = `${textLinkBase} hover:text-[var(--ink)]`;
|
export const textLink = textLinkBase;
|
||||||
|
|
||||||
/** Link de texto sobre fundo escuro. */
|
/** Link de texto sobre fundo escuro. */
|
||||||
export const textLinkDark = `${textLinkBase} text-white/80 hover:text-white`;
|
export const textLinkDark = `${textLinkBase} text-white/80`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Primeira seção de uma página interna, no celular: o padding de .ds-section (52 px) é menor que o cabeçalho fixo (60 px),
|
* Primeira seção de uma página interna, no celular: o padding de .ds-section (52 px) é menor que o cabeçalho fixo (60 px),
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import type {
|
||||||
|
InstancedBufferGeometry,
|
||||||
|
Mesh,
|
||||||
|
ShaderMaterial,
|
||||||
|
Texture,
|
||||||
|
} from "three";
|
||||||
|
import { stateAt } from "@/lib/blocos";
|
||||||
|
import { isKnownSlowScene, isKnownSoftwareWebGL } from "@/lib/device";
|
||||||
|
import BlocosScene from "./BlocosScene";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A cena de blocos: baixa os ativos, cria o renderizador do Three.js, compila o shader em tarefas separadas e desenha
|
||||||
|
* sob demanda. O WebGL do jsdom não existe: o renderizador é um dublê, e os ativos (o bloco do glTF, os matcaps e o
|
||||||
|
* JSON dos estados) vêm de dublês também; o JSON é o arquivo real.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type RendererOptions = {
|
||||||
|
alpha?: boolean;
|
||||||
|
antialias?: boolean;
|
||||||
|
powerPreference?: string;
|
||||||
|
};
|
||||||
|
const mockRenderers: MockRenderer[] = [];
|
||||||
|
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
||||||
|
let mockRendererName: string | null = null;
|
||||||
|
|
||||||
|
class MockRenderer {
|
||||||
|
domElement = document.createElement("canvas");
|
||||||
|
pixelRatio = 1;
|
||||||
|
options: RendererOptions;
|
||||||
|
render = jest.fn();
|
||||||
|
compileAsync = jest.fn(() => Promise.resolve());
|
||||||
|
dispose = jest.fn();
|
||||||
|
forceContextLoss = jest.fn();
|
||||||
|
setSize = jest.fn();
|
||||||
|
setPixelRatio = (value: number) => {
|
||||||
|
this.pixelRatio = value;
|
||||||
|
};
|
||||||
|
getPixelRatio = () => this.pixelRatio;
|
||||||
|
getContext = () => ({
|
||||||
|
getExtension: (name: string) =>
|
||||||
|
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
||||||
|
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
||||||
|
: null,
|
||||||
|
getParameter: (parameter: number) =>
|
||||||
|
parameter === 37446 ? mockRendererName : null,
|
||||||
|
});
|
||||||
|
constructor(options: RendererOptions) {
|
||||||
|
this.options = options;
|
||||||
|
mockRenderers.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock("three", () => {
|
||||||
|
const three = jest.requireActual("three");
|
||||||
|
return {
|
||||||
|
...three,
|
||||||
|
WebGLRenderer: jest.fn(
|
||||||
|
(options: RendererOptions) => new MockRenderer(options),
|
||||||
|
),
|
||||||
|
// o jsdom não carrega imagens: o matcap é uma textura vazia
|
||||||
|
TextureLoader: jest.fn(() => ({
|
||||||
|
loadAsync: () => Promise.resolve(new three.Texture()),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("three/examples/jsm/loaders/GLTFLoader.js", () => {
|
||||||
|
const { BoxGeometry, Group, Mesh } = jest.requireActual("three");
|
||||||
|
return {
|
||||||
|
GLTFLoader: jest.fn(() => ({
|
||||||
|
loadAsync: () => {
|
||||||
|
const scene = new Group();
|
||||||
|
scene.add(new Mesh(new BoxGeometry(1, 1, 1)));
|
||||||
|
return Promise.resolve({ scene });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocosJson = JSON.parse(
|
||||||
|
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
|
||||||
|
);
|
||||||
|
let mockFetchOk = true;
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
const props = () => ({
|
||||||
|
progressRef: { current: 0 },
|
||||||
|
visible: true,
|
||||||
|
onReady: jest.fn(),
|
||||||
|
onError: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function setDevicePixelRatio(value: number) {
|
||||||
|
Object.defineProperty(window, "devicePixelRatio", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockRenderers.length = 0;
|
||||||
|
mockRendererName = null;
|
||||||
|
mockFetchOk = true;
|
||||||
|
global.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: mockFetchOk,
|
||||||
|
status: mockFetchOk ? 200 : 404,
|
||||||
|
json: () => Promise.resolve(blocosJson),
|
||||||
|
}),
|
||||||
|
) as unknown as typeof fetch;
|
||||||
|
setDevicePixelRatio(1);
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Deixa as tarefas da montagem correrem (ativos, renderizador, compilação do shader, primeiro quadro pedido). */
|
||||||
|
const buildScene = (ms = 10) =>
|
||||||
|
act(async () => {
|
||||||
|
await jest.advanceTimersByTimeAsync(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mount(ui: React.ReactElement) {
|
||||||
|
const result = render(ui);
|
||||||
|
await buildScene();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const advance = (ms: number) =>
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A malha que o último quadro desenhou (a cena tem uma só). */
|
||||||
|
function drawnMesh() {
|
||||||
|
const calls = mockRenderers[0].render.mock.calls as unknown as [
|
||||||
|
{ children: Mesh[] },
|
||||||
|
][];
|
||||||
|
return calls.at(-1)?.[0].children[0] as Mesh<
|
||||||
|
InstancedBufferGeometry,
|
||||||
|
ShaderMaterial
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
const uniform = (name: string) =>
|
||||||
|
drawnMesh().material.uniforms[name].value as number;
|
||||||
|
|
||||||
|
// precisa ser o primeiro: os ativos ficam em cache depois do primeiro download que dá certo
|
||||||
|
describe("BlocosScene when the assets do not download", () => {
|
||||||
|
it("tells the page once, creates no context, and lets the next mount try again", async () => {
|
||||||
|
mockFetchOk = false;
|
||||||
|
const scene = props();
|
||||||
|
|
||||||
|
const { container, unmount } = await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers).toHaveLength(0);
|
||||||
|
expect(container.querySelector("canvas")).toBeNull();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
mockFetchOk = true;
|
||||||
|
const again = props();
|
||||||
|
await mount(<BlocosScene {...again} />);
|
||||||
|
expect(again.onError).not.toHaveBeenCalled();
|
||||||
|
expect(mockRenderers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BlocosScene", () => {
|
||||||
|
it("creates a transparent, antialiased renderer on the high-performance GPU, and puts its canvas in the page", async () => {
|
||||||
|
const { container } = await mount(<BlocosScene {...props()} />);
|
||||||
|
|
||||||
|
expect(mockRenderers).toHaveLength(1);
|
||||||
|
expect(mockRenderers[0].options).toEqual({
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
const canvas = container.querySelector("canvas");
|
||||||
|
expect(canvas).toBe(mockRenderers[0].domElement);
|
||||||
|
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["full", 3, 1.75],
|
||||||
|
["full", 1.5, 1.5],
|
||||||
|
["lite", 3, 1.5],
|
||||||
|
["lite", 0.5, 1],
|
||||||
|
] as const)(
|
||||||
|
"caps the pixel ratio on %s (device %p -> %p)",
|
||||||
|
async (tier, device, expected) => {
|
||||||
|
setDevicePixelRatio(device);
|
||||||
|
await mount(<BlocosScene {...props()} tier={tier} />);
|
||||||
|
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("draws every block in one instanced call that starts on marca", async () => {
|
||||||
|
await mount(<BlocosScene {...props()} />);
|
||||||
|
advance(20);
|
||||||
|
|
||||||
|
const mesh = drawnMesh();
|
||||||
|
expect(mesh.geometry.type).toBe("InstancedBufferGeometry");
|
||||||
|
expect(mesh.geometry.instanceCount).toBe(238);
|
||||||
|
expect(uniform("uFrom")).toBe(0);
|
||||||
|
expect(uniform("uTo")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the page it is ready on the second frame drawn", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
advance(17);
|
||||||
|
expect(mockRenderers[0].render).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
advance(17);
|
||||||
|
expect(mockRenderers[0].render).toHaveBeenCalledTimes(2);
|
||||||
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
advance(3000);
|
||||||
|
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws nothing while not visible", async () => {
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
const scene = props();
|
||||||
|
|
||||||
|
const { rerender } = await mount(
|
||||||
|
<BlocosScene {...scene} visible={false} />,
|
||||||
|
);
|
||||||
|
advance(3000);
|
||||||
|
expect(frames).not.toHaveBeenCalled();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
|
||||||
|
rerender(<BlocosScene {...scene} visible />);
|
||||||
|
advance(100);
|
||||||
|
expect(mockRenderers[0].render).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws at screen rate through the entry and the first 60 frames, then only on the 30 fps pulse", async () => {
|
||||||
|
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||||
|
await mount(<BlocosScene {...props()} />);
|
||||||
|
|
||||||
|
advance(1500); // entrada de 1,4 s, no ritmo da tela
|
||||||
|
const entry = frames.mock.calls.length;
|
||||||
|
expect(entry).toBeGreaterThan(80);
|
||||||
|
expect(uniform("uIntro")).toBe(1);
|
||||||
|
|
||||||
|
advance(1000);
|
||||||
|
const pulse = frames.mock.calls.length - entry;
|
||||||
|
expect(pulse).toBeGreaterThan(20);
|
||||||
|
expect(pulse).toBeLessThanOrEqual(34);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the dive progress on full", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(1500);
|
||||||
|
|
||||||
|
scene.progressRef.current = 0.63;
|
||||||
|
advance(4000);
|
||||||
|
|
||||||
|
expect(uniform("uFrom")).toBe(2);
|
||||||
|
expect(uniform("uTo")).toBe(2);
|
||||||
|
expect(uniform("uT")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes state by itself on lite, 4 s on each, even with nobody touching the page", async () => {
|
||||||
|
const scene = props();
|
||||||
|
scene.progressRef.current = 0.93; // ignorado no lite
|
||||||
|
await mount(<BlocosScene {...scene} tier="lite" />);
|
||||||
|
|
||||||
|
advance(1400 + 3500); // entrada e 3,5 s parado na marca
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 0]);
|
||||||
|
|
||||||
|
advance(1100); // no meio da troca de 1,2 s
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||||
|
expect(uniform("uT")).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
advance(1000);
|
||||||
|
expect([uniform("uFrom"), uniform("uTo"), uniform("uT")]).toEqual([
|
||||||
|
1, 1, 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** O hospedeiro com 800 x 600 px no canto da janela (o jsdom não faz layout). */
|
||||||
|
const hostOf800x600 = () => {
|
||||||
|
jest.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(800);
|
||||||
|
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(600);
|
||||||
|
jest
|
||||||
|
.spyOn(Element.prototype, "getBoundingClientRect")
|
||||||
|
.mockReturnValue(new DOMRect(0, 0, 800, 600));
|
||||||
|
};
|
||||||
|
const fire = (event: Event) =>
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings the resting state to life after the entry and fades it out while the piece changes state", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(1000);
|
||||||
|
expect(uniform("uVida")).toBe(0); // a entrada ainda assenta
|
||||||
|
|
||||||
|
advance(1500);
|
||||||
|
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||||
|
expect(uniform("uEstado")).toBe(0);
|
||||||
|
const tempo = uniform("uTempo");
|
||||||
|
advance(500);
|
||||||
|
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
|
||||||
|
|
||||||
|
// no meio da troca da marca para a tela
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0.23;
|
||||||
|
advance(1000);
|
||||||
|
expect(uniform("uT")).toBeGreaterThan(0);
|
||||||
|
expect(uniform("uVida")).toBeLessThan(0.05);
|
||||||
|
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0.35;
|
||||||
|
advance(3000);
|
||||||
|
expect(uniform("uEstado")).toBe(1);
|
||||||
|
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("freezes the resting life with the pulse once nobody moves", async () => {
|
||||||
|
const scene = props();
|
||||||
|
scene.progressRef.current = 0.35; // fora do topo: sem o ciclo em repouso
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(6000); // a batida para 5 s depois do último gesto
|
||||||
|
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||||
|
const tempo = uniform("uTempo");
|
||||||
|
|
||||||
|
advance(3000);
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||||
|
expect(uniform("uTempo")).toBe(tempo);
|
||||||
|
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
advance(500);
|
||||||
|
expect(uniform("uTempo")).toBeGreaterThan(tempo);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** O estado que o último quadro desenhou: de, para e t. */
|
||||||
|
const shown = () => [uniform("uFrom"), uniform("uTo"), uniform("uT")];
|
||||||
|
|
||||||
|
it("parked at the top on full, cycles by itself through the four states, about 3 s on each, drawing only the changes", async () => {
|
||||||
|
const scene = { ...props(), onEstado: jest.fn() };
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
advance(1400 + 2500); // entrada e 2,5 s parado na marca
|
||||||
|
expect(shown()).toEqual([0, 0, 0]);
|
||||||
|
advance(1100); // na troca de 1,2 s para a tela
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||||
|
expect(uniform("uT")).toBeGreaterThan(0);
|
||||||
|
// durante a troca, nenhuma legenda: os blocos da marca ainda ocupam o lugar dela
|
||||||
|
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
|
||||||
|
advance(1500);
|
||||||
|
expect(shown()).toEqual([1, 1, 0]);
|
||||||
|
expect(scene.onEstado).toHaveBeenLastCalledWith(1); // assentada na tela
|
||||||
|
|
||||||
|
// a batida parou (5 s sem gesto): parada na tela, nenhum quadro até a próxima troca
|
||||||
|
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||||
|
advance(1000);
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||||
|
|
||||||
|
advance(4200 * 2 + 1000); // integrações, dados
|
||||||
|
expect(shown()).toEqual([3, 3, 0]);
|
||||||
|
advance(4200);
|
||||||
|
expect(shown()).toEqual([0, 0, 0]);
|
||||||
|
// cada legenda entra com a peça assentada e sai quando a troca seguinte começa
|
||||||
|
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
3,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the piece back to the scroll at once and without a jump, and starts the cycle again only after 3 s at the top", async () => {
|
||||||
|
const scene = { ...props(), onEstado: jest.fn() };
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(1400 + 3000 + 1200 + 1000);
|
||||||
|
expect(shown()).toEqual([1, 1, 0]); // parada na tela, pelo ciclo
|
||||||
|
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0.02;
|
||||||
|
advance(50); // a próxima batida
|
||||||
|
// a rolagem pede a marca: a peça sai da tela para ela, do ponto em que estava
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([1, 0]);
|
||||||
|
expect(uniform("uT")).toBeGreaterThan(0);
|
||||||
|
expect(uniform("uT")).toBeLessThan(0.1);
|
||||||
|
expect(scene.onEstado).toHaveBeenLastCalledWith(null); // em troca: sem legenda
|
||||||
|
advance(1000);
|
||||||
|
expect(shown()).toEqual([0, 0, 0]);
|
||||||
|
|
||||||
|
// de volta ao topo: 3 s parada na marca antes da troca seguinte
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0;
|
||||||
|
advance(3000);
|
||||||
|
expect(shown()).toEqual([0, 0, 0]);
|
||||||
|
advance(1500);
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the dive once the scroll leaves the top, and tells the legend the piece has left its box", async () => {
|
||||||
|
const scene = { ...props(), onEstado: jest.fn() };
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(1400 + 3000 + 1200 + 1000);
|
||||||
|
expect(scene.onEstado).toHaveBeenLastCalledWith(1);
|
||||||
|
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0.63;
|
||||||
|
advance(4000);
|
||||||
|
expect(shown()).toEqual([2, 2, 0]);
|
||||||
|
expect(scene.onEstado).toHaveBeenLastCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the piece from the cycle into the middle of a dive transition and keeps it there", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(1400 + 4200 + 4200 + 1000);
|
||||||
|
expect(shown()).toEqual([2, 2, 0]); // parada nas integrações, pelo ciclo
|
||||||
|
|
||||||
|
// a rolagem para no meio da troca da marca para a tela
|
||||||
|
fire(new Event("scroll"));
|
||||||
|
scene.progressRef.current = 0.26;
|
||||||
|
advance(3000);
|
||||||
|
expect([uniform("uFrom"), uniform("uTo")]).toEqual([0, 1]);
|
||||||
|
expect(uniform("uT")).toBeCloseTo(stateAt(0.26).t, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lite: tells the legend every state it builds", async () => {
|
||||||
|
const scene = { ...props(), onEstado: jest.fn() };
|
||||||
|
await mount(<BlocosScene {...scene} tier="lite" />);
|
||||||
|
advance(1400 + 4000 + 1200 + 500);
|
||||||
|
expect(scene.onEstado.mock.calls.map(([estado]) => estado)).toEqual([
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pushes the blocks under the mouse forward on full and lets them go when the mouse leaves", async () => {
|
||||||
|
hostOf800x600();
|
||||||
|
await mount(<BlocosScene {...props()} />);
|
||||||
|
advance(2500);
|
||||||
|
expect(uniform("uCursorPeso")).toBe(0);
|
||||||
|
|
||||||
|
// o centro do hospedeiro cai no centro da peça
|
||||||
|
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
|
||||||
|
advance(1000);
|
||||||
|
expect(uniform("uCursorPeso")).toBeGreaterThan(0.9);
|
||||||
|
const cursor = drawnMesh().material.uniforms.uCursor.value as {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
expect(Math.hypot(cursor.x, cursor.y)).toBeLessThan(0.5);
|
||||||
|
|
||||||
|
// perto do canto de cima à esquerda, longe da peça, o ponto vai junto
|
||||||
|
fire(new PointerEvent("pointermove", { clientX: 100, clientY: 100 }));
|
||||||
|
advance(1000);
|
||||||
|
expect(cursor.x).toBeLessThan(-3);
|
||||||
|
expect(cursor.y).toBeGreaterThan(2);
|
||||||
|
|
||||||
|
fire(new PointerEvent("pointerout", { relatedTarget: null }));
|
||||||
|
advance(1500);
|
||||||
|
expect(uniform("uCursorPeso")).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives lite the resting life on the pulse, without the cursor", async () => {
|
||||||
|
hostOf800x600();
|
||||||
|
await mount(<BlocosScene {...props()} tier="lite" />);
|
||||||
|
advance(2500);
|
||||||
|
expect(uniform("uVida")).toBeGreaterThan(0.9);
|
||||||
|
const tempo = uniform("uTempo");
|
||||||
|
|
||||||
|
fire(new PointerEvent("pointermove", { clientX: 400, clientY: 300 }));
|
||||||
|
advance(500);
|
||||||
|
expect(uniform("uTempo")).toBeCloseTo(tempo + 0.5, 1);
|
||||||
|
expect(uniform("uCursorPeso")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases the geometry, the material, the textures, the context, the canvas, the timers and the listeners on unmount", async () => {
|
||||||
|
const removed = jest.spyOn(window, "removeEventListener");
|
||||||
|
const scene = props();
|
||||||
|
const { container, unmount } = await mount(<BlocosScene {...scene} />);
|
||||||
|
advance(20);
|
||||||
|
const mesh = drawnMesh();
|
||||||
|
const disposed = [
|
||||||
|
jest.spyOn(mesh.geometry, "dispose"),
|
||||||
|
jest.spyOn(mesh.material, "dispose"),
|
||||||
|
jest.spyOn(mesh.material.uniforms.uPorcelana.value as Texture, "dispose"),
|
||||||
|
jest.spyOn(mesh.material.uniforms.uLima.value as Texture, "dispose"),
|
||||||
|
];
|
||||||
|
const canvasRemoved = jest.spyOn(
|
||||||
|
mockRenderers[0].domElement,
|
||||||
|
"removeEventListener",
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
for (const dispose of disposed) expect(dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(container.querySelector("canvas")).toBeNull();
|
||||||
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
|
expect(removed.mock.calls.map(([type]) => type)).toEqual(
|
||||||
|
expect.arrayContaining(["resize", "pointermove", "pointerout"]),
|
||||||
|
);
|
||||||
|
expect(canvasRemoved).toHaveBeenCalledWith(
|
||||||
|
"webglcontextlost",
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||||
|
advance(1000);
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the page once when the WebGL context is lost", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
mockRenderers[0].domElement.dispatchEvent(
|
||||||
|
new Event("webglcontextlost", { cancelable: true }),
|
||||||
|
);
|
||||||
|
mockRenderers[0].domElement.dispatchEvent(
|
||||||
|
new Event("webglcontextlost", { cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up once and remembers it when the entry frames average more than 28 ms", async () => {
|
||||||
|
// quadros de 40 ms: um aparelho que não segura a cena
|
||||||
|
jest
|
||||||
|
.spyOn(window, "requestAnimationFrame")
|
||||||
|
.mockImplementation(
|
||||||
|
(callback) =>
|
||||||
|
setTimeout(
|
||||||
|
() => callback(performance.now()),
|
||||||
|
40,
|
||||||
|
) as unknown as number,
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(window, "cancelAnimationFrame")
|
||||||
|
.mockImplementation((id) => clearTimeout(id));
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
advance(3000);
|
||||||
|
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(isKnownSlowScene()).toBe(true);
|
||||||
|
const drawn = mockRenderers[0].render.mock.calls.length;
|
||||||
|
expect(drawn).toBeLessThanOrEqual(62);
|
||||||
|
advance(2000);
|
||||||
|
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a device that holds 60 fps, without remembering anything", async () => {
|
||||||
|
const scene = props();
|
||||||
|
await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
advance(3000);
|
||||||
|
|
||||||
|
expect(scene.onError).not.toHaveBeenCalled();
|
||||||
|
expect(isKnownSlowScene()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BlocosScene: software WebGL", () => {
|
||||||
|
it("draws on the CPU: no canvas, the page is told once, the context is freed and the session remembers it", async () => {
|
||||||
|
mockRendererName = "llvmpipe (LLVM 15.0.7, 256 bits)";
|
||||||
|
const scene = props();
|
||||||
|
|
||||||
|
const { container } = await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
expect(mockRenderers).toHaveLength(1);
|
||||||
|
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
||||||
|
expect(container.querySelector("canvas")).toBeNull();
|
||||||
|
expect(scene.onError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scene.onReady).not.toHaveBeenCalled();
|
||||||
|
expect(isKnownSoftwareWebGL()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
||||||
|
mockRendererName = "ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11)";
|
||||||
|
const scene = props();
|
||||||
|
|
||||||
|
const { container } = await mount(<BlocosScene {...scene} />);
|
||||||
|
|
||||||
|
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
||||||
|
expect(scene.onError).not.toHaveBeenCalled();
|
||||||
|
expect(isKnownSoftwareWebGL()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,919 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type RefObject, useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
type BufferGeometry,
|
||||||
|
Euler,
|
||||||
|
InstancedBufferAttribute,
|
||||||
|
InstancedBufferGeometry,
|
||||||
|
MathUtils,
|
||||||
|
Matrix3,
|
||||||
|
Matrix4,
|
||||||
|
Mesh,
|
||||||
|
PerspectiveCamera,
|
||||||
|
Plane,
|
||||||
|
Ray,
|
||||||
|
Scene,
|
||||||
|
ShaderMaterial,
|
||||||
|
SRGBColorSpace,
|
||||||
|
type Texture,
|
||||||
|
TextureLoader,
|
||||||
|
Vector2,
|
||||||
|
Vector3,
|
||||||
|
WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import {
|
||||||
|
type Blocos,
|
||||||
|
caixaDosEstados,
|
||||||
|
cicloAt,
|
||||||
|
createFrameBudget,
|
||||||
|
type Estado,
|
||||||
|
type FrameVerdict,
|
||||||
|
framePiece,
|
||||||
|
papeis,
|
||||||
|
parseBlocos,
|
||||||
|
type Rect,
|
||||||
|
seguir,
|
||||||
|
stateAt,
|
||||||
|
} from "@/lib/blocos";
|
||||||
|
import {
|
||||||
|
isSoftwareWebGL,
|
||||||
|
rememberSlowScene,
|
||||||
|
rememberSoftwareWebGL,
|
||||||
|
} from "@/lib/device";
|
||||||
|
import { startFramePulse } from "./FramePulse";
|
||||||
|
|
||||||
|
export type BlocosSceneProps = {
|
||||||
|
/** Progresso do mergulho (0 a 1), escrito pela rolagem. */
|
||||||
|
progressRef: RefObject<number>;
|
||||||
|
visible: boolean;
|
||||||
|
onReady: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
/** `full` segue a rolagem e o mouse; `lite` (celular) troca de estado sozinho. Padrão `full`. */
|
||||||
|
tier?: "full" | "lite";
|
||||||
|
/** Onde a peça descansa no estado `marca` (px CSS relativos ao hospedeiro); sem ele, no hospedeiro inteiro. */
|
||||||
|
placementRef?: RefObject<Rect | null>;
|
||||||
|
/** Área útil dos outros estados (px CSS relativos ao hospedeiro); sem ela, o hospedeiro inteiro. */
|
||||||
|
stageRef?: RefObject<Rect | null>;
|
||||||
|
/**
|
||||||
|
* O estado em que a peça está assentada na caixa dela (`placementRef` no `full`, a caixa toda no `lite`), para a
|
||||||
|
* legenda; `null` durante uma troca e fora da caixa (no mergulho). Avisa só quando muda.
|
||||||
|
*/
|
||||||
|
onEstado?: (estado: number | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Os blocos da marca, em tempo real: uma malha instanciada (um bloco chanfrado do Blender, repetido) com os quatro
|
||||||
|
* estados de `public/3d/blocos.json` em atributos por instância. A troca de estado ("encaixar": cada bloco sai com um
|
||||||
|
* atraso próprio, faz um arco curto para a frente e assenta sem quicar) e a entrada correm no vertex shader; o
|
||||||
|
* JavaScript só atualiza uniforms, a pose e o enquadramento. A luz vem de dois matcaps renderizados no Blender com a
|
||||||
|
* mesma luz do pôster, então o primeiro quadro assume por cima do pôster sem salto.
|
||||||
|
* Parada num estado, a peça tem vida própria (`vida` no vertex shader), calculada pelo tempo; no `full`, os blocos
|
||||||
|
* perto do ponteiro avançam para a frente. O desenho é sob demanda: no ritmo da tela só enquanto a entrada, uma troca
|
||||||
|
* ou a rolagem assenta; fora disso a batida de 30 quadros por segundo enquanto há alguém mexendo na página (é ela que
|
||||||
|
* faz a vida correr e parar) e, no `lite`, um quadro na próxima troca.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Assets = {
|
||||||
|
bloco: BufferGeometry;
|
||||||
|
blocos: Blocos;
|
||||||
|
porcelana: Texture;
|
||||||
|
lima: Texture;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Duração da entrada (s): os blocos saem de perto do lugar e assentam na marca, por cima do pôster. */
|
||||||
|
const ENTRADA = 1.4;
|
||||||
|
/** `lite`: tempo parado em cada estado e tempo de cada troca (s). */
|
||||||
|
const FICA = 4;
|
||||||
|
const TROCA = 1.2;
|
||||||
|
/** `full` parado no topo: tempo em cada estado do ciclo em repouso (s); a troca é a mesma do `lite`. */
|
||||||
|
const FICA_TOPO = 3;
|
||||||
|
/** Avanço do mergulho abaixo do qual a página conta como parada no topo. */
|
||||||
|
const TOPO = 0.01;
|
||||||
|
/** Quantas trocas inteiras por `TROCA` a peça anda quando segue um pedido novo (a rolagem que assume o ciclo). */
|
||||||
|
const SEGUE = 2;
|
||||||
|
/** Pose da marca em repouso: a da câmera do pôster em `3d/blocos.py` (pela esquerda, 26°, e de cima, 12°). */
|
||||||
|
const REPOUSO = { yaw: 0.45, pitch: 0.21 };
|
||||||
|
/** Pose dos outros estados, quase de frente: a da câmera das prévias em `3d/blocos.py`. */
|
||||||
|
const FRENTE = { yaw: 0.24, pitch: 0.12 };
|
||||||
|
/** A câmera fica parada; a peça é que se move e muda de tamanho para caber onde `framePiece` manda. */
|
||||||
|
const DISTANCIA = 40;
|
||||||
|
/** O plano da peça (z = 0, no espaço dela), onde o ponteiro é medido. */
|
||||||
|
const PLANO = new Plane(new Vector3(0, 0, 1), 0);
|
||||||
|
|
||||||
|
const VERTEX = /* glsl */ `
|
||||||
|
#define ATRASO 0.35
|
||||||
|
attribute vec4 aPos0;
|
||||||
|
attribute vec4 aPos1;
|
||||||
|
attribute vec4 aPos2;
|
||||||
|
attribute vec4 aPos3;
|
||||||
|
attribute vec3 aEsc0;
|
||||||
|
attribute vec3 aEsc1;
|
||||||
|
attribute vec3 aEsc2;
|
||||||
|
attribute vec3 aEsc3;
|
||||||
|
attribute vec3 aEntrada;
|
||||||
|
attribute float aSemente;
|
||||||
|
// papel na vida em repouso (papeis, em lib/blocos.ts): quadrado da marca, botão da tela, linha, barra
|
||||||
|
attribute vec4 aPapel;
|
||||||
|
uniform float uFrom;
|
||||||
|
uniform float uTo;
|
||||||
|
uniform float uT;
|
||||||
|
uniform float uIntro;
|
||||||
|
// peso da vida (0 na troca e na entrada), estado parado em que ela corre e o relógio dela (s)
|
||||||
|
uniform float uVida;
|
||||||
|
uniform float uEstado;
|
||||||
|
uniform float uTempo;
|
||||||
|
uniform vec3 uBotao;
|
||||||
|
uniform float uChao;
|
||||||
|
// ponteiro no plano z = 0 da peça e o peso dele (0 sem ponteiro, no lite e fora do repouso)
|
||||||
|
uniform vec2 uCursor;
|
||||||
|
uniform float uCursorPeso;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying float vTom;
|
||||||
|
|
||||||
|
// posição (xyz) e tom (w) do bloco no estado i
|
||||||
|
vec4 lugar(float i) { return i < 0.5 ? aPos0 : i < 1.5 ? aPos1 : i < 2.5 ? aPos2 : aPos3; }
|
||||||
|
vec3 escala(float i) { return i < 0.5 ? aEsc0 : i < 1.5 ? aEsc1 : i < 2.5 ? aEsc2 : aEsc3; }
|
||||||
|
|
||||||
|
// encaixar: cada bloco sai com um atraso próprio e assenta com ease-out, sem passar do ponto
|
||||||
|
float encaixe(float t) {
|
||||||
|
float k = clamp((t - aSemente * ATRASO) / (1.0 - ATRASO), 0.0, 1.0);
|
||||||
|
return 1.0 - pow(1.0 - k, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// pulo de um quadrado, u de 0 a 1: sobe rápido e assenta devagar, sem passar do lugar; 0 fora disso
|
||||||
|
float pulo(float u) {
|
||||||
|
if (u <= 0.0 || u >= 1.0) return 0.0;
|
||||||
|
if (u < 0.35) return 1.0 - pow(1.0 - u / 0.35, 2.0);
|
||||||
|
return 1.0 - smoothstep(0.0, 1.0, (u - 0.35) / 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a vida em repouso do estado: muda posição, escala e tom do bloco
|
||||||
|
void vida(inout vec3 p, inout vec3 s, inout float tom) {
|
||||||
|
if (uEstado < 0.5) {
|
||||||
|
// marca: os quatro quadrados lima pulam 0,25 em sequência, 0,4 s cada, um ciclo a cada 6 s; o primeiro vem 2,5 s
|
||||||
|
// depois de a cena nascer, logo após a entrada (e dentro dos 4 s do lite na marca)
|
||||||
|
if (aPapel.x >= 0.0) p.y += uVida * 0.25 * pulo((mod(uTempo - 2.5, 6.0) - aPapel.x * 0.3) / 0.4);
|
||||||
|
} else if (uEstado < 1.5) {
|
||||||
|
// tela: o botão inteiro pulsa de 1 a 1,08 em volta do centro, a cada 2,4 s
|
||||||
|
float k = 1.0 + uVida * 0.08 * aPapel.y * sin(3.14159265 * min(mod(uTempo, 2.4) / 0.9, 1.0));
|
||||||
|
p = uBotao + (p - uBotao) * k;
|
||||||
|
s *= k;
|
||||||
|
} else if (uEstado < 2.5) {
|
||||||
|
// integrações: um pulso lima corre do núcleo aos nós em 0,9 s, a cada 2,5 s
|
||||||
|
float frente = mod(uTempo, 2.5) / 0.9 * 1.4 - 0.2;
|
||||||
|
if (aPapel.z >= 0.0) tom = max(tom, uVida * (1.0 - smoothstep(0.0, 0.2, abs(aPapel.z - frente))));
|
||||||
|
} else if (aPapel.w >= 0.0) {
|
||||||
|
// dados: cada barra respira 15% em altura a partir do chão, com a fase dela, em 3,2 s
|
||||||
|
float k = 1.0 + uVida * 0.15 * (0.5 - 0.5 * cos(6.2831853 * (uTempo / 3.2 + aPapel.w)));
|
||||||
|
p.z = uChao + (p.z - uChao) * k;
|
||||||
|
s.z *= k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 a = lugar(uFrom);
|
||||||
|
vec4 b = lugar(uTo);
|
||||||
|
vec3 ea = escala(uFrom);
|
||||||
|
vec3 eb = escala(uTo);
|
||||||
|
// bloco que só existe num dos estados cresce ou some no lugar, sem viajar
|
||||||
|
vec3 pa = ea.x > 0.0 ? a.xyz : b.xyz;
|
||||||
|
vec3 pb = eb.x > 0.0 ? b.xyz : a.xyz;
|
||||||
|
float e = encaixe(uT);
|
||||||
|
vec3 p = mix(pa, pb, e);
|
||||||
|
// arco curto para a frente (+Z), que volta a zero ao assentar; quem não sai do lugar não faz arco
|
||||||
|
p.z += sin(3.14159265 * e) * min(distance(pa, pb), 1.0) * (0.5 + 0.5 * aSemente);
|
||||||
|
vec3 s = mix(ea, eb, e);
|
||||||
|
vTom = mix(a.w, b.w, e);
|
||||||
|
if (uVida > 0.0) vida(p, s, vTom);
|
||||||
|
// cursor: os blocos a até 2 unidades do ponteiro avançam até 0,5 para a frente, com queda suave
|
||||||
|
p.z += 0.5 * uCursorPeso * (1.0 - smoothstep(0.0, 2.0, distance(p.xy, uCursor)));
|
||||||
|
// entrada: de perto do lugar e um pouco menor, com o mesmo encaixe
|
||||||
|
float ei = encaixe(uIntro);
|
||||||
|
p += aEntrada * (1.0 - ei);
|
||||||
|
s *= mix(0.8, 1.0, ei);
|
||||||
|
vec4 mv = modelViewMatrix * vec4(position * s + p, 1.0);
|
||||||
|
// escala não uniforme: a normal vai pela inversa da escala
|
||||||
|
vNormal = normalize(normalMatrix * (normal / max(s, vec3(1e-4))));
|
||||||
|
gl_Position = projectionMatrix * mv;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FRAGMENT = /* glsl */ `
|
||||||
|
uniform sampler2D uPorcelana;
|
||||||
|
uniform sampler2D uLima;
|
||||||
|
uniform mat3 uLuz;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying float vTom;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// matcap pela normal em espaço de visão, desfeita da pose (uLuz): a luz fica presa à peça como no Blender, onde a
|
||||||
|
// câmera do pôster girou em volta da peça e a luz não. Sem a correção de perspectiva do MeshMatcapMaterial, que
|
||||||
|
// prenderia a luz à câmera de novo; só o balanço e o mouse mexem nela
|
||||||
|
vec3 n = uLuz * normalize(vNormal);
|
||||||
|
vec2 uv = n.xy * 0.495 + 0.5;
|
||||||
|
gl_FragColor = vec4(mix(texture2D(uPorcelana, uv).rgb, texture2D(uLima, uv).rgb, vTom), 1.0);
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let assets: Promise<Assets> | null = null;
|
||||||
|
|
||||||
|
async function loadAssets(): Promise<Assets> {
|
||||||
|
const textures = new TextureLoader();
|
||||||
|
const [gltf, blocos, porcelana, lima] = await Promise.all([
|
||||||
|
new GLTFLoader().loadAsync("/3d/bloco.glb"),
|
||||||
|
fetch("/3d/blocos.json").then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`blocos.json: ${response.status}`);
|
||||||
|
return response.json().then(parseBlocos);
|
||||||
|
}),
|
||||||
|
textures.loadAsync("/3d/matcap-porcelana.webp"),
|
||||||
|
textures.loadAsync("/3d/matcap-lima.webp"),
|
||||||
|
]);
|
||||||
|
let bloco: BufferGeometry | undefined;
|
||||||
|
gltf.scene.traverse((object) => {
|
||||||
|
if (!bloco && object instanceof Mesh) bloco = object.geometry;
|
||||||
|
});
|
||||||
|
if (!bloco) throw new Error("bloco.glb sem malha");
|
||||||
|
// os matcaps estão em sRGB: o three decodifica na amostra e `colorspace_fragment` codifica de volta na saída
|
||||||
|
porcelana.colorSpace = SRGBColorSpace;
|
||||||
|
lima.colorSpace = SRGBColorSpace;
|
||||||
|
return { bloco, blocos, porcelana, lima };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Os ativos baixados uma vez por página; uma falha esvazia o cache para a próxima montagem tentar de novo. */
|
||||||
|
function loadBlocos() {
|
||||||
|
assets ??= loadAssets().catch((error) => {
|
||||||
|
assets = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baixa e deixa em cache o bloco, os estados e os matcaps, para quem quer adiantar o download antes de montar a cena.
|
||||||
|
* Nunca rejeita: uma falha aparece ao montar (`onError`).
|
||||||
|
*/
|
||||||
|
export function prepareBlocos(): Promise<void> {
|
||||||
|
return loadBlocos().then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Número de 0 a 1 fixo para cada bloco e canal: a mesma "sorte" a cada visita. */
|
||||||
|
function sorteio(bloco: number, canal: number) {
|
||||||
|
let h = Math.imul(bloco * 8 + canal + 1, 0x9e3779b1);
|
||||||
|
h = Math.imul(h ^ (h >>> 15), 0x85ebca6b);
|
||||||
|
h ^= h >>> 13;
|
||||||
|
return (h >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A cena: câmera, a malha instanciada com os quatro estados e o material de matcap. */
|
||||||
|
function buildWorld({ bloco, blocos, porcelana, lima }: Assets) {
|
||||||
|
const { n, estados } = blocos;
|
||||||
|
const geometry = new InstancedBufferGeometry();
|
||||||
|
geometry.index = bloco.index;
|
||||||
|
geometry.setAttribute("position", bloco.getAttribute("position"));
|
||||||
|
geometry.setAttribute("normal", bloco.getAttribute("normal"));
|
||||||
|
geometry.instanceCount = n;
|
||||||
|
estados.forEach((dados, e) => {
|
||||||
|
const lugar = new Float32Array(n * 4);
|
||||||
|
const escala = new Float32Array(n * 3);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
lugar[i * 4 + k] = dados[i * 7 + k];
|
||||||
|
escala[i * 3 + k] = dados[i * 7 + 3 + k];
|
||||||
|
}
|
||||||
|
lugar[i * 4 + 3] = dados[i * 7 + 6];
|
||||||
|
}
|
||||||
|
geometry.setAttribute(`aPos${e}`, new InstancedBufferAttribute(lugar, 4));
|
||||||
|
geometry.setAttribute(`aEsc${e}`, new InstancedBufferAttribute(escala, 3));
|
||||||
|
});
|
||||||
|
const semente = new Float32Array(n);
|
||||||
|
const entrada = new Float32Array(n * 3);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
semente[i] = sorteio(i, 0);
|
||||||
|
// de onde o bloco entra: até 1,2 unidade do lugar, mais para a frente que para trás
|
||||||
|
const x = sorteio(i, 1) - 0.5;
|
||||||
|
const y = sorteio(i, 2) - 0.5;
|
||||||
|
const z = sorteio(i, 3);
|
||||||
|
const k = (0.4 + 0.8 * sorteio(i, 4)) / Math.hypot(x, y, z || 1);
|
||||||
|
entrada[i * 3] = x * k;
|
||||||
|
entrada[i * 3 + 1] = y * k;
|
||||||
|
entrada[i * 3 + 2] = z * k;
|
||||||
|
}
|
||||||
|
geometry.setAttribute("aSemente", new InstancedBufferAttribute(semente, 1));
|
||||||
|
geometry.setAttribute("aEntrada", new InstancedBufferAttribute(entrada, 3));
|
||||||
|
const { vida, botao, chao } = papeis(blocos);
|
||||||
|
geometry.setAttribute("aPapel", new InstancedBufferAttribute(vida, 4));
|
||||||
|
|
||||||
|
// a visão desfeita da pose da peça (ver o fragment shader)
|
||||||
|
const luz = new Matrix3();
|
||||||
|
const material = new ShaderMaterial({
|
||||||
|
vertexShader: VERTEX,
|
||||||
|
fragmentShader: FRAGMENT,
|
||||||
|
uniforms: {
|
||||||
|
uFrom: { value: 0 },
|
||||||
|
uTo: { value: 0 },
|
||||||
|
uT: { value: 0 },
|
||||||
|
uIntro: { value: 0 },
|
||||||
|
uVida: { value: 0 },
|
||||||
|
uEstado: { value: 0 },
|
||||||
|
uTempo: { value: 0 },
|
||||||
|
uBotao: { value: new Vector3(...botao) },
|
||||||
|
uChao: { value: chao },
|
||||||
|
uCursor: { value: new Vector2() },
|
||||||
|
uCursorPeso: { value: 0 },
|
||||||
|
uPorcelana: { value: porcelana },
|
||||||
|
uLima: { value: lima },
|
||||||
|
uLuz: { value: luz },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mesh = new Mesh(geometry, material);
|
||||||
|
// a malha é um bloco só: a caixa dela não diz onde estão as instâncias
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
const scene = new Scene();
|
||||||
|
scene.add(mesh);
|
||||||
|
const camera = new PerspectiveCamera(35, 1, 1, DISTANCIA * 4);
|
||||||
|
camera.position.set(0, 0, DISTANCIA);
|
||||||
|
// o raio do ponteiro sai dela antes do primeiro desenho
|
||||||
|
camera.updateMatrixWorld();
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
mesh,
|
||||||
|
geometry,
|
||||||
|
material,
|
||||||
|
porcelana,
|
||||||
|
lima,
|
||||||
|
luz,
|
||||||
|
pose: new Euler(),
|
||||||
|
rotation: new Matrix4(),
|
||||||
|
// o raio do ponteiro levado para dentro da peça e onde ele fura o plano dela
|
||||||
|
inversa: new Matrix4(),
|
||||||
|
raio: new Ray(),
|
||||||
|
mira: new Vector3(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type World = ReturnType<typeof buildWorld>;
|
||||||
|
type Tier = NonNullable<BlocosSceneProps["tier"]>;
|
||||||
|
|
||||||
|
type SceneState = {
|
||||||
|
/** Progresso amortecido (`full`). */
|
||||||
|
progress: number;
|
||||||
|
/** Entrada, de 0 a 1. */
|
||||||
|
intro: number;
|
||||||
|
/** Relógio da troca sozinha (`lite`, s), que só anda à vista e depois da entrada. */
|
||||||
|
clock: number;
|
||||||
|
/** `full`: tempo (s) com a página parada no topo, sem rolagem; acima de 0, o ciclo em repouso tem o comando. */
|
||||||
|
quieto: number;
|
||||||
|
/** O alvo do progresso no quadro anterior (rolagem é ele mudar). */
|
||||||
|
ultimoAlvo: number;
|
||||||
|
/** O estado mostrado, quem o pede e se ele já alcançou o pedido (aí o segue sem atraso). */
|
||||||
|
mostrado: Estado | null;
|
||||||
|
fonte: "ciclo" | "rolagem";
|
||||||
|
preso: boolean;
|
||||||
|
pointer: Vector2;
|
||||||
|
tilt: Vector2;
|
||||||
|
frames: number;
|
||||||
|
/**
|
||||||
|
* Peso da vida em repouso (0 a 1), o estado em que ela corre e o relógio dela e do balanço (s, só anda quando há
|
||||||
|
* quadro: o balanço não salta quando o desenho volta depois de parado).
|
||||||
|
*/
|
||||||
|
vida: number;
|
||||||
|
vidaEstado: number;
|
||||||
|
vidaTempo: number;
|
||||||
|
/** Ponteiro amortecido no plano da peça e o peso dele (0 a 1). */
|
||||||
|
cursor: Vector2;
|
||||||
|
cursorPeso: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StepInput = {
|
||||||
|
tier: Tier;
|
||||||
|
target: number;
|
||||||
|
placement: Rect | null;
|
||||||
|
stage: Rect | null;
|
||||||
|
host: { width: number; height: number };
|
||||||
|
/** Tempo real desde o quadro anterior (ms). */
|
||||||
|
delta: number;
|
||||||
|
/** Ponteiro em px do hospedeiro (`full`, com o mouse dentro dele); `null` sem ele. */
|
||||||
|
cursor: Vector2 | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Peso da marca num estado: 1 parado nela, 0 nos outros, entre os dois numa troca de ou para ela. */
|
||||||
|
function pesoMarca({ from, to, t }: Estado) {
|
||||||
|
return (from === 0 ? 1 - t : 0) + (to === 0 ? t : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Um quadro: avança a entrada, o progresso (ou o relógio da troca), a pose e o enquadramento, a vida em repouso e o
|
||||||
|
* cursor, e escreve os uniforms.
|
||||||
|
* Diz se ainda há algo assentando (o próximo quadro sai de imediato), em quanto tempo (s) vem a próxima troca do
|
||||||
|
* ciclo parado (`lite` e `full` no topo) e o estado assentado que a legenda mostra.
|
||||||
|
*/
|
||||||
|
function stepBlocos(world: World, s: SceneState, input: StepInput) {
|
||||||
|
const { tier, target, placement, stage, host, delta } = input;
|
||||||
|
const dt = Math.min(delta / 1000, 1 / 20);
|
||||||
|
s.intro = Math.min(1, s.intro + dt / ENTRADA);
|
||||||
|
let settling = s.intro < 1;
|
||||||
|
let wait = 0;
|
||||||
|
let pedido: Estado;
|
||||||
|
let fonte: SceneState["fonte"] = "ciclo";
|
||||||
|
// a troca sozinha conta tempo real (o quadro seguinte de uma espera chega só no fim dela)
|
||||||
|
const real = Math.min(delta / 1000, FICA + TROCA);
|
||||||
|
if (tier === "full") {
|
||||||
|
// rolagem amortecida: a peça segue o dedo com um leve atraso
|
||||||
|
s.progress += (target - s.progress) * (1 - Math.exp(-dt * 5));
|
||||||
|
if (Math.abs(target - s.progress) > 4e-4) settling = true;
|
||||||
|
else s.progress = target;
|
||||||
|
// parada no topo, sem rolagem desde o quadro anterior: o ciclo em repouso (que começa parado na marca, `FICA_TOPO`
|
||||||
|
// s) assume; qualquer rolagem devolve o comando à rolagem
|
||||||
|
const noTopo =
|
||||||
|
s.intro >= 1 &&
|
||||||
|
target < TOPO &&
|
||||||
|
target === s.ultimoAlvo &&
|
||||||
|
s.progress === target;
|
||||||
|
s.ultimoAlvo = target;
|
||||||
|
s.quieto = noTopo ? s.quieto + real : 0;
|
||||||
|
if (s.quieto > 0) {
|
||||||
|
const ciclo = cicloAt(s.quieto, FICA_TOPO, TROCA);
|
||||||
|
pedido = ciclo;
|
||||||
|
if (ciclo.wait === 0) settling = true;
|
||||||
|
else wait = ciclo.wait;
|
||||||
|
} else {
|
||||||
|
pedido = stateAt(s.progress);
|
||||||
|
fonte = "rolagem";
|
||||||
|
}
|
||||||
|
s.tilt.lerp(s.pointer, 1 - Math.exp(-dt * 3));
|
||||||
|
} else {
|
||||||
|
if (s.intro >= 1) s.clock += real;
|
||||||
|
const lite = cicloAt(s.clock, FICA, TROCA);
|
||||||
|
pedido = lite;
|
||||||
|
if (lite.wait === 0) settling = true;
|
||||||
|
else wait = lite.wait;
|
||||||
|
}
|
||||||
|
// quem pede mudou: a peça vai do estado em que está até o pedido, sem salto, e daí em diante o segue
|
||||||
|
if (fonte !== s.fonte) s.preso = false;
|
||||||
|
s.fonte = fonte;
|
||||||
|
let estado = pedido;
|
||||||
|
if (s.mostrado && !s.preso) {
|
||||||
|
estado = seguir(s.mostrado, pedido, (dt * SEGUE) / TROCA);
|
||||||
|
s.preso =
|
||||||
|
estado.from === pedido.from &&
|
||||||
|
estado.to === pedido.to &&
|
||||||
|
estado.t === pedido.t;
|
||||||
|
if (!s.preso) settling = true;
|
||||||
|
}
|
||||||
|
s.mostrado = { from: estado.from, to: estado.to, t: estado.t };
|
||||||
|
const u = world.material.uniforms;
|
||||||
|
u.uFrom.value = estado.from;
|
||||||
|
u.uTo.value = estado.to;
|
||||||
|
u.uT.value = estado.t;
|
||||||
|
u.uIntro.value = s.intro;
|
||||||
|
|
||||||
|
// peso da marca: 1 parado nela, 0 nos outros estados; pose e enquadramento seguem a troca
|
||||||
|
const w = pesoMarca(estado);
|
||||||
|
// a marca vai na caixa da peça; os outros estados, no ciclo do topo, na caixa dos estados (dentro da da peça, longe
|
||||||
|
// do texto) e, no mergulho, na área útil. `fora` diz quanto a rolagem já pede um estado fora da marca: 0 no topo,
|
||||||
|
// então a rolagem assume do ciclo sem salto (o progresso amortecido parte de lá)
|
||||||
|
const fora =
|
||||||
|
tier === "full" && s.quieto === 0 ? 1 - pesoMarca(stateAt(s.progress)) : 0;
|
||||||
|
let area = stage;
|
||||||
|
if (tier === "full" && placement) {
|
||||||
|
const topo = caixaDosEstados(placement, host);
|
||||||
|
const fim = stage ?? { x: 0, y: 0, ...host };
|
||||||
|
area = {
|
||||||
|
x: MathUtils.lerp(topo.x, fim.x, fora),
|
||||||
|
y: MathUtils.lerp(topo.y, fim.y, fora),
|
||||||
|
width: MathUtils.lerp(topo.width, fim.width, fora),
|
||||||
|
height: MathUtils.lerp(topo.height, fim.height, fora),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// px por unidade no plano z = 0, para levar o enquadramento (px) para a cena
|
||||||
|
const k =
|
||||||
|
host.height /
|
||||||
|
(2 * DISTANCIA * Math.tan(MathUtils.degToRad(world.camera.fov / 2)));
|
||||||
|
const frame = framePiece(host, placement, w, area);
|
||||||
|
const x = (frame.x - host.width / 2) / k;
|
||||||
|
const y = (host.height / 2 - frame.y) / k;
|
||||||
|
world.mesh.position.set(x, y, 0);
|
||||||
|
world.mesh.scale.setScalar(frame.scale / k);
|
||||||
|
// a pose vale em relação à linha de visada: fora do centro a câmera já vê a peça de lado (à direita, pela
|
||||||
|
// esquerda dela), e a guinada e a elevação descontam esse ângulo para ela aparecer como no pôster
|
||||||
|
const pose = world.pose.set(
|
||||||
|
MathUtils.lerp(FRENTE.pitch, REPOUSO.pitch, w) + Math.atan2(y, DISTANCIA),
|
||||||
|
MathUtils.lerp(FRENTE.yaw, REPOUSO.yaw, w) - Math.atan2(x, DISTANCIA),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const sway = tier === "full" ? 0.08 * Math.sin(s.vidaTempo * 0.35) : 0;
|
||||||
|
world.mesh.rotation.set(
|
||||||
|
pose.x - s.tilt.y * 0.06,
|
||||||
|
pose.y + sway + s.tilt.x * 0.1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// a inversa da pose (uma rotação: a transposta) leva a visão de volta à frente da peça, onde os matcaps foram feitos
|
||||||
|
world.luz
|
||||||
|
.setFromMatrix4(world.rotation.makeRotationFromEuler(pose))
|
||||||
|
.transpose();
|
||||||
|
|
||||||
|
// vida em repouso: só parada num estado e depois da entrada; muda de estado só depois de sumir no anterior
|
||||||
|
const parado = estado.from === estado.to && s.intro >= 1;
|
||||||
|
if (parado && s.vida < 0.01) s.vidaEstado = estado.to;
|
||||||
|
const vida = parado && estado.to === s.vidaEstado ? 1 : 0;
|
||||||
|
s.vida += (vida - s.vida) * (1 - Math.exp(-dt * 5));
|
||||||
|
s.vidaTempo += dt;
|
||||||
|
u.uVida.value = s.vida;
|
||||||
|
u.uEstado.value = s.vidaEstado;
|
||||||
|
u.uTempo.value = s.vidaTempo;
|
||||||
|
|
||||||
|
// cursor: o raio da câmera pelo ponteiro, levado para dentro da peça, fura o plano z = 0 dela
|
||||||
|
let mira = 0;
|
||||||
|
if (input.cursor) {
|
||||||
|
world.mesh.updateMatrixWorld();
|
||||||
|
const { raio, camera } = world;
|
||||||
|
raio.origin.setFromMatrixPosition(camera.matrixWorld);
|
||||||
|
raio.direction
|
||||||
|
.set(
|
||||||
|
(input.cursor.x / host.width) * 2 - 1,
|
||||||
|
1 - (input.cursor.y / host.height) * 2,
|
||||||
|
0.5,
|
||||||
|
)
|
||||||
|
.unproject(camera)
|
||||||
|
.sub(raio.origin);
|
||||||
|
raio.applyMatrix4(world.inversa.copy(world.mesh.matrixWorld).invert());
|
||||||
|
if (raio.intersectPlane(PLANO, world.mira)) mira = 1;
|
||||||
|
}
|
||||||
|
if (mira) {
|
||||||
|
// chegando de fora, o ponto já nasce sob o ponteiro; dentro, segue com um leve atraso
|
||||||
|
const segue = s.cursorPeso < 0.01 ? 1 : 1 - Math.exp(-dt * 12);
|
||||||
|
s.cursor.x += (world.mira.x - s.cursor.x) * segue;
|
||||||
|
s.cursor.y += (world.mira.y - s.cursor.y) * segue;
|
||||||
|
}
|
||||||
|
s.cursorPeso += (mira - s.cursorPeso) * (1 - Math.exp(-dt * 6));
|
||||||
|
u.uCursor.value.copy(s.cursor);
|
||||||
|
u.uCursorPeso.value = s.cursorPeso * s.vida;
|
||||||
|
s.frames += 1;
|
||||||
|
// a legenda diz o estado em que a peça assentou, só com ela na caixa dela (no `lite`, sempre): nenhuma durante uma
|
||||||
|
// troca, quando os blocos do estado anterior ainda ocupam o lugar dela
|
||||||
|
const assentada = estado.from === estado.to && estado.t === 0;
|
||||||
|
const legenda =
|
||||||
|
assentada && (tier === "lite" || fora <= 0.001) ? estado.from : null;
|
||||||
|
return { again: settling, wait, ready: s.frames === 2, legenda };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solta o contexto WebGL e tira o canvas da página. */
|
||||||
|
function disposeRenderer(renderer: WebGLRenderer) {
|
||||||
|
renderer.dispose();
|
||||||
|
renderer.forceContextLoss();
|
||||||
|
renderer.domElement.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O renderizador transparente, com antialias, na GPU de alto desempenho, com o canvas dentro de `host`. Lança se o
|
||||||
|
* contexto não abre ou se ele desenha por software (o veredito fica anotado na sessão: `rememberSoftwareWebGL`).
|
||||||
|
* Razão de pixels até 1,75 (`full`) ou 1,5 (`lite`).
|
||||||
|
*/
|
||||||
|
function createRenderer(host: HTMLElement, tier: Tier) {
|
||||||
|
const renderer = new WebGLRenderer({
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
});
|
||||||
|
if (isSoftwareWebGL(renderer.getContext())) {
|
||||||
|
rememberSoftwareWebGL();
|
||||||
|
disposeRenderer(renderer);
|
||||||
|
throw new Error("WebGL por software");
|
||||||
|
}
|
||||||
|
renderer.setPixelRatio(
|
||||||
|
Math.min(
|
||||||
|
tier === "lite" ? 1.5 : 1.75,
|
||||||
|
Math.max(1, window.devicePixelRatio || 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
canvas.setAttribute("aria-hidden", "true");
|
||||||
|
canvas.style.cssText = "display:block;width:100%;height:100%";
|
||||||
|
host.append(canvas);
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Parts = { renderer: WebGLRenderer; world: World };
|
||||||
|
|
||||||
|
/** Solta a geometria, o material, as texturas e o contexto WebGL. */
|
||||||
|
function disposeParts({ renderer, world }: Parts) {
|
||||||
|
world.geometry.dispose();
|
||||||
|
world.material.dispose();
|
||||||
|
world.porcelana.dispose();
|
||||||
|
world.lima.dispose();
|
||||||
|
disposeRenderer(renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Callbacks = Pick<
|
||||||
|
BlocosSceneProps,
|
||||||
|
"progressRef" | "placementRef" | "stageRef" | "onEstado"
|
||||||
|
> & {
|
||||||
|
onReady: () => void;
|
||||||
|
onError: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlocosEngine = {
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Liga tamanho, mouse (`full`) e perda de contexto, e desenha sob demanda. */
|
||||||
|
function startBlocos(
|
||||||
|
host: HTMLElement,
|
||||||
|
parts: Parts,
|
||||||
|
{
|
||||||
|
progressRef,
|
||||||
|
placementRef,
|
||||||
|
stageRef,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
onEstado,
|
||||||
|
}: Callbacks,
|
||||||
|
tier: Tier,
|
||||||
|
): BlocosEngine {
|
||||||
|
const { renderer, world } = parts;
|
||||||
|
const canvas = renderer.domElement;
|
||||||
|
const s: SceneState = {
|
||||||
|
// nasce no ponto da rolagem em que a página já está, sem varrer os estados no caminho
|
||||||
|
progress: progressRef.current ?? 0,
|
||||||
|
intro: 0,
|
||||||
|
clock: 0,
|
||||||
|
quieto: 0,
|
||||||
|
ultimoAlvo: progressRef.current ?? 0,
|
||||||
|
mostrado: null,
|
||||||
|
fonte: "rolagem",
|
||||||
|
preso: true,
|
||||||
|
pointer: new Vector2(),
|
||||||
|
tilt: new Vector2(),
|
||||||
|
frames: 0,
|
||||||
|
vida: 0,
|
||||||
|
vidaEstado: 0,
|
||||||
|
vidaTempo: 0,
|
||||||
|
cursor: new Vector2(),
|
||||||
|
cursorPeso: 0,
|
||||||
|
};
|
||||||
|
/** Último ponteiro do mouse (px da janela), `null` sem mouse ou fora da janela; e ele em px do hospedeiro. */
|
||||||
|
let mouse: Vector2 | null = null;
|
||||||
|
const noHost = new Vector2();
|
||||||
|
const size = { width: 1, height: 1 };
|
||||||
|
const budget = createFrameBudget();
|
||||||
|
let verdict: FrameVerdict = "pending";
|
||||||
|
let alive = true;
|
||||||
|
let visible = false;
|
||||||
|
let raf = 0;
|
||||||
|
/** O quadro pedido veio do quadro anterior (só esses medem o orçamento). */
|
||||||
|
let chained = false;
|
||||||
|
let last = 0;
|
||||||
|
let wake: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let stopPulse: (() => void) | undefined;
|
||||||
|
/** O último estado avisado para a legenda. */
|
||||||
|
let legenda: number | null = null;
|
||||||
|
|
||||||
|
const request = (fromFrame: boolean) => {
|
||||||
|
if (raf || !alive || !visible) return;
|
||||||
|
chained = fromFrame;
|
||||||
|
raf = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
const invalidate = () => request(false);
|
||||||
|
|
||||||
|
/** Para de desenhar: nenhum quadro, batida nem espera pendente. */
|
||||||
|
const halt = () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = 0;
|
||||||
|
clearTimeout(wake);
|
||||||
|
wake = undefined;
|
||||||
|
stopPulse?.();
|
||||||
|
stopPulse = undefined;
|
||||||
|
last = 0;
|
||||||
|
};
|
||||||
|
const fail = () => {
|
||||||
|
if (!alive) return;
|
||||||
|
alive = false;
|
||||||
|
halt();
|
||||||
|
onError();
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (now: number) => {
|
||||||
|
const delta = last ? now - last : 1000 / 60;
|
||||||
|
last = now;
|
||||||
|
if (chained) verdict = budget(delta);
|
||||||
|
if (verdict === "slow") {
|
||||||
|
rememberSlowScene();
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cursor: Vector2 | null = null;
|
||||||
|
if (mouse) {
|
||||||
|
const r = host.getBoundingClientRect();
|
||||||
|
noHost.set(mouse.x - r.left, mouse.y - r.top);
|
||||||
|
if (
|
||||||
|
noHost.x >= 0 &&
|
||||||
|
noHost.y >= 0 &&
|
||||||
|
noHost.x <= r.width &&
|
||||||
|
noHost.y <= r.height
|
||||||
|
)
|
||||||
|
cursor = noHost;
|
||||||
|
}
|
||||||
|
const step = stepBlocos(world, s, {
|
||||||
|
tier,
|
||||||
|
target: progressRef.current ?? 0,
|
||||||
|
placement: placementRef?.current ?? null,
|
||||||
|
stage: stageRef?.current ?? null,
|
||||||
|
host: size,
|
||||||
|
delta,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
renderer.render(world.scene, world.camera);
|
||||||
|
if (step.ready) onReady();
|
||||||
|
if (step.legenda !== legenda) {
|
||||||
|
legenda = step.legenda;
|
||||||
|
onEstado?.(legenda);
|
||||||
|
}
|
||||||
|
// a entrada e os quadros seguintes correm no ritmo da tela até o orçamento ter veredito
|
||||||
|
if (step.again || verdict === "pending") request(true);
|
||||||
|
else if (step.wait) {
|
||||||
|
clearTimeout(wake);
|
||||||
|
wake = setTimeout(invalidate, step.wait * 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const render = (now: number) => {
|
||||||
|
raf = 0;
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
draw(now);
|
||||||
|
} catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
size.width = Math.max(1, host.clientWidth);
|
||||||
|
size.height = Math.max(1, host.clientHeight);
|
||||||
|
renderer.setSize(size.width, size.height, false);
|
||||||
|
world.camera.aspect = size.width / size.height;
|
||||||
|
world.camera.updateProjectionMatrix();
|
||||||
|
invalidate();
|
||||||
|
};
|
||||||
|
const onPointer = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== "mouse") return;
|
||||||
|
s.pointer.set(
|
||||||
|
(event.clientX / window.innerWidth) * 2 - 1,
|
||||||
|
-(event.clientY / window.innerHeight) * 2 + 1,
|
||||||
|
);
|
||||||
|
mouse ??= new Vector2();
|
||||||
|
mouse.set(event.clientX, event.clientY);
|
||||||
|
};
|
||||||
|
// o mouse saiu da janela: os blocos voltam
|
||||||
|
const onLeave = (event: PointerEvent) => {
|
||||||
|
if (!event.relatedTarget) mouse = null;
|
||||||
|
};
|
||||||
|
const onLost = (event: Event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fail();
|
||||||
|
};
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||||
|
observer?.observe(host);
|
||||||
|
if (!observer) window.addEventListener("resize", resize);
|
||||||
|
if (tier === "full") {
|
||||||
|
window.addEventListener("pointermove", onPointer, { passive: true });
|
||||||
|
window.addEventListener("pointerout", onLeave, { passive: true });
|
||||||
|
}
|
||||||
|
canvas.addEventListener("webglcontextlost", onLost);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
setVisible(next) {
|
||||||
|
visible = next;
|
||||||
|
if (!next || !alive) return halt();
|
||||||
|
// a batida também dá vida ao `lite` parado; a troca sozinha segue com o próprio relógio
|
||||||
|
stopPulse ??= startFramePulse(invalidate);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
alive = false;
|
||||||
|
halt();
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
window.removeEventListener("pointermove", onPointer);
|
||||||
|
window.removeEventListener("pointerout", onLeave);
|
||||||
|
canvas.removeEventListener("webglcontextlost", onLost);
|
||||||
|
disposeParts(parts);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cede a vez ao navegador: o passo seguinte roda noutra tarefa. */
|
||||||
|
const nextTask = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monta a cena em tarefas separadas, para nenhuma passar de uns poucos milissegundos: os ativos, o renderizador, a
|
||||||
|
* cena com a compilação do shader (fora da thread principal) e o primeiro quadro. Devolve `null` se `isAlive` virou
|
||||||
|
* falso no caminho; lança se os ativos não baixam, se o aparelho não cria um contexto WebGL, se ele desenha por
|
||||||
|
* software ou se o shader não compila.
|
||||||
|
*/
|
||||||
|
export async function mountBlocos(
|
||||||
|
host: HTMLElement,
|
||||||
|
callbacks: Callbacks,
|
||||||
|
tier: Tier,
|
||||||
|
isAlive: () => boolean,
|
||||||
|
): Promise<BlocosEngine | null> {
|
||||||
|
const loaded = await loadBlocos();
|
||||||
|
await nextTask();
|
||||||
|
if (!isAlive()) return null;
|
||||||
|
const renderer = createRenderer(host, tier);
|
||||||
|
let parts: Parts | undefined;
|
||||||
|
try {
|
||||||
|
await nextTask();
|
||||||
|
if (!isAlive()) {
|
||||||
|
disposeRenderer(renderer);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
parts = { renderer, world: buildWorld(loaded) };
|
||||||
|
await renderer.compileAsync(parts.world.scene, parts.world.camera);
|
||||||
|
await nextTask();
|
||||||
|
} catch (error) {
|
||||||
|
if (parts) disposeParts(parts);
|
||||||
|
else disposeRenderer(renderer);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!isAlive()) {
|
||||||
|
disposeParts(parts);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return startBlocos(host, parts, callbacks, tier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlocosScene({
|
||||||
|
progressRef,
|
||||||
|
visible,
|
||||||
|
onReady,
|
||||||
|
onError,
|
||||||
|
tier = "full",
|
||||||
|
placementRef,
|
||||||
|
stageRef,
|
||||||
|
onEstado,
|
||||||
|
}: BlocosSceneProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
|
const engine = useRef<BlocosEngine | null>(null);
|
||||||
|
// sempre a última versão dos avisos, sem refazer a cena quando o pai troca de função
|
||||||
|
const callbacks = useRef({ onReady, onError, onEstado });
|
||||||
|
useEffect(() => {
|
||||||
|
callbacks.current = { onReady, onError, onEstado };
|
||||||
|
});
|
||||||
|
// o que a página pediu até a cena ficar pronta (ela nasce em tarefas separadas, depois do `visible` já ter chegado)
|
||||||
|
const wantsVisible = useRef(visible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
let alive = true;
|
||||||
|
mountBlocos(
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
progressRef,
|
||||||
|
placementRef,
|
||||||
|
stageRef,
|
||||||
|
onReady: () => callbacks.current.onReady(),
|
||||||
|
onEstado: (estado) => callbacks.current.onEstado?.(estado),
|
||||||
|
onError: () => callbacks.current.onError(),
|
||||||
|
},
|
||||||
|
tier,
|
||||||
|
() => alive,
|
||||||
|
)
|
||||||
|
.then((created) => {
|
||||||
|
if (!created) return;
|
||||||
|
if (!alive) return created.dispose();
|
||||||
|
engine.current = created;
|
||||||
|
created.setVisible(wantsVisible.current);
|
||||||
|
})
|
||||||
|
// sem ativos, sem WebGL, WebGL por software ou shader que não compila: o hero fica no pôster
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) callbacks.current.onError();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
engine.current?.dispose();
|
||||||
|
engine.current = null;
|
||||||
|
};
|
||||||
|
}, [progressRef, placementRef, stageRef, tier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
wantsVisible.current = visible;
|
||||||
|
engine.current?.setVisible(visible);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,11 +41,11 @@ describe("CinematicHome: dados estruturados", () => {
|
|||||||
expect(container.firstElementChild).toBe(scripts[0]);
|
expect(container.firstElementChild).toBe(scripts[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("describes only the three offers as Service nodes tied to the person, without repeating what the layout emits", () => {
|
it("describes only the five offers as Service nodes tied to the person, without repeating what the layout emits", () => {
|
||||||
const [data] = structuredData();
|
const [data] = structuredData();
|
||||||
|
|
||||||
expect(data["@context"]).toBe("https://schema.org");
|
expect(data["@context"]).toBe("https://schema.org");
|
||||||
expect(data["@graph"]).toHaveLength(3);
|
expect(data["@graph"]).toHaveLength(5);
|
||||||
for (const offer of data["@graph"]) {
|
for (const offer of data["@graph"]) {
|
||||||
expect(offer["@type"]).toBe("Service");
|
expect(offer["@type"]).toBe("Service");
|
||||||
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
|
expect(offer.provider).toEqual({ "@id": SEO_IDS.person });
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { useCinematicScroll } from "./useCinematicScroll";
|
|||||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Testes de caminhos-base de CinematicHome: o endereço do trabalho em destaque (projeto presente com site,
|
* Testes de caminhos-base de CinematicHome: a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele
|
||||||
* presente sem site, ausente), a repasse das props opcionais, a ordem das cenas (sem rodapé próprio: ele vem do layout) e a ligação dos hooks.
|
* vem do layout) e a ligação dos hooks.
|
||||||
* As cenas e os hooks são trocados por dublês que registram o que recebem.
|
* As cenas e os hooks são trocados por dublês que registram o que recebem.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -75,59 +75,6 @@ beforeEach(() => {
|
|||||||
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
|
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CinematicHome: endereço do trabalho em destaque", () => {
|
|
||||||
it("path: the featured project (Lavação 047) is in the list with its own domain -> that domain goes to the hero", () => {
|
|
||||||
render(
|
|
||||||
<CinematicHome
|
|
||||||
projects={[
|
|
||||||
project("sev-modaintima", "https://sevmodaintima.com.br"),
|
|
||||||
project("lavacao-047", "https://www.lavacao047.com.br/"),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastHeroProps().workUrl).toEqual({
|
|
||||||
text: "lavacao047.com.br",
|
|
||||||
secure: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the featured project is in the list but has no site -> no address", () => {
|
|
||||||
render(<CinematicHome projects={[project("lavacao-047", null)]} />);
|
|
||||||
|
|
||||||
expect(lastHeroProps().workUrl).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the featured project is not in the list -> no address", () => {
|
|
||||||
render(
|
|
||||||
<CinematicHome
|
|
||||||
projects={[project("sev-modaintima", "https://sevmodaintima.com.br")]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastHeroProps().workUrl).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the featured project only has a hosting address (vercel.app) -> no address bar text", () => {
|
|
||||||
render(
|
|
||||||
<CinematicHome
|
|
||||||
projects={[
|
|
||||||
project("lavacao-047", "https://landing-page-lavacao047.vercel.app"),
|
|
||||||
]}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(lastHeroProps().workUrl).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: empty project list -> no address and the page still renders", () => {
|
|
||||||
render(<CinematicHome projects={[]} />);
|
|
||||||
|
|
||||||
expect(lastHeroProps().workUrl).toBeNull();
|
|
||||||
expect(screen.getByTestId("scene-contact")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CinematicHome: props e estrutura", () => {
|
describe("CinematicHome: props e estrutura", () => {
|
||||||
it("path: every optional prop given -> each scene receives its own data", () => {
|
it("path: every optional prop given -> each scene receives its own data", () => {
|
||||||
const projects = [project("lavacao-047", "https://a.example.com")];
|
const projects = [project("lavacao-047", "https://a.example.com")];
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ import Contact from "../Contact";
|
|||||||
import Hero, { type HeroStats } from "../Hero";
|
import Hero, { type HeroStats } from "../Hero";
|
||||||
import HomeFaq from "../HomeFaq";
|
import HomeFaq from "../HomeFaq";
|
||||||
import Projects from "../Projects";
|
import Projects from "../Projects";
|
||||||
import { displayUrl } from "../showcase/BrowserFrame";
|
|
||||||
import Testimonials from "../Testimonials";
|
import Testimonials from "../Testimonials";
|
||||||
import { WorkModalities } from "../WorkModalities";
|
import { WorkModalities } from "../WorkModalities";
|
||||||
import CinematicMain from "./CinematicMain";
|
import CinematicMain from "./CinematicMain";
|
||||||
|
|
||||||
/** Projeto do navegador de HeroWork (a Lavação 047), cujo endereço, se for domínio próprio, aparece na barra. */
|
|
||||||
const HERO_WORK_PROJECT = "lavacao-047";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
|
* Dados estruturados da home: as três ofertas como nós Service (provider ligado à pessoa e área de atendimento). Só elas:
|
||||||
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
|
* o WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. Sem ProfessionalService nem LocalBusiness
|
||||||
@@ -44,19 +40,11 @@ export default function CinematicHome({
|
|||||||
clients?: ClientLink[];
|
clients?: ClientLink[];
|
||||||
testimonials?: Testimonial[];
|
testimonials?: Testimonial[];
|
||||||
}) {
|
}) {
|
||||||
const shownUrl = displayUrl({
|
|
||||||
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
|
|
||||||
?.demoUrl,
|
|
||||||
});
|
|
||||||
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
|
|
||||||
const heroWorkUrl =
|
|
||||||
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||||
<CinematicMain>
|
<CinematicMain>
|
||||||
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
|
<Hero stats={heroStats} clients={clients} />
|
||||||
|
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,19 @@ function MockScene(props: SceneProps) {
|
|||||||
return require("react").createElement("canvas", { "data-testid": "scene" });
|
return require("react").createElement("canvas", { "data-testid": "scene" });
|
||||||
}
|
}
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
// ler o módulo falha quando o teste simula o pacote que não carrega (uma promessa rejeitada)
|
||||||
get default() {
|
get default() {
|
||||||
if (mockImportFails) throw new Error("chunk indisponível");
|
if (mockImportFails) throw new Error("chunk indisponível");
|
||||||
return MockScene;
|
return MockScene;
|
||||||
},
|
},
|
||||||
get prepareField() {
|
get prepareBlocos() {
|
||||||
if (mockImportFails) throw new Error("chunk indisponível");
|
if (mockImportFails) throw new Error("chunk indisponível");
|
||||||
return () => mockPrepare();
|
return () => {
|
||||||
|
mockPrepare();
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -345,7 +348,7 @@ describe("HeroField: falhas voltam ao hero simples", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
it("path: low power -> the WebGL probe, the timer and the gesture listeners are never started", async () => {
|
||||||
mockMedia(["(pointer: coarse)"]);
|
mockNavigator({ hardwareConcurrency: 4 });
|
||||||
const probe = jest.fn(() => ({ getExtension: () => null }));
|
const probe = jest.fn(() => ({ getExtension: () => null }));
|
||||||
setContext(probe);
|
setContext(probe);
|
||||||
const addListener = jest.spyOn(window, "addEventListener");
|
const addListener = jest.spyOn(window, "addEventListener");
|
||||||
@@ -539,7 +542,7 @@ describe("HeroField: rolagem e visibilidade", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroField: navegador sem matchMedia", () => {
|
describe("HeroField: navegador sem matchMedia", () => {
|
||||||
it("path: no matchMedia at all -> the dive is not started, but the ambient scene still mounts", async () => {
|
it("path: no matchMedia at all -> the dive is not started, but the scene still mounts", async () => {
|
||||||
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
window.matchMedia = undefined as unknown as typeof window.matchMedia;
|
||||||
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { rememberSoftwareWebGL } from "@/lib/device";
|
import { rememberSlowScene, rememberSoftwareWebGL } from "@/lib/device";
|
||||||
import HeroField, { DIVE_QUERY } from "./HeroField";
|
import HeroField, { DIVE_QUERY } from "./HeroField";
|
||||||
|
|
||||||
type SceneProps = {
|
type SceneProps = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
progressRef: { current: number };
|
progressRef: { current: number };
|
||||||
|
placementRef?: { current: unknown };
|
||||||
|
stageRef?: { current: unknown };
|
||||||
|
tier?: string;
|
||||||
labelsRef?: unknown;
|
labelsRef?: unknown;
|
||||||
onReady: () => void;
|
onReady: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
|
onEstado?: (estado: number | null) => void;
|
||||||
};
|
};
|
||||||
const mockScenes: SceneProps[] = [];
|
const mockScenes: SceneProps[] = [];
|
||||||
const mockPrepare = jest.fn();
|
const mockPrepare = jest.fn();
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: function MockScene(props: SceneProps) {
|
default: function MockScene(props: SceneProps) {
|
||||||
mockScenes.push(props);
|
mockScenes.push(props);
|
||||||
@@ -20,7 +24,10 @@ jest.mock("./HeroFieldScene", () => ({
|
|||||||
"data-testid": "scene",
|
"data-testid": "scene",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
prepareField: () => mockPrepare(),
|
prepareBlocos: () => {
|
||||||
|
mockPrepare();
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const original = {
|
const original = {
|
||||||
@@ -47,14 +54,41 @@ function mockNavigator(values: Record<string, unknown>) {
|
|||||||
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
/** O que o globals.css dá ao hero: a altura do mergulho (telas de hero além dele) vem de `--hero-tail`. */
|
||||||
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
const HERO_TAIL = { "--hero-tail": 1.7 } as React.CSSProperties;
|
||||||
|
|
||||||
|
/** O palco e a peça (o pôster) como o Hero os monta: a peça fica na coluna da direita, 760 px à direita do palco. */
|
||||||
function Hero() {
|
function Hero() {
|
||||||
return (
|
return (
|
||||||
<section className="hero" style={HERO_TAIL}>
|
<section className="hero" style={HERO_TAIL}>
|
||||||
<HeroField />
|
<div
|
||||||
|
className="hero-stage"
|
||||||
|
style={{ "--site-header-height": "60px" } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<HeroField />
|
||||||
|
<div className="hero-piece" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STAGE = { left: 0, top: -40, width: 1440, height: 900 };
|
||||||
|
const PIECE = { left: 760, top: 110, width: 600, height: 620 };
|
||||||
|
/** A área útil do mergulho: 6% a 94% da largura, do cabeçalho (60 px) + 24 px até 70% da altura. */
|
||||||
|
const STAGE_AREA = { x: 86.4, y: 84, width: 1267.2, height: 546 };
|
||||||
|
/** A caixa da peça medida a partir do palco. */
|
||||||
|
const PLACEMENT = { x: 760, y: 150, width: 600, height: 620 };
|
||||||
|
|
||||||
|
function mockLayout() {
|
||||||
|
jest
|
||||||
|
.spyOn(Element.prototype, "getBoundingClientRect")
|
||||||
|
.mockImplementation(function (this: Element) {
|
||||||
|
const box = this.classList.contains("hero-stage")
|
||||||
|
? STAGE
|
||||||
|
: this.classList.contains("hero-piece")
|
||||||
|
? PIECE
|
||||||
|
: { left: 0, top: 0, width: 0, height: 0 };
|
||||||
|
return { ...box, x: box.left, y: box.top } as DOMRect;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const section = () => document.querySelector(".hero") as HTMLElement;
|
const section = () => document.querySelector(".hero") as HTMLElement;
|
||||||
|
|
||||||
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
/** Rola o hero no meio do mergulho e diz se a rolagem chegou a escrever o progresso no CSS. */
|
||||||
@@ -83,6 +117,7 @@ async function idle() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
mockLayout();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
mockScenes.length = 0;
|
mockScenes.length = 0;
|
||||||
mockPrepare.mockClear();
|
mockPrepare.mockClear();
|
||||||
@@ -101,6 +136,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
window.matchMedia = original.matchMedia;
|
window.matchMedia = original.matchMedia;
|
||||||
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
(window as unknown as Record<string, unknown>).WebGL2RenderingContext =
|
||||||
@@ -108,7 +144,7 @@ afterEach(() => {
|
|||||||
HTMLCanvasElement.prototype.getContext = original.getContext;
|
HTMLCanvasElement.prototype.getContext = original.getContext;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("HeroField (ambient background)", () => {
|
describe("HeroField (the 3D blocks over the hero poster)", () => {
|
||||||
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
it("mounts the WebGL scene only after the page is idle, on a capable desktop", async () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
expect(screen.queryByTestId("scene")).toBeNull();
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
@@ -175,7 +211,13 @@ describe("HeroField (ambient background)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each<[string, () => void]>([
|
it.each<[string, () => void]>([
|
||||||
["a touch pointer", () => mockMedia([DIVE_QUERY, "(pointer: coarse)"])],
|
[
|
||||||
|
"a weak phone (touch with 5 cores)",
|
||||||
|
() => {
|
||||||
|
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||||
|
mockNavigator({ hardwareConcurrency: 5 });
|
||||||
|
},
|
||||||
|
],
|
||||||
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
|
||||||
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
|
||||||
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
|
||||||
@@ -187,13 +229,14 @@ describe("HeroField (ambient background)", () => {
|
|||||||
undefined;
|
undefined;
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// o veredito vem do contexto da cena (HeroFieldScene), que o anota na sessão; a página seguinte já nasce estática
|
// os vereditos vêm do contexto da cena (BlocosScene), que os anota na sessão; a página seguinte já nasce estática
|
||||||
[
|
[
|
||||||
"a software WebGL found earlier in the session",
|
"a software WebGL found earlier in the session",
|
||||||
() => rememberSoftwareWebGL(),
|
() => rememberSoftwareWebGL(),
|
||||||
],
|
],
|
||||||
|
["a slow scene found earlier in the session", () => rememberSlowScene()],
|
||||||
])(
|
])(
|
||||||
"keeps the light static background (no WebGL, no dive, no scroll work) with %s",
|
"keeps only the poster (no WebGL, no dive, no scroll work) with %s",
|
||||||
async (_label, arrange) => {
|
async (_label, arrange) => {
|
||||||
arrange();
|
arrange();
|
||||||
const { container } = render(<Hero />);
|
const { container } = render(<Hero />);
|
||||||
@@ -211,7 +254,7 @@ describe("HeroField (ambient background)", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p and --hero-open", async () => {
|
it("marks the hero for the dive (the CSS then gives it its scroll block) and follows the hero scroll with --hero-p", async () => {
|
||||||
const { container } = render(<Hero />);
|
const { container } = render(<Hero />);
|
||||||
const hero = container.querySelector(".hero") as HTMLElement;
|
const hero = container.querySelector(".hero") as HTMLElement;
|
||||||
expect(hero).toHaveAttribute("data-dive");
|
expect(hero).toHaveAttribute("data-dive");
|
||||||
@@ -237,15 +280,13 @@ describe("HeroField (ambient background)", () => {
|
|||||||
|
|
||||||
scroll(700); // meio do mergulho (1400 px de trilho)
|
scroll(700); // meio do mergulho (1400 px de trilho)
|
||||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBeCloseTo(0.5);
|
||||||
// a máscara já se abriu por completo: o fundo aparece inteiro
|
|
||||||
expect(Number(hero.style.getPropertyValue("--hero-open"))).toBe(1);
|
|
||||||
|
|
||||||
scroll(1400);
|
scroll(1400);
|
||||||
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
expect(Number(hero.style.getPropertyValue("--hero-p"))).toBe(1);
|
||||||
expect(mockScenes.at(-1)?.visible).toBe(false);
|
expect(mockScenes.at(-1)?.visible).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not start the dive on a short screen (the CSS does not apply there), but the ambient scene still runs", async () => {
|
it("does not start the dive on a short screen (the CSS does not apply there), but the scene still runs", async () => {
|
||||||
mockMedia([]);
|
mockMedia([]);
|
||||||
|
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
@@ -277,4 +318,134 @@ describe("HeroField (ambient background)", () => {
|
|||||||
expect(section()).not.toHaveAttribute("data-dive");
|
expect(section()).not.toHaveAttribute("data-dive");
|
||||||
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
expect(section().style.getPropertyValue("--hero-p")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("full: covers the stage and hands the scene the measured poster box (where marca rests) and the useful dive area", async () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
await idle();
|
||||||
|
|
||||||
|
const scene = mockScenes.at(-1);
|
||||||
|
expect(scene?.tier).toBe("full");
|
||||||
|
expect(scene?.placementRef?.current).toEqual(PLACEMENT);
|
||||||
|
const area = scene?.stageRef?.current as typeof STAGE_AREA;
|
||||||
|
for (const key of ["x", "y", "width", "height"] as const)
|
||||||
|
expect(area[key]).toBeCloseTo(STAGE_AREA[key], 6);
|
||||||
|
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||||
|
expect(field).toHaveAttribute("data-tier", "full");
|
||||||
|
expect(field.getAttribute("style")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lite (a capable phone): no dive, the canvas box is the poster box, and no placement goes to the scene", async () => {
|
||||||
|
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
expect(section()).not.toHaveAttribute("data-dive");
|
||||||
|
|
||||||
|
await idle();
|
||||||
|
|
||||||
|
const scene = mockScenes.at(-1);
|
||||||
|
expect(scene?.tier).toBe("lite");
|
||||||
|
expect(scene?.placementRef).toBeUndefined();
|
||||||
|
expect(scene?.stageRef).toBeUndefined();
|
||||||
|
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||||
|
expect(field).toHaveAttribute("data-tier", "lite");
|
||||||
|
expect(field.style).toMatchObject({
|
||||||
|
left: "760px",
|
||||||
|
top: "150px",
|
||||||
|
width: "600px",
|
||||||
|
height: "620px",
|
||||||
|
});
|
||||||
|
expect(section()).not.toHaveAttribute("data-dive");
|
||||||
|
expect(scrollWritesProgress()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lite: falls back to the poster when the scene gives up", async () => {
|
||||||
|
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
await idle();
|
||||||
|
act(() => mockScenes.at(-1)?.onReady());
|
||||||
|
|
||||||
|
act(() => mockScenes.at(-1)?.onError());
|
||||||
|
|
||||||
|
const field = container.querySelector(".hero-field") as HTMLElement;
|
||||||
|
expect(screen.queryByTestId("scene")).toBeNull();
|
||||||
|
expect(field).not.toHaveAttribute("data-ready");
|
||||||
|
expect(field).not.toHaveAttribute("data-tier");
|
||||||
|
// de volta a cobrir o palco (inset: 0 do CSS), sem a caixa em linha
|
||||||
|
expect([field.style.left, field.style.width]).toEqual(["", ""]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A legenda da peça, se existir. */
|
||||||
|
const caption = (container: HTMLElement) =>
|
||||||
|
container.querySelector<HTMLElement>(".hero-legenda");
|
||||||
|
const tell = (estado: number | null) =>
|
||||||
|
act(() => mockScenes.at(-1)?.onEstado?.(estado));
|
||||||
|
|
||||||
|
it("captions what the piece is building, under the box of the states and aligned to its left, and nothing on marca", async () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
await idle();
|
||||||
|
act(() => mockScenes.at(-1)?.onReady());
|
||||||
|
tell(0);
|
||||||
|
expect(caption(container)).toBeNull();
|
||||||
|
|
||||||
|
for (const [estado, texto] of [
|
||||||
|
[1, "Sites e sistemas"],
|
||||||
|
[2, "Integrações"],
|
||||||
|
[3, "Painéis e dados"],
|
||||||
|
] as const) {
|
||||||
|
tell(estado);
|
||||||
|
const legenda = caption(container) as HTMLElement;
|
||||||
|
expect(legenda).toHaveTextContent(texto);
|
||||||
|
expect(legenda).toHaveAttribute("data-shown");
|
||||||
|
}
|
||||||
|
const legenda = caption(container) as HTMLElement;
|
||||||
|
// 12 px abaixo da caixa em que a cena põe os estados no topo, alinhada a eles: a caixa da peça sem os 22%
|
||||||
|
// da esquerda (760 + 132 px), na proporção 17 x 12 (468 x 330,4 px), centrada na altura da peça (150 + 620 / 2)
|
||||||
|
// (os estados têm 14,5 das 17 unidades dela, centrados: a legenda começa onde eles começam)
|
||||||
|
expect(Number.parseFloat(legenda.style.left)).toBeCloseTo(
|
||||||
|
892 + (468 * (1 - 14.5 / 17)) / 2,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
expect(Number.parseFloat(legenda.style.top)).toBeCloseTo(
|
||||||
|
460 + (468 * 12) / 17 / 2 + 12,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
// dentro do campo decorativo
|
||||||
|
|
||||||
|
expect(legenda.closest('[aria-hidden="true"]')).not.toBeNull();
|
||||||
|
// cada texto novo é digitado (.ds-type, um passo por caractere)
|
||||||
|
const typed = legenda.querySelector<HTMLElement>(".ds-type");
|
||||||
|
expect(typed?.style.getPropertyValue("--n")).toBe("15");
|
||||||
|
|
||||||
|
// de volta à marca, ou fora da caixa (o mergulho): some, com o último texto ainda no lugar enquanto apaga
|
||||||
|
tell(0);
|
||||||
|
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||||
|
tell(2);
|
||||||
|
tell(null);
|
||||||
|
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no caption before the scene is ready, nor after it gives up", async () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
await idle();
|
||||||
|
tell(1);
|
||||||
|
expect(caption(container)).not.toHaveAttribute("data-shown");
|
||||||
|
|
||||||
|
act(() => mockScenes.at(-1)?.onReady());
|
||||||
|
expect(caption(container)).toHaveAttribute("data-shown");
|
||||||
|
act(() => mockScenes.at(-1)?.onError());
|
||||||
|
expect(caption(container)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lite: puts the caption right under the canvas box, from 1024 px up only (below, the piece shares the title's corner)", async () => {
|
||||||
|
mockMedia([DIVE_QUERY, "(pointer: coarse)"]);
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
await idle();
|
||||||
|
act(() => mockScenes.at(-1)?.onReady());
|
||||||
|
tell(1);
|
||||||
|
|
||||||
|
expect(caption(container)?.style).toMatchObject({
|
||||||
|
left: "0px",
|
||||||
|
top: "632px",
|
||||||
|
});
|
||||||
|
expect(caption(container)).toHaveClass("max-lg:hidden");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
|
type CSSProperties,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { isKnownSoftwareWebGL, isLowPowerDevice } from "@/lib/device";
|
import { caixaDosEstados, legendaDe, type Rect } from "@/lib/blocos";
|
||||||
import type { HeroFieldSceneProps } from "./HeroFieldScene";
|
import {
|
||||||
|
isKnownSlowScene,
|
||||||
|
isKnownSoftwareWebGL,
|
||||||
|
type SceneTier,
|
||||||
|
sceneTier,
|
||||||
|
} from "@/lib/device";
|
||||||
|
import type { BlocosSceneProps } from "./BlocosScene";
|
||||||
|
import "./hero-legenda.css";
|
||||||
|
|
||||||
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
/** A mesma condição do CSS do mergulho (globals.css): tela com altura para o conteúdo e sem movimento reduzido. */
|
||||||
export const DIVE_QUERY =
|
export const DIVE_QUERY =
|
||||||
@@ -28,6 +36,13 @@ function heroTailScreens(section: HTMLElement) {
|
|||||||
const IDLE_SLACK_MS = 20;
|
const IDLE_SLACK_MS = 20;
|
||||||
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
/** Sem intervalo ocioso folgado até aqui (ms), a cena entra mesmo assim. */
|
||||||
const IDLE_PATIENCE_MS = 4000;
|
const IDLE_PATIENCE_MS = 4000;
|
||||||
|
/** Distância (px) entre a caixa da peça e a legenda abaixo dela. */
|
||||||
|
const LEGENDA_GAP = 12;
|
||||||
|
/**
|
||||||
|
* Recuo da legenda no full, em fração da largura da caixa dos estados: tela, integrações e dados têm até 14,5 das 17
|
||||||
|
* unidades dela, centrados (3d/blocos.py), e a legenda começa onde eles começam.
|
||||||
|
*/
|
||||||
|
const LEGENDA_RECUO = (1 - 14.5 / 17) / 2;
|
||||||
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
|
/** Gestos que contam como a primeira interação: a cena entra logo depois, sem esperar a folga. */
|
||||||
const FIRST_ACTIVITY = [
|
const FIRST_ACTIVITY = [
|
||||||
"pointerdown",
|
"pointerdown",
|
||||||
@@ -38,15 +53,10 @@ const FIRST_ACTIVITY = [
|
|||||||
"scroll",
|
"scroll",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const smooth = (a: number, b: number, x: number) => {
|
|
||||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
|
||||||
return t * t * (3 - 2 * t);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
|
* O navegador tem a API do WebGL2? É só uma leitura: abrir um contexto de teste aqui, na hidratação, custava uma tarefa
|
||||||
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
|
* de 150 ms (500 ms por software). Se o WebGL2 abre de verdade, e se ele desenha na GPU, quem diz é a própria cena,
|
||||||
* no contexto que ela abre para desenhar (HeroFieldScene.createRenderer); o veredito "por software" fica anotado na
|
* no contexto que ela abre para desenhar (createRenderer em BlocosScene); o veredito "por software" fica anotado na
|
||||||
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
* sessão (isKnownSoftwareWebGL), e as páginas seguintes já nascem estáticas.
|
||||||
*/
|
*/
|
||||||
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
const hasWebGL2 = () => typeof window.WebGL2RenderingContext !== "undefined";
|
||||||
@@ -91,21 +101,86 @@ function whenIdle(run: () => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, a geometria (pré-calculada
|
* Carrega a cena em passos separados, cada um na própria tarefa: o pacote do Three.js, os ativos (bloco, estados e
|
||||||
* num intervalo ocioso) e só então o Canvas. Devolve `null` se a tela foi desmontada no meio do caminho.
|
* matcaps, baixados num intervalo ocioso) e só então o canvas. Devolve `null` se a tela foi desmontada no meio do
|
||||||
|
* caminho.
|
||||||
*/
|
*/
|
||||||
async function loadScene(isAlive: () => boolean) {
|
async function loadScene(isAlive: () => boolean) {
|
||||||
const scene = await import("./HeroFieldScene");
|
const scene = await import("./BlocosScene");
|
||||||
await nextIdle();
|
await nextIdle();
|
||||||
if (!isAlive()) return null;
|
if (!isAlive()) return null;
|
||||||
scene.prepareField();
|
await scene.prepareBlocos();
|
||||||
await nextIdle();
|
await nextIdle();
|
||||||
return isAlive() ? scene.default : null;
|
return isAlive() ? scene.default : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liga a rolagem do hero às variáveis CSS --hero-p (0 no topo, 1 no fim do mergulho) e --hero-open (0 em
|
* Mede, em relação ao palco que a cena cobre (o pai do elemento raiz, onde ele fica com `inset: 0`), a peça do hero
|
||||||
* repouso, 1 quando a máscara do globals.css já se abriu para o mergulho), e marca o hero com data-dive, que
|
* (o pôster, `.hero-piece`) e a área útil do mergulho: 6% a 94% da largura, do cabeçalho (`--site-header-height`) mais
|
||||||
|
* 24 px até 70% da altura, onde começam as legendas e a faixa escura delas. Mede na montagem, quando a entrada da peça
|
||||||
|
* acaba (`.hero-in` anima o deslocamento) e a cada mudança de tamanho do hero, um quadro depois. Sem a peça, `null`.
|
||||||
|
* Diz também o tamanho do palco. Devolve a função que desfaz tudo.
|
||||||
|
*/
|
||||||
|
function trackPiece(
|
||||||
|
root: HTMLElement,
|
||||||
|
section: HTMLElement,
|
||||||
|
onChange: (
|
||||||
|
piece: Rect | null,
|
||||||
|
stage: Rect | null,
|
||||||
|
size: { width: number; height: number },
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
|
let frame = 0;
|
||||||
|
const measure = () => {
|
||||||
|
frame = 0;
|
||||||
|
const piece = section.querySelector<HTMLElement>(".hero-piece");
|
||||||
|
const stage = root.parentElement;
|
||||||
|
if (!stage) return onChange(null, null, { width: 0, height: 0 });
|
||||||
|
const origin = stage.getBoundingClientRect();
|
||||||
|
const header =
|
||||||
|
Number.parseFloat(
|
||||||
|
getComputedStyle(stage).getPropertyValue("--site-header-height"),
|
||||||
|
) || 0;
|
||||||
|
const top = header + 24;
|
||||||
|
const area = {
|
||||||
|
x: origin.width * 0.06,
|
||||||
|
y: top,
|
||||||
|
width: origin.width * 0.88,
|
||||||
|
height: Math.max(0, origin.height * 0.7 - top),
|
||||||
|
};
|
||||||
|
const size = { width: origin.width, height: origin.height };
|
||||||
|
if (!piece) return onChange(null, area, size);
|
||||||
|
const box = piece.getBoundingClientRect();
|
||||||
|
onChange(
|
||||||
|
{
|
||||||
|
x: box.left - origin.left,
|
||||||
|
y: box.top - origin.top,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
},
|
||||||
|
area,
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const queue = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(measure);
|
||||||
|
};
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(queue);
|
||||||
|
observer?.observe(section);
|
||||||
|
if (!observer) window.addEventListener("resize", queue);
|
||||||
|
section.addEventListener("animationend", queue);
|
||||||
|
measure();
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
observer?.disconnect();
|
||||||
|
window.removeEventListener("resize", queue);
|
||||||
|
section.removeEventListener("animationend", queue);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liga a rolagem do hero à variável CSS --hero-p (0 no topo, 1 no fim do mergulho) e marca o hero com data-dive, que
|
||||||
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
|
* liga o bloco de rolagem do mergulho no CSS. Devolve a função que desfaz tudo.
|
||||||
*/
|
*/
|
||||||
function trackDive(
|
function trackDive(
|
||||||
@@ -129,8 +204,6 @@ function trackDive(
|
|||||||
if (p === progress.current) return;
|
if (p === progress.current) return;
|
||||||
progress.current = p;
|
progress.current = p;
|
||||||
section.style.setProperty("--hero-p", p.toFixed(4));
|
section.style.setProperty("--hero-p", p.toFixed(4));
|
||||||
// a máscara só se abre quando o texto e a coluna da direita já quase saíram (o conteúdo some até p ≈ 0,24)
|
|
||||||
section.style.setProperty("--hero-open", smooth(0.16, 0.3, p).toFixed(4));
|
|
||||||
onDone(p >= 0.999);
|
onDone(p >= 0.999);
|
||||||
};
|
};
|
||||||
const queue = () => {
|
const queue = () => {
|
||||||
@@ -145,7 +218,6 @@ function trackDive(
|
|||||||
window.removeEventListener("resize", queue);
|
window.removeEventListener("resize", queue);
|
||||||
delete section.dataset.dive;
|
delete section.dataset.dive;
|
||||||
section.style.removeProperty("--hero-p");
|
section.style.removeProperty("--hero-p");
|
||||||
section.style.removeProperty("--hero-open");
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,43 +240,71 @@ function trackVisibility(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mounted = {
|
||||||
|
Scene: ComponentType<BlocosSceneProps>;
|
||||||
|
tier: Exclude<SceneTier, "static">;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fundo ambiente do hero e dono do mergulho. Só um aparelho que aguenta a cena (WebGL2 na GPU, não por software,
|
* A cena 3D do hero (os blocos da marca) e dono do mergulho. O pôster (`.hero-piece`, renderizado no Blender) é o
|
||||||
* sem toque, com mais de 4 núcleos e mais de 4 GB, sem "economizar dados" e sem movimento reduzido) recebe qualquer coisa daqui: nos
|
* primeiro quadro em todo aparelho; daqui só sai algo onde `sceneTier()` não é `static` e o WebGL2 existe, sem desenho
|
||||||
* outros o hero é só o palco de 100svh com o fundo estático leve do CSS (.hero-glow), sem ouvinte de rolagem
|
* por software nem cena lenta já descobertos nesta sessão. Fora disso não há ouvinte de rolagem nem download do
|
||||||
* e sem baixar o Three.js. No aparelho capaz, a cena WebGL entra na primeira interação ou quando a página fica
|
* Three.js. A cena entra na primeira interação ou quando a página fica ociosa com folga, em passos separados (pacote,
|
||||||
* ociosa com folga, em passos separados (pacote, geometria, Canvas); a rolagem do hero conduz o mergulho
|
* ativos, canvas), por cima do pôster, que some quando ela fica pronta (`data-ready`, no CSS):
|
||||||
* (trackDive). O desenho por software só se descobre no contexto que a cena abre (nunca num contexto de teste na
|
* - `full` (computador): o canvas cobre o palco; a marca descansa sobre o pôster (`placementRef`) e a rolagem do
|
||||||
* hidratação): ela avisa por `onError` e o hero volta ao palco estático. Sem legendas sobre o 3D: o espaço da direita
|
* hero conduz o mergulho (trackDive), em que a peça vai ao centro e passa por tela, integrações e dados junto das
|
||||||
* é das molduras.
|
* legendas.
|
||||||
|
* - `lite` (celular capaz): sem mergulho; o canvas ocupa só a caixa da peça e troca de estado sozinho.
|
||||||
|
* Com a cena pronta, uma legenda abaixo da caixa da peça diz o que ela está montando (`legendaDe`, avisada pela cena
|
||||||
|
* em `onEstado`): nenhuma na marca nem fora da caixa, no mergulho.
|
||||||
|
* O desenho por software e a cena lenta só se descobrem no contexto que a cena abre (nunca num contexto de teste na
|
||||||
|
* hidratação): ela avisa por `onError` e o hero volta ao pôster.
|
||||||
*/
|
*/
|
||||||
export default function HeroField() {
|
export default function HeroField() {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const progressRef = useRef(0);
|
const progressRef = useRef(0);
|
||||||
|
const placementRef = useRef<Rect | null>(null);
|
||||||
|
// a área útil do mergulho (full): entre o cabeçalho e as legendas
|
||||||
|
const stageRef = useRef<Rect | null>(null);
|
||||||
const giveUp = useRef(() => {});
|
const giveUp = useRef(() => {});
|
||||||
const [Scene, setScene] = useState<ComponentType<HeroFieldSceneProps> | null>(
|
const [mounted, setMounted] = useState<Mounted | null>(null);
|
||||||
|
// no lite, a caixa da peça e do canvas (em estilo em linha); no full, a caixa dos estados: a legenda vai abaixo dela
|
||||||
|
const [box, setBox] = useState<Rect | null>(null);
|
||||||
|
// a última legenda (o texto fica enquanto ela some) e quantas vezes entrou (cada entrada digita de novo)
|
||||||
|
const [legenda, setLegenda] = useState<{ texto: string; vez: number } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [legendaOn, setLegendaOn] = useState(false);
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
// fim do mergulho: a câmera parou diante dos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
|
// fim do mergulho: a cena parou nos dados e Projetos já vai cobrindo o palco; parar o desenho poupa a GPU
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLowPowerDevice()) return;
|
const tier = sceneTier();
|
||||||
const section = rootRef.current?.closest<HTMLElement>(".hero");
|
if (tier === "static") return;
|
||||||
if (!section) return;
|
const root = rootRef.current;
|
||||||
// sem WebGL2, ou com o desenho por software já descoberto nesta sessão: o hero estático de 100svh, já na hidratação,
|
const section = root?.closest<HTMLElement>(".hero");
|
||||||
// sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai do contexto
|
if (!root || !section) return;
|
||||||
// da cena, ocioso, sem uma tarefa longa na hidratação); sendo software, a cena avisa e o hero volta a 100svh
|
// sem WebGL2, ou com o desenho por software ou a cena lenta já descobertos nesta sessão: só o pôster, já na
|
||||||
if (!hasWebGL2() || isKnownSoftwareWebGL()) return;
|
// hidratação, sem o bloco de rolagem do mergulho. Num aparelho ainda não testado o mergulho liga já (o veredito sai
|
||||||
|
// do contexto da cena, ocioso, sem uma tarefa longa na hidratação); se ela desistir, avisa e o hero volta ao pôster
|
||||||
|
if (!hasWebGL2() || isKnownSoftwareWebGL() || isKnownSlowScene()) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const stopDive = window.matchMedia?.(DIVE_QUERY).matches
|
const stopDive =
|
||||||
? trackDive(section, progressRef, setDone)
|
tier === "full" && window.matchMedia?.(DIVE_QUERY).matches
|
||||||
: undefined;
|
? trackDive(section, progressRef, setDone)
|
||||||
|
: undefined;
|
||||||
const stopVisibility = trackVisibility(section, setVisible);
|
const stopVisibility = trackVisibility(section, setVisible);
|
||||||
// com o pacote que não carrega ou com o contexto perdido: de volta ao hero simples de 100svh
|
const stopPiece = trackPiece(root, section, (piece, stage, size) => {
|
||||||
|
// no full, a legenda vai sob a caixa dos estados (onde a cena põe tela, integrações e dados no topo)
|
||||||
|
setBox(piece && tier === "full" ? caixaDosEstados(piece, size) : piece);
|
||||||
|
if (tier === "lite") return;
|
||||||
|
placementRef.current = piece;
|
||||||
|
stageRef.current = stage;
|
||||||
|
});
|
||||||
|
// com o pacote que não carrega, sem WebGL ou com o contexto perdido: de volta ao pôster
|
||||||
giveUp.current = () => {
|
giveUp.current = () => {
|
||||||
stopDive?.();
|
stopDive?.();
|
||||||
if (alive) setFailed(true);
|
if (alive) setFailed(true);
|
||||||
@@ -213,7 +313,7 @@ export default function HeroField() {
|
|||||||
loadScene(() => alive)
|
loadScene(() => alive)
|
||||||
.then((component) => {
|
.then((component) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
if (component) setScene(() => component);
|
if (component) setMounted({ Scene: component, tier });
|
||||||
else giveUp.current();
|
else giveUp.current();
|
||||||
})
|
})
|
||||||
.catch(() => giveUp.current());
|
.catch(() => giveUp.current());
|
||||||
@@ -222,28 +322,76 @@ export default function HeroField() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
stopWaiting();
|
stopWaiting();
|
||||||
stopVisibility();
|
stopVisibility();
|
||||||
|
stopPiece();
|
||||||
stopDive?.();
|
stopDive?.();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReady = useCallback(() => setReady(true), []);
|
const handleReady = useCallback(() => setReady(true), []);
|
||||||
const handleError = useCallback(() => giveUp.current(), []);
|
const handleError = useCallback(() => giveUp.current(), []);
|
||||||
|
const handleEstado = useCallback((estado: number | null) => {
|
||||||
|
const texto = legendaDe(estado);
|
||||||
|
setLegendaOn(texto !== null);
|
||||||
|
if (texto)
|
||||||
|
setLegenda((anterior) => ({ texto, vez: (anterior?.vez ?? 0) + 1 }));
|
||||||
|
}, []);
|
||||||
|
const scene = mounted && !failed ? mounted : null;
|
||||||
|
const lite = scene?.tier === "lite";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="hero-field"
|
className="hero-field"
|
||||||
data-ready={ready && !failed ? "" : undefined}
|
data-tier={scene?.tier}
|
||||||
|
data-ready={ready && scene ? "" : undefined}
|
||||||
|
style={
|
||||||
|
lite && box
|
||||||
|
? {
|
||||||
|
left: box.x,
|
||||||
|
top: box.y,
|
||||||
|
width: box.width,
|
||||||
|
height: box.height,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{Scene && !failed && (
|
{scene && (
|
||||||
<Scene
|
<scene.Scene
|
||||||
progressRef={progressRef}
|
progressRef={progressRef}
|
||||||
|
placementRef={lite ? undefined : placementRef}
|
||||||
|
stageRef={lite ? undefined : stageRef}
|
||||||
|
tier={scene.tier}
|
||||||
visible={visible && !done}
|
visible={visible && !done}
|
||||||
onReady={handleReady}
|
onReady={handleReady}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
|
onEstado={handleEstado}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* abaixo de 1024 px a peça fica no canto, na linha do rótulo, e a legenda cairia sobre o título: só de 1024 em diante */}
|
||||||
|
{scene && legenda && box && (
|
||||||
|
<p
|
||||||
|
className="hero-legenda absolute flex max-lg:hidden items-center gap-sp-3 whitespace-nowrap font-display text-[13px] leading-[1.5] tracking-[0.02em] text-[rgba(244,242,237,.72)]"
|
||||||
|
data-shown={ready && legendaOn ? "" : undefined}
|
||||||
|
style={
|
||||||
|
lite
|
||||||
|
? { left: 0, top: box.height + LEGENDA_GAP }
|
||||||
|
: {
|
||||||
|
left: box.x + box.width * LEGENDA_RECUO,
|
||||||
|
top: box.y + box.height + LEGENDA_GAP,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 bg-[var(--slash)]" />
|
||||||
|
<span
|
||||||
|
key={legenda.vez}
|
||||||
|
className="ds-type"
|
||||||
|
style={{ "--n": legenda.texto.length } as CSSProperties}
|
||||||
|
>
|
||||||
|
{legenda.texto}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { act, render } from "@testing-library/react";
|
|
||||||
import HeroFieldScene from "./HeroFieldScene";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caminho-base de mountField que HeroFieldScene.test.tsx não exerce: o erro que vem ANTES de as peças existirem
|
|
||||||
* (a nuvem de pontos ou a câmera não se criam). O renderizador já abriu o contexto WebGL, então precisa ser solto
|
|
||||||
* (`disposeRenderer`, o `else` do `catch`), o canvas sai da página e a página é avisada. Os outros caminhos de
|
|
||||||
* mountField (desmontada antes da 1ª tarefa, entre o renderizador e as peças, durante a compilação, shader que não
|
|
||||||
* compila, sucesso) estão em HeroFieldScene.test.tsx.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mockRenderers: MockRenderer[] = [];
|
|
||||||
let mockBuildThrows = false;
|
|
||||||
|
|
||||||
class MockRenderer {
|
|
||||||
domElement = document.createElement("canvas");
|
|
||||||
render = jest.fn();
|
|
||||||
compileAsync = jest.fn(() => Promise.resolve());
|
|
||||||
dispose = jest.fn();
|
|
||||||
forceContextLoss = jest.fn();
|
|
||||||
setSize = jest.fn();
|
|
||||||
setPixelRatio = jest.fn();
|
|
||||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
|
||||||
getContext = () => ({ getExtension: () => null });
|
|
||||||
getPixelRatio = () => 1;
|
|
||||||
constructor() {
|
|
||||||
mockRenderers.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock("three", () => {
|
|
||||||
const actual = jest.requireActual("three");
|
|
||||||
class FailingCamera extends actual.PerspectiveCamera {
|
|
||||||
constructor(...args: unknown[]) {
|
|
||||||
if (mockBuildThrows) throw new Error("sem memória para a cena");
|
|
||||||
super(...args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
PerspectiveCamera: FailingCamera,
|
|
||||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
mockRenderers.length = 0;
|
|
||||||
mockBuildThrows = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = () => ({
|
|
||||||
progressRef: { current: 0 },
|
|
||||||
visible: false,
|
|
||||||
onReady: jest.fn(),
|
|
||||||
onError: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildScene = () =>
|
|
||||||
act(async () => {
|
|
||||||
await jest.advanceTimersByTimeAsync(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mountField: erro antes de as peças existirem", () => {
|
|
||||||
it("path: the scene parts fail to build -> the renderer opened a moment ago is released, the canvas leaves and the page is told once", async () => {
|
|
||||||
mockBuildThrows = true;
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container } = render(<HeroFieldScene {...scene} />);
|
|
||||||
await buildScene();
|
|
||||||
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: nothing fails -> the same setup mounts, draws and is released only on unmount (the control for the case above)", async () => {
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container, unmount } = render(<HeroFieldScene {...scene} />);
|
|
||||||
await buildScene();
|
|
||||||
|
|
||||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].dispose).not.toHaveBeenCalled();
|
|
||||||
expect(container.querySelector("canvas")).not.toBeNull();
|
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,957 +0,0 @@
|
|||||||
import {
|
|
||||||
type BufferGeometry,
|
|
||||||
type Group,
|
|
||||||
type PerspectiveCamera,
|
|
||||||
type Plane,
|
|
||||||
Ray,
|
|
||||||
type ShaderMaterial,
|
|
||||||
type Vector3,
|
|
||||||
} from "three";
|
|
||||||
import {
|
|
||||||
createField,
|
|
||||||
type FieldEngine,
|
|
||||||
isSettling,
|
|
||||||
prepareField,
|
|
||||||
type Rig,
|
|
||||||
type Tick,
|
|
||||||
updatePointer,
|
|
||||||
updateRig,
|
|
||||||
} from "./HeroFieldScene";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Testes de caminhos-base do quadro da cena 3D (createField, sem React) e das contas que o compõem:
|
|
||||||
* - onReady no 2º quadro, amortecimento da rolagem (limite de dt, progresso ausente), entrada, escurecimento e abertura;
|
|
||||||
* - retrato x paisagem (posição do grupo e distância da câmera) e o foco da lente;
|
|
||||||
* - mouse ativo x parado x toque, raio que acerta ou não o plano, e a quantidade de pontos (denso x leve);
|
|
||||||
* - quando pedir o próximo quadro (entrada e rolagem assentando) e quando deixar para a batida de 30 por segundo (cursor);
|
|
||||||
* - perda do contexto WebGL, falha ao desenhar e limpeza.
|
|
||||||
* O WebGLRenderer é um dublê (o jsdom não tem WebGL); o `three` é o de verdade, com ShaderMaterial, BufferGeometry,
|
|
||||||
* PerspectiveCamera e Group registrando as instâncias criadas. O quadro é chamado direto por `renderAt`, com o relógio
|
|
||||||
* que o teste escolhe.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const mockMaterials: ShaderMaterial[] = [];
|
|
||||||
const mockGeometries: BufferGeometry[] = [];
|
|
||||||
const mockCameras: PerspectiveCamera[] = [];
|
|
||||||
const mockGroups: Group[] = [];
|
|
||||||
const mockCounts = { vectors: 0 };
|
|
||||||
const mockRenderers: MockRenderer[] = [];
|
|
||||||
let mockRenderThrows = false;
|
|
||||||
|
|
||||||
class MockRenderer {
|
|
||||||
domElement = document.createElement("canvas");
|
|
||||||
pixelRatio = 2;
|
|
||||||
render = jest.fn(() => {
|
|
||||||
if (mockRenderThrows) throw new Error("falha ao desenhar");
|
|
||||||
});
|
|
||||||
dispose = jest.fn();
|
|
||||||
forceContextLoss = jest.fn();
|
|
||||||
setSize = jest.fn();
|
|
||||||
setPixelRatio = jest.fn();
|
|
||||||
// um WebGL que não conta o nome do renderizador (sem WEBGL_debug_renderer_info): não é por software
|
|
||||||
getContext = () => ({ getExtension: () => null });
|
|
||||||
getPixelRatio = () => this.pixelRatio;
|
|
||||||
constructor() {
|
|
||||||
mockRenderers.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock("three", () => {
|
|
||||||
const actual = jest.requireActual("three");
|
|
||||||
class CapturingMaterial extends actual.ShaderMaterial {
|
|
||||||
constructor(parameters: unknown) {
|
|
||||||
super(parameters);
|
|
||||||
mockMaterials.push(this as unknown as ShaderMaterial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class CapturingGeometry extends actual.BufferGeometry {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
mockGeometries.push(this as unknown as BufferGeometry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class CapturingCamera extends actual.PerspectiveCamera {
|
|
||||||
constructor(...args: unknown[]) {
|
|
||||||
super(...args);
|
|
||||||
mockCameras.push(this as unknown as PerspectiveCamera);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class CapturingGroup extends actual.Group {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
mockGroups.push(this as unknown as Group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class CountingVector3 extends actual.Vector3 {
|
|
||||||
constructor(x?: number, y?: number, z?: number) {
|
|
||||||
super(x, y, z);
|
|
||||||
mockCounts.vectors += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
WebGLRenderer: jest.fn(() => new MockRenderer()),
|
|
||||||
ShaderMaterial: CapturingMaterial,
|
|
||||||
BufferGeometry: CapturingGeometry,
|
|
||||||
PerspectiveCamera: CapturingCamera,
|
|
||||||
Group: CapturingGroup,
|
|
||||||
Vector3: CountingVector3,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
type Setup = {
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
/** `null` simula uma ref sem valor (o código trata como 0). */
|
|
||||||
progress?: number | null;
|
|
||||||
visible?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
let engines: FieldEngine[] = [];
|
|
||||||
let frameRequests: jest.SpyInstance;
|
|
||||||
|
|
||||||
function setup({
|
|
||||||
width = 1200,
|
|
||||||
height = 800,
|
|
||||||
progress = 0,
|
|
||||||
visible = true,
|
|
||||||
}: Setup = {}) {
|
|
||||||
const host = document.createElement("div");
|
|
||||||
Object.defineProperty(host, "clientWidth", { value: width });
|
|
||||||
Object.defineProperty(host, "clientHeight", { value: height });
|
|
||||||
document.body.append(host);
|
|
||||||
const progressRef = {
|
|
||||||
current: progress === null ? undefined : progress,
|
|
||||||
} as { current: number };
|
|
||||||
const onReady = jest.fn();
|
|
||||||
const onError = jest.fn();
|
|
||||||
const engine = createField(host, { progressRef, onReady, onError });
|
|
||||||
engines.push(engine);
|
|
||||||
engine.setVisible(visible);
|
|
||||||
|
|
||||||
const renderer = mockRenderers[mockRenderers.length - 1];
|
|
||||||
const camera = mockCameras[mockCameras.length - 1];
|
|
||||||
const group = mockGroups[mockGroups.length - 1];
|
|
||||||
const uniforms = () => mockMaterials[mockMaterials.length - 1].uniforms;
|
|
||||||
let clock = 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Um quadro `deltaMs` depois do anterior; devolve quantos quadros ele pediu em seguida. O primeiro quadro de uma
|
|
||||||
* cena não tem anterior: o passo dele é 1/60 de segundo, seja qual for `deltaMs`.
|
|
||||||
*/
|
|
||||||
function frame(deltaMs = 1000 / 60) {
|
|
||||||
frameRequests.mockClear();
|
|
||||||
clock += deltaMs;
|
|
||||||
engine.renderAt(clock);
|
|
||||||
return frameRequests.mock.calls.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Roda `count` quadros e devolve quantas vezes o próximo quadro foi pedido no último. */
|
|
||||||
function settle(count: number, deltaMs = 50) {
|
|
||||||
let asked = 0;
|
|
||||||
for (let i = 0; i < count; i++) asked = frame(deltaMs);
|
|
||||||
return asked;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
engine,
|
|
||||||
renderer,
|
|
||||||
camera,
|
|
||||||
group,
|
|
||||||
progressRef,
|
|
||||||
onReady,
|
|
||||||
onError,
|
|
||||||
uniforms,
|
|
||||||
frame,
|
|
||||||
settle,
|
|
||||||
/** O relógio do quadro (ms), para os testes que pedem o tempo em segundos. */
|
|
||||||
now: () => clock,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalHardware = Object.getOwnPropertyDescriptor(
|
|
||||||
navigator,
|
|
||||||
"hardwareConcurrency",
|
|
||||||
);
|
|
||||||
|
|
||||||
function setCores(value: number | undefined) {
|
|
||||||
Object.defineProperty(navigator, "hardwareConcurrency", {
|
|
||||||
configurable: true,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWindowSize(width: number, height = 800) {
|
|
||||||
Object.defineProperty(window, "innerWidth", {
|
|
||||||
configurable: true,
|
|
||||||
value: width,
|
|
||||||
});
|
|
||||||
Object.defineProperty(window, "innerHeight", {
|
|
||||||
configurable: true,
|
|
||||||
value: height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointerMove(type: string, x = 600, y = 400) {
|
|
||||||
const event = new MouseEvent("pointermove", { clientX: x, clientY: y });
|
|
||||||
Object.defineProperty(event, "pointerType", { value: type });
|
|
||||||
window.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockCounts.vectors = 0;
|
|
||||||
mockMaterials.length = 0;
|
|
||||||
mockGeometries.length = 0;
|
|
||||||
mockCameras.length = 0;
|
|
||||||
mockGroups.length = 0;
|
|
||||||
mockRenderers.length = 0;
|
|
||||||
mockRenderThrows = false;
|
|
||||||
engines = [];
|
|
||||||
setCores(8);
|
|
||||||
setWindowSize(1200);
|
|
||||||
// o relógio do cursor: performance.now() em segundos é o que `lastMove` guarda
|
|
||||||
jest.spyOn(performance, "now").mockReturnValue(1000);
|
|
||||||
frameRequests = jest
|
|
||||||
.spyOn(window, "requestAnimationFrame")
|
|
||||||
.mockImplementation(() => 1 as unknown as number);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const engine of engines) engine.dispose();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
document.body.innerHTML = "";
|
|
||||||
if (originalHardware)
|
|
||||||
Object.defineProperty(navigator, "hardwareConcurrency", originalHardware);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: quadro — começo e rolagem", () => {
|
|
||||||
it("path: onReady fires on the second frame, once", () => {
|
|
||||||
const scene = setup();
|
|
||||||
|
|
||||||
scene.frame();
|
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
|
||||||
scene.frame();
|
|
||||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
|
||||||
scene.frame();
|
|
||||||
scene.frame();
|
|
||||||
|
|
||||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the scroll progress is damped toward the target (and a long frame cannot overshoot)", () => {
|
|
||||||
const scene = setup({ progress: 1 });
|
|
||||||
|
|
||||||
scene.frame(); // o primeiro quadro: passo de 1/60 de segundo
|
|
||||||
const afterFirst = 1 - Math.exp(-(1 / 60) * 5);
|
|
||||||
scene.frame(5000); // delta enorme: o passo é limitado a 1/20 de segundo
|
|
||||||
|
|
||||||
const expected = 1 - (1 - afterFirst) * Math.exp(-(1 / 20) * 5);
|
|
||||||
expect(scene.uniforms().uScroll.value).toBeCloseTo(expected, 9);
|
|
||||||
expect(scene.uniforms().uScroll.value).toBeLessThan(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: no progress available (undefined) -> treated as the top of the page", () => {
|
|
||||||
const scene = setup({ progress: null });
|
|
||||||
|
|
||||||
scene.settle(5);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uScroll.value).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the intro advances every frame and stops at 1", () => {
|
|
||||||
const scene = setup();
|
|
||||||
|
|
||||||
scene.frame(50); // o primeiro quadro: 1/60 de segundo
|
|
||||||
expect(scene.uniforms().uIntro.value).toBeCloseTo(1 / 60 / 2.6, 9);
|
|
||||||
scene.frame(50);
|
|
||||||
expect(scene.uniforms().uIntro.value).toBeCloseTo((1 / 60 + 0.05) / 2.6, 9);
|
|
||||||
|
|
||||||
scene.settle(80);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uIntro.value).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the scene only dims, and only at the very end of the dive (it never ends in the dark)", () => {
|
|
||||||
const scene = setup({ progress: 0 });
|
|
||||||
scene.settle(10);
|
|
||||||
expect(scene.uniforms().uFade.value).toBe(1);
|
|
||||||
|
|
||||||
scene.progressRef.current = 0.9;
|
|
||||||
scene.settle(120);
|
|
||||||
expect(scene.uniforms().uFade.value).toBe(1);
|
|
||||||
|
|
||||||
scene.progressRef.current = 1;
|
|
||||||
scene.settle(120);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uFade.value).toBeCloseTo(0.6, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: layers spread apart only while they face the viewer", () => {
|
|
||||||
const scene = setup({ progress: 0 });
|
|
||||||
scene.settle(5);
|
|
||||||
expect(scene.uniforms().uSpread.value).toBeCloseTo(1.05, 6);
|
|
||||||
|
|
||||||
scene.progressRef.current = 0.5;
|
|
||||||
scene.settle(150);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uSpread.value).toBeGreaterThan(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the clock reaches the shader in seconds since the scene started", () => {
|
|
||||||
const scene = setup();
|
|
||||||
scene.frame(0);
|
|
||||||
scene.frame(2000);
|
|
||||||
expect(scene.uniforms().uTime.value).toBeCloseTo(2, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: enquadramento e foco", () => {
|
|
||||||
it("path: landscape window -> the group sits just below center and the camera rests at 9", () => {
|
|
||||||
const scene = setup({ width: 1200, height: 800 });
|
|
||||||
|
|
||||||
scene.frame();
|
|
||||||
|
|
||||||
expect(scene.group.position.y).toBeCloseTo(-0.5, 9);
|
|
||||||
expect(scene.camera.position.z).toBeCloseTo(9, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: portrait window -> the group sits lower and the camera backs off to keep the layer in frame", () => {
|
|
||||||
const scene = setup({ width: 400, height: 800 });
|
|
||||||
|
|
||||||
scene.frame();
|
|
||||||
|
|
||||||
expect(scene.group.position.y).toBeLessThan(-0.5);
|
|
||||||
expect(scene.camera.position.z).toBeGreaterThan(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the renderer and the camera follow the size of the host", () => {
|
|
||||||
const scene = setup({ width: 900, height: 300 });
|
|
||||||
|
|
||||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(900, 300, false);
|
|
||||||
expect(scene.camera.aspect).toBeCloseTo(3, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: a host without size yet (0 x 0) -> never a zero-size canvas or a NaN aspect", () => {
|
|
||||||
const scene = setup({ width: 0, height: 0 });
|
|
||||||
|
|
||||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(1, 1, false);
|
|
||||||
expect(Number.isFinite(scene.camera.aspect)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: end of the dive -> the group has settled back to the center and the camera stopped in front of the data layer", () => {
|
|
||||||
const scene = setup({ progress: 1 });
|
|
||||||
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
const spread = scene.uniforms().uSpread.value;
|
|
||||||
expect(scene.group.position.y).toBeCloseTo(0, 6);
|
|
||||||
// passou pelas duas primeiras camadas e parou diante da última (a dos dados), a menos de 6 unidades dela
|
|
||||||
expect(scene.camera.position.z).toBeLessThan(0);
|
|
||||||
expect(scene.camera.position.z).toBeGreaterThan(-spread);
|
|
||||||
expect(scene.camera.position.z + spread).toBeLessThan(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: camera in front of the layers -> the lens focuses on the next layer ahead", () => {
|
|
||||||
const scene = setup({ progress: 0 });
|
|
||||||
|
|
||||||
scene.frame();
|
|
||||||
|
|
||||||
// câmera em 9 e primeira camada em 1,05: foco a 9 - 1,05
|
|
||||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(9 - 1.05, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: camera parked in front of the data layer -> the lens focuses on it, never closer than 1.5", () => {
|
|
||||||
const scene = setup({ progress: 1 });
|
|
||||||
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
const spread = scene.uniforms().uSpread.value;
|
|
||||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(
|
|
||||||
scene.camera.position.z + spread,
|
|
||||||
6,
|
|
||||||
);
|
|
||||||
expect(scene.uniforms().uFocus.value).toBeGreaterThanOrEqual(1.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: camera between the first layer and the middle one -> the lens focuses on the middle layer, at the origin", () => {
|
|
||||||
const scene = setup({ progress: 0.55 });
|
|
||||||
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
const spread = scene.uniforms().uSpread.value;
|
|
||||||
const z = scene.camera.position.z;
|
|
||||||
// já passou da primeira camada (z abaixo de spread) e ainda não chegou à do meio (z acima de 0,2)
|
|
||||||
expect(z).toBeGreaterThan(0.2);
|
|
||||||
expect(z).toBeLessThan(spread);
|
|
||||||
expect(scene.uniforms().uFocus.value).toBeCloseTo(Math.max(1.5, z), 6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: mouse", () => {
|
|
||||||
it("path: pointer type other than mouse (touch, pen) -> ignored, the cursor stays on its own path", () => {
|
|
||||||
const scene = setup();
|
|
||||||
pointerMove("touch");
|
|
||||||
pointerMove("pen");
|
|
||||||
|
|
||||||
// sem mouse, o cursor fantasma passa a valer 0,4 (não 0,8)
|
|
||||||
scene.settle(120);
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: a mouse moved a moment ago -> the pointer drives the cursor strength to 0.8", () => {
|
|
||||||
const scene = setup();
|
|
||||||
scene.frame(); // o relógio da cena começa
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
for (let i = 0; i < 40; i++) scene.frame(50); // 2 s: o mouse ainda conta
|
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.8, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the mouse stopped long ago (over 2.5 s) -> back to the ghost cursor", () => {
|
|
||||||
const scene = setup();
|
|
||||||
scene.frame(0);
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
for (let i = 0; i < 120; i++) scene.frame(50); // 6 s depois
|
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: a mouse event at timestamp 0 is not counted as activity", () => {
|
|
||||||
const scene = setup();
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(0);
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
for (let i = 0; i < 30; i++) scene.frame(50);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0.4, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: cursor strength fades to 0 once the dive is under way", () => {
|
|
||||||
const scene = setup({ progress: 1 });
|
|
||||||
scene.frame();
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
for (let i = 0; i < 200; i++) scene.frame(5);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uMouseAmt.value).toBeCloseTo(0, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: active mouse and the ray hits the layer plane -> the shader cursor follows the hit point", () => {
|
|
||||||
const scene = setup();
|
|
||||||
jest
|
|
||||||
.spyOn(Ray.prototype, "intersectPlane")
|
|
||||||
.mockImplementation(((_plane: Plane, target: Vector3) =>
|
|
||||||
target.set(1, 0, 2)) as never);
|
|
||||||
// o grupo real está girado e abaixado: aqui o ponto do raio já vale como ponto do grupo
|
|
||||||
scene.group.worldToLocal = ((point: Vector3) => point) as never;
|
|
||||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
|
||||||
const before = scene.uniforms().uMouse.value.clone();
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
scene.frame(50);
|
|
||||||
|
|
||||||
const factor = 1 - Math.exp(-(1 / 20) * 10);
|
|
||||||
expect(scene.uniforms().uMouse.value.x).toBeCloseTo(
|
|
||||||
before.x + (1 - before.x) * factor,
|
|
||||||
6,
|
|
||||||
);
|
|
||||||
expect(scene.uniforms().uMouse.value.y).toBeCloseTo(
|
|
||||||
before.y + (2 - before.y) * factor,
|
|
||||||
6,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: active mouse but the ray misses the plane -> the shader cursor stays where it was", () => {
|
|
||||||
const scene = setup();
|
|
||||||
jest.spyOn(Ray.prototype, "intersectPlane").mockReturnValue(null as never);
|
|
||||||
scene.frame(16); // um mouse no instante 0 da cena não conta como atividade
|
|
||||||
const before = scene.uniforms().uMouse.value.clone();
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
scene.frame(50);
|
|
||||||
|
|
||||||
expect(scene.uniforms().uMouse.value.x).toBe(before.x);
|
|
||||||
expect(scene.uniforms().uMouse.value.y).toBe(before.y);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: idle mouse -> the ghost cursor wanders on its own", () => {
|
|
||||||
const scene = setup();
|
|
||||||
|
|
||||||
scene.frame(0);
|
|
||||||
scene.frame(9000);
|
|
||||||
|
|
||||||
const { x, y } = scene.uniforms().uMouse.value;
|
|
||||||
expect(Math.hypot(x, y)).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: quando pedir o próximo quadro", () => {
|
|
||||||
it("path: the intro is still running -> a frame is requested even when the hero is not visible", () => {
|
|
||||||
const scene = setup({ visible: false });
|
|
||||||
|
|
||||||
expect(scene.frame()).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: everything settled and the hero is visible but nothing interacts -> no extra frame (the pulse does it)", () => {
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
expect(scene.frame(50)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: everything settled and the hero is not visible -> no frame", () => {
|
|
||||||
const scene = setup({ visible: false });
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
expect(scene.frame(50)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: settled, visible, and the mouse is moving -> the frame draws but asks for no other (the pulse brings the next at 30 Hz)", () => {
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
scene.settle(200);
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
const drawn = scene.renderer.render.mock.calls.length;
|
|
||||||
|
|
||||||
expect(scene.frame(50)).toBe(0);
|
|
||||||
|
|
||||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: settled, mouse active, but the hero is not visible -> no frame (cursor only counts on screen)", () => {
|
|
||||||
const scene = setup({ visible: false });
|
|
||||||
scene.settle(200);
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
expect(scene.frame(50)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the scroll target moved after the hero left the screen -> still finishes settling", () => {
|
|
||||||
const scene = setup({ visible: false });
|
|
||||||
scene.settle(200);
|
|
||||||
scene.progressRef.current = 0.5;
|
|
||||||
|
|
||||||
expect(scene.frame()).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: becoming visible later -> the pulse starts and asks for a frame right away", () => {
|
|
||||||
const scene = setup({ visible: false });
|
|
||||||
scene.settle(200);
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
expect(scene.frame(50)).toBe(0);
|
|
||||||
frameRequests.mockClear();
|
|
||||||
|
|
||||||
scene.engine.setVisible(true);
|
|
||||||
|
|
||||||
expect(frameRequests).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: several requests before a frame become ONE animation frame", () => {
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
scene.settle(200);
|
|
||||||
frameRequests.mockClear();
|
|
||||||
// ligar a batida pede um quadro; religá-la sem que ele tenha vindo não pede outro
|
|
||||||
scene.engine.setVisible(false);
|
|
||||||
scene.engine.setVisible(true);
|
|
||||||
scene.engine.setVisible(false);
|
|
||||||
scene.engine.setVisible(true);
|
|
||||||
expect(frameRequests.mock.calls.length).toBeLessThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isSettling / updatePointer / updateRig (contas puras)", () => {
|
|
||||||
function rig(): Rig {
|
|
||||||
const scene = setup();
|
|
||||||
scene.frame();
|
|
||||||
const material = mockMaterials[mockMaterials.length - 1];
|
|
||||||
return {
|
|
||||||
group: scene.group,
|
|
||||||
camera: scene.camera,
|
|
||||||
uniforms: material.uniforms,
|
|
||||||
layout: {
|
|
||||||
wide: true,
|
|
||||||
tanHalf: Math.tan((35 / 2) * (Math.PI / 180)),
|
|
||||||
spreadMax: 5.5,
|
|
||||||
rest: 9,
|
|
||||||
scale: 1000,
|
|
||||||
camera: [
|
|
||||||
[0, 9],
|
|
||||||
[1, -1],
|
|
||||||
],
|
|
||||||
} as unknown as Rig["layout"],
|
|
||||||
state: {
|
|
||||||
progress: 0,
|
|
||||||
pointer: { set() {}, x: 0, y: 0 } as never,
|
|
||||||
lastMove: -10,
|
|
||||||
mouse: { lerp() {}, x: 0, y: 0, copy() {} } as never,
|
|
||||||
tilt: { lerp() {}, x: 0, y: 0 } as never,
|
|
||||||
frames: 10,
|
|
||||||
},
|
|
||||||
helpers: {} as Rig["helpers"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tick = (overrides: Partial<Tick> = {}): Tick => ({
|
|
||||||
p: 0,
|
|
||||||
face: 0,
|
|
||||||
time: 1,
|
|
||||||
dt: 1 / 60,
|
|
||||||
cursorActive: false,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isSettling: the intro still running keeps asking, the settled scene does not", () => {
|
|
||||||
const r = rig();
|
|
||||||
r.uniforms.uIntro.value = 1;
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
|
||||||
r.uniforms.uIntro.value = 0.5;
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isSettling: the scroll target far from the shown progress keeps asking, the cursor does not (the pulse does that)", () => {
|
|
||||||
const r = rig();
|
|
||||||
r.uniforms.uIntro.value = 1;
|
|
||||||
r.uniforms.uMouseAmt.value = 0;
|
|
||||||
r.state.frames = 1;
|
|
||||||
r.state.lastMove = 1;
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0)).toBe(false);
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0.5)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updatePointer: returns the strength it is heading for (0.8 with the mouse, 0.4 without, none in the dive)", () => {
|
|
||||||
const r = rig();
|
|
||||||
r.helpers = {
|
|
||||||
target: { set: () => ({}), x: 0, y: 0 },
|
|
||||||
ray: { setFromCamera() {}, ray: { intersectPlane: () => null } },
|
|
||||||
plane: { set: () => ({ applyMatrix4() {} }) },
|
|
||||||
up: {},
|
|
||||||
hit: {},
|
|
||||||
} as never;
|
|
||||||
expect(updatePointer(r, tick({ cursorActive: true }))).toBeCloseTo(0.8, 9);
|
|
||||||
expect(updatePointer(r, tick({ cursorActive: false }))).toBeCloseTo(0.4, 9);
|
|
||||||
expect(updatePointer(r, tick({ p: 1 }))).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateRig: the rotation goes from three quarters (resting) to facing the viewer (diving)", () => {
|
|
||||||
const r = rig();
|
|
||||||
updateRig(r, tick({ face: 0 }));
|
|
||||||
const resting = r.group.rotation.x;
|
|
||||||
updateRig(r, tick({ face: 1 }));
|
|
||||||
expect(resting).toBeCloseTo(0.42, 9);
|
|
||||||
expect(r.group.rotation.x).toBeCloseTo(Math.PI / 2, 9);
|
|
||||||
// de frente, o balanço lateral some
|
|
||||||
expect(r.group.rotation.y).toBeCloseTo(0, 9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isSettling: the scroll damping counts as settling only beyond 0.0004 of the target", () => {
|
|
||||||
const r = rig();
|
|
||||||
r.uniforms.uIntro.value = 1;
|
|
||||||
r.state.progress = 0.5;
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0.50039)).toBe(false);
|
|
||||||
expect(isSettling(r.uniforms, r.state, 0.5005)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateRig: the camera follows the choreography -> the first frame before it starts, a smoothed midpoint, the last frame after it ends", () => {
|
|
||||||
const r = rig();
|
|
||||||
|
|
||||||
updateRig(r, tick({ p: -0.5 }));
|
|
||||||
expect(r.camera.position.z).toBe(9);
|
|
||||||
|
|
||||||
updateRig(r, tick({ p: 0.5 }));
|
|
||||||
expect(r.camera.position.z).toBeCloseTo(4, 9);
|
|
||||||
|
|
||||||
updateRig(r, tick({ p: 2 }));
|
|
||||||
expect(r.camera.position.z).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: geometria pré-calculada e quadros sem alocação", () => {
|
|
||||||
it("path: prepareField builds the points before the scene mounts, and mounting reuses them", () => {
|
|
||||||
prepareField();
|
|
||||||
expect(mockGeometries).toHaveLength(1);
|
|
||||||
const prepared = mockGeometries[0];
|
|
||||||
|
|
||||||
prepareField(); // de novo, sem montar: nada é refeito
|
|
||||||
expect(mockGeometries).toHaveLength(1);
|
|
||||||
|
|
||||||
setup();
|
|
||||||
expect(mockGeometries).toHaveLength(1);
|
|
||||||
expect(mockGeometries[0]).toBe(prepared);
|
|
||||||
expect(prepared.getAttribute("position").count).toBeGreaterThan(1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the preparation is used once; a second scene builds its own points", () => {
|
|
||||||
prepareField();
|
|
||||||
setup();
|
|
||||||
expect(mockGeometries).toHaveLength(1);
|
|
||||||
|
|
||||||
setup();
|
|
||||||
expect(mockGeometries).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: no vector is created per frame beyond the few the frame itself needs, not even with the mouse over the layer", () => {
|
|
||||||
const scene = setup();
|
|
||||||
scene.frame(0);
|
|
||||||
(performance.now as jest.Mock).mockReturnValue(scene.now());
|
|
||||||
pointerMove("mouse");
|
|
||||||
scene.frame();
|
|
||||||
const before = mockCounts.vectors;
|
|
||||||
|
|
||||||
for (let i = 0; i < 60; i++) scene.frame();
|
|
||||||
|
|
||||||
// nenhum Vector3 novo: os auxiliares são criados uma vez só
|
|
||||||
expect(mockCounts.vectors).toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: densidade, contexto WebGL, erros e limpeza", () => {
|
|
||||||
const pointCount = () => {
|
|
||||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
|
||||||
return geometry.getAttribute("position").count;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("path: wide window and 4+ cores -> the dense field", () => {
|
|
||||||
setWindowSize(1200);
|
|
||||||
setCores(8);
|
|
||||||
setup();
|
|
||||||
const dense = pointCount();
|
|
||||||
|
|
||||||
mockGeometries.length = 0;
|
|
||||||
setWindowSize(600);
|
|
||||||
setup();
|
|
||||||
|
|
||||||
expect(dense).toBeGreaterThan(pointCount());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: wide window but 2 cores -> the light field", () => {
|
|
||||||
setWindowSize(1200);
|
|
||||||
setCores(2);
|
|
||||||
setup();
|
|
||||||
const light = pointCount();
|
|
||||||
|
|
||||||
mockGeometries.length = 0;
|
|
||||||
setCores(8);
|
|
||||||
setup();
|
|
||||||
|
|
||||||
expect(pointCount()).toBeGreaterThan(light);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the browser does not report cores -> counts as capable (4): dense, not light", () => {
|
|
||||||
setWindowSize(1200);
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
for (const [label, cores] of [
|
|
||||||
["dense", 8],
|
|
||||||
["light", 2],
|
|
||||||
["unknown", undefined],
|
|
||||||
] as const) {
|
|
||||||
mockGeometries.length = 0;
|
|
||||||
setCores(cores);
|
|
||||||
setup();
|
|
||||||
counts[label] = pointCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(Math.abs(counts.unknown - counts.dense)).toBeLessThan(
|
|
||||||
Math.abs(counts.unknown - counts.light),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the WebGL context is lost -> the default recovery is cancelled and the page is told", () => {
|
|
||||||
const scene = setup();
|
|
||||||
const event = new Event("webglcontextlost", { cancelable: true });
|
|
||||||
|
|
||||||
scene.renderer.domElement.dispatchEvent(event);
|
|
||||||
|
|
||||||
expect(event.defaultPrevented).toBe(true);
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: drawing throws -> the page is told and the scene does not crash the frame", () => {
|
|
||||||
const scene = setup();
|
|
||||||
mockRenderThrows = true;
|
|
||||||
|
|
||||||
expect(() => scene.frame()).not.toThrow();
|
|
||||||
|
|
||||||
expect(scene.onError).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: dispose -> geometry, material and renderer are released, the canvas leaves, and the listeners go away", () => {
|
|
||||||
const scene = setup();
|
|
||||||
const geometry = mockGeometries[mockGeometries.length - 1];
|
|
||||||
const material = mockMaterials[mockMaterials.length - 1];
|
|
||||||
const disposeGeometry = jest.spyOn(geometry, "dispose");
|
|
||||||
const disposeMaterial = jest.spyOn(material, "dispose");
|
|
||||||
const event = new Event("webglcontextlost", { cancelable: true });
|
|
||||||
|
|
||||||
scene.engine.dispose();
|
|
||||||
scene.renderer.domElement.dispatchEvent(event);
|
|
||||||
pointerMove("mouse");
|
|
||||||
|
|
||||||
expect(disposeGeometry).toHaveBeenCalledTimes(1);
|
|
||||||
expect(disposeMaterial).toHaveBeenCalledTimes(1);
|
|
||||||
expect(scene.renderer.dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(scene.renderer.forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(scene.renderer.domElement.isConnected).toBe(false);
|
|
||||||
expect(event.defaultPrevented).toBe(false);
|
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: a frame after dispose does nothing", () => {
|
|
||||||
const scene = setup();
|
|
||||||
scene.engine.dispose();
|
|
||||||
scene.renderer.render.mockClear();
|
|
||||||
|
|
||||||
scene.frame();
|
|
||||||
|
|
||||||
expect(scene.renderer.render).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: no ResizeObserver (old browser) -> the window resize event resizes the scene", () => {
|
|
||||||
const original = globalThis.ResizeObserver;
|
|
||||||
// @ts-expect-error o jsdom não tem ResizeObserver; o teste garante a ausência
|
|
||||||
globalThis.ResizeObserver = undefined;
|
|
||||||
try {
|
|
||||||
const scene = setup({ width: 600, height: 400 });
|
|
||||||
scene.renderer.setSize.mockClear();
|
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
expect(scene.renderer.setSize).toHaveBeenCalledWith(600, 400, false);
|
|
||||||
} finally {
|
|
||||||
globalThis.ResizeObserver = original;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: o cursor sozinho não passa de ~30 quadros por segundo", () => {
|
|
||||||
/** Quantos quadros saem (renderer.render) em `seconds` de uma tela que entrega um quadro a cada `refreshMs`. */
|
|
||||||
function drawnOn(
|
|
||||||
scene: ReturnType<typeof setup>,
|
|
||||||
refreshMs: number,
|
|
||||||
seconds = 1,
|
|
||||||
) {
|
|
||||||
const before = scene.renderer.render.mock.calls.length;
|
|
||||||
const frames = Math.round((seconds * 1000) / refreshMs);
|
|
||||||
for (let i = 0; i < frames; i++) scene.frame(refreshMs);
|
|
||||||
return scene.renderer.render.mock.calls.length - before;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uma tela de `refreshMs` entre quadros, com tudo assentado e o cursor mexendo: o único relógio é a batida (o
|
|
||||||
* setInterval de 33 ms do FramePulse, aqui tocado à mão), e um quadro da tela só roda se alguém o pediu.
|
|
||||||
*/
|
|
||||||
function pulsedScreen(refreshMs: number) {
|
|
||||||
let beat = () => {};
|
|
||||||
jest.spyOn(window, "setInterval").mockImplementation(((
|
|
||||||
callback: () => void,
|
|
||||||
) => {
|
|
||||||
beat = callback;
|
|
||||||
return 7;
|
|
||||||
}) as never);
|
|
||||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
|
||||||
let pending: FrameRequestCallback | undefined;
|
|
||||||
frameRequests.mockImplementation(((callback: FrameRequestCallback) => {
|
|
||||||
pending = callback;
|
|
||||||
return 1;
|
|
||||||
}) as never);
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
let clock = 0;
|
|
||||||
let nextBeat = 33;
|
|
||||||
const run = (seconds: number) => {
|
|
||||||
const drawnBefore = scene.renderer.render.mock.calls.length;
|
|
||||||
frameRequests.mockClear();
|
|
||||||
const end = clock + seconds * 1000;
|
|
||||||
for (clock += refreshMs; clock <= end; clock += refreshMs) {
|
|
||||||
for (; nextBeat <= clock; nextBeat += 33) beat();
|
|
||||||
const frame = pending;
|
|
||||||
pending = undefined;
|
|
||||||
frame?.(1000 + clock);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
drawn: scene.renderer.render.mock.calls.length - drawnBefore,
|
|
||||||
requested: frameRequests.mock.calls.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
run(6); // a entrada corre a cada quadro da tela e assenta
|
|
||||||
pointerMove("mouse");
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[144, 1000 / 144],
|
|
||||||
[120, 1000 / 120],
|
|
||||||
[60, 1000 / 60],
|
|
||||||
])(
|
|
||||||
"path: a %i Hz screen with the cursor moving -> about 30 frames a second drawn and asked for, never a wake-up per screen frame",
|
|
||||||
(_hz, refreshMs) => {
|
|
||||||
const run = pulsedScreen(refreshMs);
|
|
||||||
|
|
||||||
const { drawn, requested } = run(2);
|
|
||||||
|
|
||||||
expect(drawn).toBeGreaterThanOrEqual(2 * 25);
|
|
||||||
expect(drawn).toBeLessThanOrEqual(2 * 31);
|
|
||||||
// quem pede é a batida: nenhum requestAnimationFrame para pular o desenho e pedir outro
|
|
||||||
expect(requested).toBeLessThanOrEqual(drawn + 1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("path: a frame the pulse asks for soon after another is drawn, not skipped (nothing throttles it any more)", () => {
|
|
||||||
const run = pulsedScreen(1000 / 144);
|
|
||||||
const drawn = run(0.5).drawn;
|
|
||||||
expect(drawn).toBeGreaterThanOrEqual(14);
|
|
||||||
// 27,8 ms (4 quadros de 144 Hz) entre duas batidas que se alinharam mal: o desenho sai do mesmo jeito
|
|
||||||
expect(run(0.1).drawn).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the intro and the scroll are not throttled (one frame per screen frame while they settle)", () => {
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
scene.frame(); // a entrada ainda corre
|
|
||||||
const intro = drawnOn(scene, 1000 / 144, 0.5);
|
|
||||||
expect(intro).toBe(Math.round(500 / (1000 / 144)));
|
|
||||||
|
|
||||||
scene.settle(200); // tudo assentado
|
|
||||||
scene.progressRef.current = 1; // a rolagem se move: o mergulho segue a cada quadro
|
|
||||||
const scrolling = drawnOn(scene, 1000 / 60, 0.25);
|
|
||||||
expect(scrolling).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("path: the frame the pulse asks for 33 ms after the last one is drawn, not skipped", () => {
|
|
||||||
const scene = setup({ visible: true });
|
|
||||||
scene.settle(200);
|
|
||||||
|
|
||||||
scene.frame(33);
|
|
||||||
|
|
||||||
// o quadro de uma batida que sai com folga de temporizador (33 ms) entra
|
|
||||||
expect(scene.renderer.render.mock.calls.length).toBeGreaterThan(0);
|
|
||||||
const drawn = scene.renderer.render.mock.calls.length;
|
|
||||||
scene.frame(33);
|
|
||||||
expect(scene.renderer.render.mock.calls.length).toBe(drawn + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createField: a batida de 30 quadros por segundo", () => {
|
|
||||||
it("path: setVisible(true) twice in a row -> a single pulse; leaving and coming back starts a new one", () => {
|
|
||||||
const interval = jest
|
|
||||||
.spyOn(window, "setInterval")
|
|
||||||
.mockReturnValue(7 as unknown as ReturnType<typeof setInterval>);
|
|
||||||
jest.spyOn(window, "clearInterval").mockImplementation(() => {});
|
|
||||||
const { engine } = setup({ visible: false });
|
|
||||||
expect(interval).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
engine.setVisible(true);
|
|
||||||
engine.setVisible(true);
|
|
||||||
expect(interval).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
engine.setVisible(false);
|
|
||||||
engine.setVisible(false); // sair de novo sem estar à vista não desliga nada duas vezes
|
|
||||||
engine.setVisible(true);
|
|
||||||
expect(interval).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,471 +0,0 @@
|
|||||||
import { act, render } from "@testing-library/react";
|
|
||||||
import { isKnownSoftwareWebGL } from "@/lib/device";
|
|
||||||
import HeroFieldScene from "./HeroFieldScene";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* O componente que monta a cena: cria o renderizador do Three.js (sem React Three Fiber), põe o canvas na página,
|
|
||||||
* liga a batida só com o hero à vista e solta tudo ao sair. A cena nasce em tarefas separadas (renderizador,
|
|
||||||
* compilação do shader, primeiro quadro): os testes deixam essas tarefas correrem com `mount`. O WebGL do jsdom não
|
|
||||||
* existe: o renderizador é um dublê.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type RendererOptions = {
|
|
||||||
alpha?: boolean;
|
|
||||||
antialias?: boolean;
|
|
||||||
powerPreference?: string;
|
|
||||||
};
|
|
||||||
const mockRenderers: MockRenderer[] = [];
|
|
||||||
let mockFailToCreate = false;
|
|
||||||
/** O nome do renderizador que o contexto de teste conta (WEBGL_debug_renderer_info); `null` = a extensão não existe. */
|
|
||||||
let mockRendererName: string | null = null;
|
|
||||||
/** Quando o teste quer segurar a compilação do shader: a promessa que `compileAsync` devolve. */
|
|
||||||
let mockCompile: () => Promise<unknown> = () => Promise.resolve();
|
|
||||||
|
|
||||||
class MockRenderer {
|
|
||||||
domElement = document.createElement("canvas");
|
|
||||||
pixelRatio = 1;
|
|
||||||
options: RendererOptions;
|
|
||||||
render = jest.fn();
|
|
||||||
compileAsync = jest.fn(() => mockCompile());
|
|
||||||
dispose = jest.fn();
|
|
||||||
forceContextLoss = jest.fn();
|
|
||||||
setSize = jest.fn();
|
|
||||||
setPixelRatio = (value: number) => {
|
|
||||||
this.pixelRatio = value;
|
|
||||||
};
|
|
||||||
getPixelRatio = () => this.pixelRatio;
|
|
||||||
getContext = () => ({
|
|
||||||
getExtension: (name: string) =>
|
|
||||||
name === "WEBGL_debug_renderer_info" && mockRendererName !== null
|
|
||||||
? { UNMASKED_RENDERER_WEBGL: 37446 }
|
|
||||||
: null,
|
|
||||||
getParameter: (parameter: number) =>
|
|
||||||
parameter === 37446 ? mockRendererName : null,
|
|
||||||
});
|
|
||||||
constructor(options: RendererOptions) {
|
|
||||||
if (mockFailToCreate) throw new Error("sem WebGL");
|
|
||||||
this.options = options;
|
|
||||||
mockRenderers.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// a classe é criada só quando a cena pede o renderizador (o mock é içado antes da declaração dela)
|
|
||||||
jest.mock("three", () => ({
|
|
||||||
...jest.requireActual("three"),
|
|
||||||
WebGLRenderer: jest.fn(
|
|
||||||
(options: RendererOptions) => new MockRenderer(options),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const props = () => ({
|
|
||||||
progressRef: { current: 0 },
|
|
||||||
visible: false,
|
|
||||||
onReady: jest.fn(),
|
|
||||||
onError: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function setDevicePixelRatio(value: number) {
|
|
||||||
Object.defineProperty(window, "devicePixelRatio", {
|
|
||||||
configurable: true,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
mockRenderers.length = 0;
|
|
||||||
mockFailToCreate = false;
|
|
||||||
mockRendererName = null;
|
|
||||||
mockCompile = () => Promise.resolve();
|
|
||||||
setDevicePixelRatio(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Deixa as tarefas da montagem correrem (renderizador, compilação do shader, primeiro quadro pedido). */
|
|
||||||
const buildScene = (ms = 10) =>
|
|
||||||
act(async () => {
|
|
||||||
await jest.advanceTimersByTimeAsync(ms);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Só a tarefa que já estava na fila (as que ela mesma agenda esperam a próxima chamada): um passo da montagem. */
|
|
||||||
const nextTask = () =>
|
|
||||||
act(async () => {
|
|
||||||
await jest.runOnlyPendingTimersAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function mount(ui: React.ReactElement) {
|
|
||||||
const result = render(ui);
|
|
||||||
await buildScene();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("HeroFieldScene (ambient 3D)", () => {
|
|
||||||
it("creates a transparent renderer without antialiasing, on the high-performance GPU, and puts its canvas in the page", async () => {
|
|
||||||
const { container } = await mount(<HeroFieldScene {...props()} />);
|
|
||||||
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
expect(mockRenderers[0].options).toMatchObject({
|
|
||||||
alpha: true,
|
|
||||||
antialias: false,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
});
|
|
||||||
const canvas = container.querySelector("canvas");
|
|
||||||
expect(canvas).toBe(mockRenderers[0].domElement);
|
|
||||||
expect(canvas).toHaveAttribute("aria-hidden", "true");
|
|
||||||
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[0.5, 1],
|
|
||||||
[1, 1],
|
|
||||||
[1.25, 1.25],
|
|
||||||
[2, 1.5],
|
|
||||||
[3, 1.5],
|
|
||||||
])(
|
|
||||||
"caps the pixel ratio at 1.5 (device %s -> %s)",
|
|
||||||
async (device, expected) => {
|
|
||||||
setDevicePixelRatio(device);
|
|
||||||
await mount(<HeroFieldScene {...props()} />);
|
|
||||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("draws on demand: a frame is asked for, never a fixed 60 fps loop, and nothing keeps running once settled", async () => {
|
|
||||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
||||||
await mount(<HeroFieldScene {...props()} />);
|
|
||||||
expect(frames).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// a entrada ainda corre: cada quadro pede o seguinte, até assentar (2,6 s de entrada)
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
const settled = frames.mock.calls.length;
|
|
||||||
expect(settled).toBeGreaterThan(2);
|
|
||||||
expect(settled).toBeLessThan(5000 / 16);
|
|
||||||
|
|
||||||
// hero fora de cena e tudo assentado: nenhum quadro a mais
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
expect(frames.mock.calls.length).toBe(settled);
|
|
||||||
expect(mockRenderers[0].render.mock.calls.length).toBeGreaterThan(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tells the page it is ready after the second frame", async () => {
|
|
||||||
const scene = props();
|
|
||||||
await mount(<HeroFieldScene {...scene} />);
|
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.onReady).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts the 30 fps pulse only while the hero is visible, and stops it when it leaves", async () => {
|
|
||||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
||||||
const scene = props();
|
|
||||||
const { rerender } = await mount(
|
|
||||||
<HeroFieldScene {...scene} visible={false} />,
|
|
||||||
);
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(6000);
|
|
||||||
});
|
|
||||||
const hidden = frames.mock.calls.length;
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
|
||||||
|
|
||||||
rerender(<HeroFieldScene {...scene} visible />);
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(20); // o quadro que a batida pediu sai
|
|
||||||
});
|
|
||||||
expect(jest.getTimerCount()).toBe(1); // um setInterval, nenhum segundo laço
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(980);
|
|
||||||
});
|
|
||||||
const shown = frames.mock.calls.length - hidden;
|
|
||||||
expect(shown).toBeGreaterThan(20);
|
|
||||||
expect(shown).toBeLessThanOrEqual(34);
|
|
||||||
|
|
||||||
rerender(<HeroFieldScene {...scene} visible={false} />);
|
|
||||||
const stopped = frames.mock.calls.length;
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(2000);
|
|
||||||
});
|
|
||||||
expect(frames.mock.calls.length).toBe(stopped);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases the context, the canvas and the timers when it unmounts", async () => {
|
|
||||||
const scene = props();
|
|
||||||
const { container, unmount } = await mount(
|
|
||||||
<HeroFieldScene {...scene} visible />,
|
|
||||||
);
|
|
||||||
expect(container.querySelector("canvas")).not.toBeNull();
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
|
||||||
const drawn = mockRenderers[0].render.mock.calls.length;
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(1000);
|
|
||||||
});
|
|
||||||
expect(mockRenderers[0].render.mock.calls.length).toBe(drawn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not rebuild the scene when the parent passes new callbacks, and calls the newest ones", async () => {
|
|
||||||
const first = props();
|
|
||||||
const { rerender } = await mount(<HeroFieldScene {...first} />);
|
|
||||||
const second = props();
|
|
||||||
rerender(
|
|
||||||
<HeroFieldScene
|
|
||||||
progressRef={first.progressRef}
|
|
||||||
visible={false}
|
|
||||||
onReady={second.onReady}
|
|
||||||
onError={second.onError}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
jest.advanceTimersByTime(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(first.onReady).not.toHaveBeenCalled();
|
|
||||||
expect(second.onReady).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tells the page and renders nothing but the empty host when WebGL cannot be created", async () => {
|
|
||||||
mockFailToCreate = true;
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[0, 1], // aparelho que não informa a proporção: 1
|
|
||||||
[0.5, 1], // nunca abaixo de 1
|
|
||||||
[1.25, 1.25],
|
|
||||||
[3, 1.5], // nunca acima de 1,5: cada ponto custa fragmentos e o fundo é ambiente
|
|
||||||
])(
|
|
||||||
"draws at a pixel ratio of %p -> the renderer gets %p",
|
|
||||||
async (device, expected) => {
|
|
||||||
setDevicePixelRatio(device);
|
|
||||||
|
|
||||||
await mount(<HeroFieldScene {...props()} />);
|
|
||||||
|
|
||||||
expect(mockRenderers[0].pixelRatio).toBe(expected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("tells the page when the WebGL context is lost after mounting, through the newest callback", async () => {
|
|
||||||
const scene = props();
|
|
||||||
await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
mockRenderers[0].domElement.dispatchEvent(
|
|
||||||
new Event("webglcontextlost", { cancelable: true }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("built in separate tasks (no single long task)", () => {
|
|
||||||
it("takes the steps one task at a time: the renderer, the point cloud and the shader compile, then the first frame", async () => {
|
|
||||||
let finishCompile = () => {};
|
|
||||||
mockCompile = () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishCompile = () => resolve(undefined);
|
|
||||||
});
|
|
||||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
render(<HeroFieldScene {...scene} />);
|
|
||||||
// nada acontece dentro do commit do React: nem o contexto WebGL
|
|
||||||
expect(mockRenderers).toHaveLength(0);
|
|
||||||
|
|
||||||
await nextTask();
|
|
||||||
// tarefa 1: só o renderizador (o contexto WebGL); a nuvem de pontos e o shader esperam a vez
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
|
||||||
expect(frames).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await nextTask();
|
|
||||||
// tarefa 2: a nuvem de pontos e o pedido de compilação; o shader compila fora da thread principal e nada foi desenhado
|
|
||||||
expect(mockRenderers[0].compileAsync).toHaveBeenCalledTimes(1);
|
|
||||||
expect(frames).not.toHaveBeenCalled();
|
|
||||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
finishCompile();
|
|
||||||
await act(async () => {
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
// a compilação acabou, mas o tamanho e o primeiro quadro esperam a própria tarefa
|
|
||||||
expect(mockRenderers[0].setSize).not.toHaveBeenCalled();
|
|
||||||
expect(frames).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await nextTask();
|
|
||||||
// tarefa 3: tamanho e o pedido do primeiro quadro, que sai noutra tarefa (requestAnimationFrame)
|
|
||||||
expect(mockRenderers[0].setSize).toHaveBeenCalled();
|
|
||||||
expect(frames).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].render).not.toHaveBeenCalled();
|
|
||||||
await act(async () => {
|
|
||||||
await jest.advanceTimersByTimeAsync(20);
|
|
||||||
});
|
|
||||||
expect(mockRenderers[0].render).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compiles the very scene it is going to draw", async () => {
|
|
||||||
await mount(<HeroFieldScene {...props()} />);
|
|
||||||
|
|
||||||
const [scene, camera] = mockRenderers[0].compileAsync.mock
|
|
||||||
.calls[0] as unknown as [{ isScene: boolean }, { isCamera: boolean }];
|
|
||||||
expect(scene.isScene).toBe(true);
|
|
||||||
expect(camera.isCamera).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
await jest.advanceTimersByTimeAsync(20);
|
|
||||||
});
|
|
||||||
expect(mockRenderers[0].render.mock.calls[0][0]).toBe(scene);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates nothing when it is unmounted before the first task (StrictMode, a quick scroll away)", async () => {
|
|
||||||
const scene = props();
|
|
||||||
const { unmount } = render(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
await buildScene();
|
|
||||||
|
|
||||||
expect(mockRenderers).toHaveLength(0);
|
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("frees the context if it is unmounted between the renderer and the point cloud", async () => {
|
|
||||||
const { container, unmount } = render(<HeroFieldScene {...props()} />);
|
|
||||||
await nextTask();
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
await buildScene();
|
|
||||||
|
|
||||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases the context if it is unmounted while the shader compiles", async () => {
|
|
||||||
let finishCompile = () => {};
|
|
||||||
mockCompile = () =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishCompile = () => resolve(undefined);
|
|
||||||
});
|
|
||||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
||||||
const { container, unmount } = await mount(
|
|
||||||
<HeroFieldScene {...props()} />,
|
|
||||||
);
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
finishCompile();
|
|
||||||
await buildScene();
|
|
||||||
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].domElement.isConnected).toBe(false);
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
expect(frames).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tells the page, and frees the context, when the shader does not compile", async () => {
|
|
||||||
mockCompile = () => Promise.reject(new Error("shader inválido"));
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the visibility the page asked for while it was still being built", async () => {
|
|
||||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
|
||||||
const scene = props();
|
|
||||||
const { rerender } = render(
|
|
||||||
<HeroFieldScene {...scene} visible={false} />,
|
|
||||||
);
|
|
||||||
rerender(<HeroFieldScene {...scene} visible />);
|
|
||||||
|
|
||||||
await buildScene();
|
|
||||||
await act(async () => {
|
|
||||||
await jest.advanceTimersByTimeAsync(980);
|
|
||||||
});
|
|
||||||
|
|
||||||
// a batida de 30 quadros por segundo ligou assim que a cena nasceu
|
|
||||||
expect(frames.mock.calls.length).toBeGreaterThan(20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("HeroFieldScene: WebGL por software (o teste é o contexto da própria cena)", () => {
|
|
||||||
beforeEach(() => sessionStorage.clear());
|
|
||||||
afterEach(() => sessionStorage.clear());
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
|
|
||||||
"llvmpipe (LLVM 15.0.7, 256 bits)",
|
|
||||||
"ANGLE (Microsoft, Microsoft Basic Render Driver (0x0000008C) Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
|
||||||
])(
|
|
||||||
"draws on the CPU (%s): no canvas, the page is told once, the context is freed and the session remembers it",
|
|
||||||
async (name) => {
|
|
||||||
mockRendererName = name;
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
// um só contexto: o do teste é o que a cena abriria para desenhar, sem um segundo
|
|
||||||
expect(mockRenderers).toHaveLength(1);
|
|
||||||
expect(mockRenderers[0].dispose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].forceContextLoss).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockRenderers[0].compileAsync).not.toHaveBeenCalled();
|
|
||||||
expect(container.querySelector("canvas")).toBeNull();
|
|
||||||
expect(scene.onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(scene.onReady).not.toHaveBeenCalled();
|
|
||||||
expect(isKnownSoftwareWebGL()).toBe(true);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("draws on the GPU: the canvas stays and the session is not marked", async () => {
|
|
||||||
mockRendererName =
|
|
||||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0, D3D11)";
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
const { container } = await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
expect(container.querySelector("canvas")).toBe(mockRenderers[0].domElement);
|
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not report an unknown renderer (no debug extension) as software", async () => {
|
|
||||||
mockRendererName = null;
|
|
||||||
const scene = props();
|
|
||||||
|
|
||||||
await mount(<HeroFieldScene {...scene} />);
|
|
||||||
|
|
||||||
expect(scene.onError).not.toHaveBeenCalled();
|
|
||||||
expect(isKnownSoftwareWebGL()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,10 +9,10 @@ import HeroField, { DIVE_QUERY } from "./HeroField";
|
|||||||
* segue a variável), não o texto do CSS.
|
* segue a variável), não o texto do CSS.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
jest.mock("./HeroFieldScene", () => ({
|
jest.mock("./BlocosScene", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => null,
|
default: () => null,
|
||||||
prepareField: () => {},
|
prepareBlocos: () => Promise.resolve(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const win = window as unknown as Record<string, unknown>;
|
const win = window as unknown as Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/* Legenda da peça do hero (HeroField): o que a peça está montando. Cada texto novo entra digitado, com o passo e o
|
||||||
|
cursor do rótulo das seções (.ds-type e as animações ds-type, ds-type-caret e ds-type-blink, em globals.css); ela
|
||||||
|
some só pela opacidade. Sem movimento reduzido: com ele a cena nem monta, e a legenda não existe. */
|
||||||
|
.hero-legenda {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-legenda[data-shown] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
|
||||||
|
.hero-legenda .ds-type {
|
||||||
|
animation: ds-type calc(var(--n) * 32ms) steps(var(--n)) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-legenda .ds-type::after {
|
||||||
|
animation:
|
||||||
|
ds-type-caret calc(var(--n) * 32ms) steps(var(--n)) forwards,
|
||||||
|
ds-type-blink 0.9s steps(1) calc(var(--n) * 32ms) forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ describe("contact-subject", () => {
|
|||||||
expect(CONTACT_KINDS).toEqual({
|
expect(CONTACT_KINDS).toEqual({
|
||||||
site: "Site que traz pedidos pelo WhatsApp",
|
site: "Site que traz pedidos pelo WhatsApp",
|
||||||
sistema: "Sistema para tirar a operação da planilha",
|
sistema: "Sistema para tirar a operação da planilha",
|
||||||
|
whatsapp: "Atendimento no WhatsApp com IA sob controle",
|
||||||
revisao: "Revisão de sistema que já existe",
|
revisao: "Revisão de sistema que já existe",
|
||||||
|
fiscal: "Nota fiscal pronta para a reforma tributária",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,24 +15,28 @@ import { offerLabels } from "@/lib/offer-labels";
|
|||||||
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
|
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* As três ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
|
* As cinco ofertas entram com o nome que têm na home e em /servicos, que vem de `offerLabels` (lib/offer-labels.ts): o
|
||||||
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
|
* módulo pequeno que o navegador pode importar, ao contrário de lib/site-content.ts, que reúne o texto de todas as páginas.
|
||||||
*/
|
*/
|
||||||
export const CONTACT_SUBJECT_OPTIONS = [
|
export const CONTACT_SUBJECT_OPTIONS = [
|
||||||
DEFAULT_CONTACT_SUBJECT,
|
DEFAULT_CONTACT_SUBJECT,
|
||||||
offerLabels.site.name,
|
offerLabels.site.name,
|
||||||
offerLabels.sistema.name,
|
offerLabels.sistema.name,
|
||||||
|
offerLabels.whatsapp.name,
|
||||||
"Aplicativos",
|
"Aplicativos",
|
||||||
"Integrações e automações",
|
"Integrações e automações",
|
||||||
"Evolução de um sistema existente",
|
"Evolução de um sistema existente",
|
||||||
offerLabels.revisao.name,
|
offerLabels.revisao.name,
|
||||||
|
offerLabels.fiscal.name,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
|
/** Tipo que uma oferta pede -> opção do formulário (o nome da oferta). */
|
||||||
export const CONTACT_KINDS = {
|
export const CONTACT_KINDS = {
|
||||||
site: offerLabels.site.name,
|
site: offerLabels.site.name,
|
||||||
sistema: offerLabels.sistema.name,
|
sistema: offerLabels.sistema.name,
|
||||||
|
whatsapp: offerLabels.whatsapp.name,
|
||||||
revisao: offerLabels.revisao.name,
|
revisao: offerLabels.revisao.name,
|
||||||
|
fiscal: offerLabels.fiscal.name,
|
||||||
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
|
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
|
||||||
|
|
||||||
export type ContactKind = keyof typeof CONTACT_KINDS;
|
export type ContactKind = keyof typeof CONTACT_KINDS;
|
||||||
|
|||||||