diff --git a/.gitignore b/.gitignore index 985fcbb..106006f 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ next-env.d.ts .omo/ .playwright-mcp/ + +/3d/out/ +/3d/__pycache__/ diff --git a/3d/blocos.py b/3d/blocos.py new file mode 100644 index 0000000..0587d51 --- /dev/null +++ b/3d/blocos.py @@ -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() diff --git a/3d/pecas.py b/3d/pecas.py new file mode 100644 index 0000000..42ba00a --- /dev/null +++ b/3d/pecas.py @@ -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// e o ffmpeg do sistema gera em public/3d/ +peca-.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() diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e16f4e7 --- /dev/null +++ b/DESIGN.md @@ -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. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..2e90a5f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,53 @@ +# Product + + + +## 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. diff --git a/__mocks__/view-transition-probe.ts b/__mocks__/view-transition-probe.ts new file mode 100644 index 0000000..f5e0c8e --- /dev/null +++ b/__mocks__/view-transition-probe.ts @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; + +/** + * O React estável do Jest não exporta (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
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 }; +} diff --git a/app/cases/[slug]/page.paths.test.tsx b/app/cases/[slug]/page.paths.test.tsx index 0f44a9a..6620d27 100644 --- a/app/cases/[slug]/page.paths.test.tsx +++ b/app/cases/[slug]/page.paths.test.tsx @@ -126,7 +126,7 @@ describe("CasePage: caminhos que o teste principal não cobre", () => { expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull(); // o NutriScan tem "Como ficou", mas o botão de contato é um só, o do fecho (sem dois seguidos) const contacts = screen.getAllByRole("link", { - name: "Falar sobre meu projeto", + name: "Chamar no WhatsApp", }); expect(contacts).toHaveLength(1); expect(contacts[0]).toBeVisible(); diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index 135e2eb..0b1c213 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -62,8 +62,8 @@ function readJsonLdEntities(container: HTMLElement): Record[] { /** * Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo - * ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho: - * `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Falar sobre meu projeto" seguidos. + * ("Chamar no WhatsApp") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho: + * `similarProjectSubject(título)`. Um botão só por página (o do fecho): sem dois "Chamar no WhatsApp" seguidos. */ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { const hrefs = screen @@ -72,7 +72,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { expect(hrefs).not.toContain("/#contact"); expect(hrefs.some((href) => /github/i.test(href))).toBe(false); - const ctas = screen.getAllByRole("link", { name: buttonLabels.contact }); + const ctas = screen.getAllByRole("link", { name: buttonLabels.whatsapp }); expect(ctas).toHaveLength(1); for (const cta of ctas) { const destination = new URL(cta.getAttribute("href") ?? ""); @@ -179,13 +179,13 @@ it("renders a client case in the sales order, with media, contact and collapsed const capabilitiesSection = sectionHeadings[4].closest("section"); expect( within(capabilitiesSection as HTMLElement).queryByRole("link", { - name: buttonLabels.contact, + name: buttonLabels.whatsapp, }), ).toBeNull(); const closingSection = sectionHeadings[5].closest("section"); expect(closingSection).toContainElement( within(closingSection as HTMLElement).getByRole("link", { - name: buttonLabels.contact, + name: buttonLabels.whatsapp, }), ); for (const capability of caseStudy.capabilities ?? []) { @@ -371,10 +371,10 @@ it("writes what the client can do now right after 'Como ficou', with no contact expect(items.length).toBeLessThanOrEqual(3); // sem número inventado expect(items.join(" ")).not.toMatch(/\d/); - // o único botão de contato é o do fecho, depois da lista: não há dois "Falar sobre meu projeto" seguidos + // o único botão de contato é o do fecho, depois da lista: não há dois "Chamar no WhatsApp" seguidos expect( within(capabilitiesSection as HTMLElement).queryByRole("link", { - name: buttonLabels.contact, + name: buttonLabels.whatsapp, }), ).toBeNull(); const closingSection = screen @@ -386,7 +386,7 @@ it("writes what the client can do now right after 'Como ficou', with no contact expect(capabilitiesSection?.nextElementSibling).toBe(closingSection); expect( within(closingSection as HTMLElement).getAllByRole("link", { - name: buttonLabels.contact, + name: buttonLabels.whatsapp, }), ).toHaveLength(1); unmount(); @@ -477,7 +477,7 @@ it("shows at most two related services, as cards under the contact button", asyn screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }), ).toBeNull(); // o botão do fecho (o único) vem antes dos cartões - const contact = screen.getByRole("link", { name: buttonLabels.contact }); + const contact = screen.getByRole("link", { name: buttonLabels.whatsapp }); expect(isBefore(contact, cards[0])).toBe(true); unmount(); @@ -748,7 +748,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc expect(hrefs).not.toContain("/#contact"); expect(hrefs.some((href) => /github/i.test(href))).toBe(false); expect(hrefs).toContain("/servicos"); - const cta = screen.getByRole("link", { name: buttonLabels.contact }); + const cta = screen.getByRole("link", { name: buttonLabels.whatsapp }); expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me"); const visibleBreadcrumb = screen.getByRole("navigation", { diff --git a/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx index c5e9517..7a3bd56 100644 --- a/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx +++ b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx @@ -4,7 +4,7 @@ import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection"; /** * Testes de caminhos-base de CaseCapabilitiesSection: com itens (a lista sob o título do cliente ou do laboratório, sem - * botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Falar sobre meu projeto" + * botão de contato: o fecho do case vem logo depois e é o único convite, para não haver dois "Chamar no WhatsApp" * seguidos) e sem itens (a seção não aparece, tenha o case site no ar ou captura ou não). */ @@ -32,7 +32,7 @@ describe("CaseCapabilitiesSection", () => { for (const item of capabilities) { expect(screen.getByText(item)).toBeVisible(); } - // o botão "Falar sobre meu projeto" é do fecho do case (CaseContactSection), não desta seção + // o botão "Chamar no WhatsApp" é do fecho do case (CaseContactSection), não desta seção expect(screen.queryByRole("link")).toBeNull(); expect(container.querySelectorAll("a")).toHaveLength(0); }); diff --git a/app/cases/_components/CaseCapabilitiesSection.tsx b/app/cases/_components/CaseCapabilitiesSection.tsx index d37d558..859a943 100644 --- a/app/cases/_components/CaseCapabilitiesSection.tsx +++ b/app/cases/_components/CaseCapabilitiesSection.tsx @@ -3,7 +3,7 @@ import type { CaseStudyContent } from "@/lib/site-content"; /** * Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam). * Sem botão de contato: o fecho do case (CaseContactSection) vem logo depois e é o único convite ao WhatsApp, para não haver dois - * "Falar sobre meu projeto" seguidos. Sem itens, a seção não aparece. + * "Chamar no WhatsApp" seguidos. Sem itens, a seção não aparece. */ export function CaseCapabilitiesSection({ caseStudy, diff --git a/app/cases/_components/CaseContactSection.paths.test.tsx b/app/cases/_components/CaseContactSection.paths.test.tsx index 0e239e3..2103dff 100644 --- a/app/cases/_components/CaseContactSection.paths.test.tsx +++ b/app/cases/_components/CaseContactSection.paths.test.tsx @@ -26,7 +26,7 @@ describe("CaseContactSection", () => { name: "Quer um projeto parecido com este?", }), ).toBeVisible(); - const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" }); + const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" }); expect(cta).toHaveAttribute("target", "_blank"); // sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever expect(messageOf(cta)).toContain( @@ -48,7 +48,7 @@ describe("CaseContactSection", () => { screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }), ).toBeVisible(); // o rótulo do botão é o mesmo do case de cliente: uma lista só - const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" }); + const cta = screen.getByRole("link", { name: "Chamar no WhatsApp" }); // a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome expect(messageOf(cta)).toContain( `Quero conversar sobre ${similarProjectSubject(nutri.title)}.`, diff --git a/app/cases/_components/CaseContactSection.tsx b/app/cases/_components/CaseContactSection.tsx index 55b564a..023e347 100644 --- a/app/cases/_components/CaseContactSection.tsx +++ b/app/cases/_components/CaseContactSection.tsx @@ -10,8 +10,8 @@ import { CaseRelatedServices } from "./CaseRelatedServices"; /** * Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê. - * O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também - * (e no botão do cabeçalho): `similarProjectSubject`. + * O botão abre o WhatsApp e tem o mesmo rótulo nos dois (`buttonLabels.whatsapp`), e o assunto da conversa também: + * `similarProjectSubject`. */ const contactCopy = { client: { title: "Quer um projeto parecido com este?" }, @@ -47,7 +47,7 @@ export function CaseContactSection({ placement="case_detail" className={primaryButton} > - {buttonLabels.contact} + {buttonLabels.whatsapp} Ver outros cases diff --git a/app/cases/_components/CaseCover.paths.test.tsx b/app/cases/_components/CaseCover.paths.test.tsx index f9d9495..fe59729 100644 --- a/app/cases/_components/CaseCover.paths.test.tsx +++ b/app/cases/_components/CaseCover.paths.test.tsx @@ -1,15 +1,23 @@ import { render, screen } from "@testing-library/react"; import { getShowcaseAssets } from "@/lib/project-showcase"; +import { caseStudies } from "@/lib/site-content"; import { CaseCover, coverSizes } from "./CaseCover"; import { getCaseMedia } from "./case-media"; +import { coverTransitionProps } from "./cover-transition"; /** - * Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster) - * e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`). + * Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster), + * os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`) + * e o pôster como elemento compartilhado da troca de página. */ jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); +// o dublê do põe as props num
para o teste ler +jest.mock("react", () => + jest.requireActual("@/__mocks__/view-transition-probe").reactWithProbe(), +); + const mockedGetCaseMedia = jest.mocked(getCaseMedia); const props = { id: "sev-modaintima", @@ -22,6 +30,23 @@ afterEach(() => { }); describe("CaseCover", () => { + it("path: capture found -> the poster is the shared element of the page change (case-, only the pair animates)", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + render(); + + const image = screen.getByAltText(`Captura de tela do site ${props.title}`); + const transition = image.parentElement; + expect(transition).toHaveAttribute("data-vt-name", `case-${props.id}`); + expect(transition).toHaveAttribute("data-vt-share", "case-cover"); + expect(transition).toHaveAttribute("data-vt-default", "none"); + }); + + it("coverTransitionProps: every case gets its own name, valid as a CSS identifier", () => { + const names = caseStudies.map(({ id }) => coverTransitionProps(id).name); + for (const name of names) expect(name).toMatch(/^[a-z][a-z0-9-]*$/); + expect(new Set(names).size).toBe(names.length); + }); + it("path: no desktop capture -> renders nothing", () => { mockedGetCaseMedia.mockReturnValue(undefined); const { container } = render(); diff --git a/app/cases/_components/CaseCover.tsx b/app/cases/_components/CaseCover.tsx index 6e88382..f303c50 100644 --- a/app/cases/_components/CaseCover.tsx +++ b/app/cases/_components/CaseCover.tsx @@ -1,9 +1,11 @@ import Image from "next/image"; +import { ViewTransition } from "react"; import { BrowserFrame, displayUrl, } from "@/app/components/showcase/BrowserFrame"; import { getCaseMedia } from "./case-media"; +import { coverTransitionProps } from "./cover-transition"; /** * Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo @@ -54,14 +56,16 @@ export function CaseCover({ return (
- {`Captura + + {`Captura +
); diff --git a/app/cases/_components/CaseHero.tsx b/app/cases/_components/CaseHero.tsx index 3a36c2e..526d44e 100644 --- a/app/cases/_components/CaseHero.tsx +++ b/app/cases/_components/CaseHero.tsx @@ -70,9 +70,9 @@ export function CaseHero({ { label: caseStudy.title, href: `/cases/${caseStudy.id}` }, ]} /> + {/* o tipo do projeto, sem o nome: a trilha logo acima já o diz */}

- {isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "} - {caseStudy.title} + {isClient ? "Case de cliente" : "Projeto de laboratório"}

{caseStudy.headline} diff --git a/app/cases/_components/CaseMediaSection.tsx b/app/cases/_components/CaseMediaSection.tsx index fa6c199..6a42807 100644 --- a/app/cases/_components/CaseMediaSection.tsx +++ b/app/cases/_components/CaseMediaSection.tsx @@ -43,7 +43,7 @@ export function CaseMediaSection({ className={secondaryButtonDark} href={caseStudy.demoUrl} target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > {buttonLabels.site} {caseStudy.title} diff --git a/app/cases/_components/CasePlayer.test.tsx b/app/cases/_components/CasePlayer.test.tsx index d3b3456..095b5c7 100644 --- a/app/cases/_components/CasePlayer.test.tsx +++ b/app/cases/_components/CasePlayer.test.tsx @@ -27,9 +27,18 @@ describe("CasePlayer", () => { name: `Reproduzir o vídeo do site ${title} no computador`, }); expect(button).toHaveAttribute("type", "button"); - // só botão de contato é limão: o de play é escuro, com contorno branco - expect(button.innerHTML).not.toContain("--slash"); - expect(button.querySelector("[aria-hidden='true']")).not.toBeNull(); + // em repouso o play é escuro, com contorno branco; o limão só vem sob o ponteiro ou no foco, com o círculo maior + const circle = button.querySelector("[aria-hidden='true']"); + const restClasses = (circle?.className ?? "") + .split(" ") + .filter((name) => !name.startsWith("group-")); + expect(restClasses.join(" ")).not.toContain("--slash"); + expect(circle).toHaveClass( + "group-hover:scale-108", + "group-hover:bg-[var(--slash)]", + "group-focus-visible:scale-108", + "group-focus-visible:bg-[var(--slash)]", + ); }); it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => { diff --git a/app/cases/_components/CasePlayer.tsx b/app/cases/_components/CasePlayer.tsx index 20f7ca4..732413d 100644 --- a/app/cases/_components/CasePlayer.tsx +++ b/app/cases/_components/CasePlayer.tsx @@ -78,9 +78,10 @@ export function CasePlayer({ aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`} className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white" > + {/* sob o ponteiro ou no foco o círculo cresce e fica verde (só o `scale` anima; a cor troca na hora) */} diff --git a/app/cases/_components/cover-transition.ts b/app/cases/_components/cover-transition.ts new file mode 100644 index 0000000..6521382 --- /dev/null +++ b/app/cases/_components/cover-transition.ts @@ -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; +} diff --git a/app/cases/page.loading.test.tsx b/app/cases/page.loading.test.tsx index 09db163..7750964 100644 --- a/app/cases/page.loading.test.tsx +++ b/app/cases/page.loading.test.tsx @@ -43,11 +43,14 @@ describe("CasesPage: carregamento das capas", () => { it("loads the first card cover at once with high priority, and every other image lazily", () => { const { container } = render(); - // sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página + // sem mosaico: a única imagem decorativa (alt vazio) é a peça de blocos do topo, preguiçosa e sem prioridade const decorative = Array.from( container.querySelectorAll("img"), ).filter((image) => image.getAttribute("alt") === ""); - expect(decorative).toHaveLength(0); + expect(decorative).toHaveLength(1); + expect(decorative[0].getAttribute("src")).toContain("estado-tela.webp"); + expect(decorative[0]).toHaveAttribute("loading", "lazy"); + expect(decorative[0]).not.toHaveAttribute("fetchpriority"); const [first, ...others] = coverImages(container); expect(first).toHaveAttribute("loading", "eager"); diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx index 2349379..742125b 100644 --- a/app/cases/page.paths.test.tsx +++ b/app/cases/page.paths.test.tsx @@ -77,11 +77,27 @@ describe("CasesPage: cases de cliente", () => { ).toBeNull(); }); - it("path: contact -> 'Falar sobre meu projeto', and the list of clients follows the showcase: the SEV first, then Lavação 047, SC Plus, AS Serviços and Julia Beauty", () => { + it("path: an odd number of client cases -> the last card takes the whole row on wide screens, so the grid ends with no empty cell", () => { + const { container } = render(); + + 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(); expect( - screen.getByRole("link", { name: buttonLabels.contact }), + screen.getByRole("link", { name: buttonLabels.whatsapp }), ).toHaveAttribute("href", expect.stringContaining("https://wa.me/")); const clientTitles = Array.from( container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"), @@ -98,18 +114,24 @@ describe("CasesPage: cases de cliente", () => { ]); // um botão de contato só na página: o do fecho expect( - screen.getAllByRole("link", { name: buttonLabels.contact }), + screen.getAllByRole("link", { name: buttonLabels.whatsapp }), ).toHaveLength(1); }); }); describe("CasesPage: topo sem mosaico", () => { - it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => { + it("path: captures found -> no poster mosaic (the cards below already carry the covers), only the block piece beside the text, a lower strip, no 'Veja o que já' twice", () => { const { container } = render(); const hero = container.querySelector("section"); - expect(hero?.querySelector("img")).toBeNull(); - expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); + const images = hero?.querySelectorAll("img") ?? []; + expect(images).toHaveLength(1); + expect(images[0].getAttribute("src")).toContain("estado-tela.webp"); + // decorativa e só de 1024 px em diante + expect(images[0].closest('[aria-hidden="true"]')).toHaveClass( + "hidden", + "lg:block", + ); expect(hero?.className).toContain("md:!pb-sp-12"); // o título e o subtítulo não repetem a mesma frase const heading = within(hero as HTMLElement).getByRole("heading", { @@ -130,7 +152,7 @@ describe("CasesPage: topo sem mosaico", () => { const { container } = render(); const hero = container.querySelector("section"); - expect(hero?.querySelector("img")).toBeNull(); + expect(hero?.querySelectorAll("img")).toHaveLength(1); const firstCover = clientCard( container, clientCasesForListing[0].id, diff --git a/app/cases/page.tsx b/app/cases/page.tsx index c25fd1c..91b26f4 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -8,6 +8,7 @@ import { textLink, } from "@/app/components/button-styles"; import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; +import { PagePiece } from "@/app/components/PagePiece"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; @@ -20,7 +21,7 @@ import { } from "@/lib/site-content"; import { CASES_SUBJECT } from "@/lib/whatsapp"; import { CaseCoverLink } from "./_components/CaseCoverLink"; -import { compactHero } from "./_components/hero-styles"; +import { compactHero, heroWithMediaGrid } from "./_components/hero-styles"; const pageUrl = `${siteUrl}/cases`; const pageTitle = "Cases: sites e sistemas para empresas"; @@ -42,45 +43,60 @@ export const metadata: Metadata = { }), }; -/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */ +/** + * Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. + * `wide`: o cartão que sobraria sozinho na última linha ocupa a linha inteira, capa à esquerda e texto à direita. + */ function ClientCaseCard({ caseStudy, priority = false, + wide = false, }: { caseStudy: CaseStudyContent; /** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */ priority?: boolean; + wide?: boolean; }) { return ( -
+ ); @@ -144,15 +160,14 @@ export default function CasesPage() {
-
-
+
+
-

Trabalhos entregues

Veja o que já fiz para empresas.

@@ -160,6 +175,7 @@ export default function CasesPage() { {pageLead}

+
@@ -169,11 +185,12 @@ export default function CasesPage() { Projetos de clientes

- {clientCasesForListing.map((caseStudy, index) => ( + {clientCasesForListing.map((caseStudy, index, all) => ( ))}
@@ -211,7 +228,7 @@ export default function CasesPage() { placement="cases_hub_footer" className={primaryButton} > - {buttonLabels.contact} + {buttonLabels.whatsapp} {buttonLabels.allServices} diff --git a/app/components/ClientStrip.test.tsx b/app/components/ClientStrip.test.tsx index 695f7e7..160864b 100644 --- a/app/components/ClientStrip.test.tsx +++ b/app/components/ClientStrip.test.tsx @@ -55,11 +55,11 @@ it("shows only the experience when there are no clients, and nothing without eit expect(none.container).toBeEmptyDOMElement(); }); -it("gives every client link a touch target of at least 44 px (min-h-11)", () => { +it("gives every client link 44 px of touch target on the phone (min-h-11) and 24 px from 640 px on, where the sector shares the item", () => { render(); for (const link of screen.getAllByRole("link")) { - expect(link).toHaveClass("min-h-11"); + expect(link).toHaveClass("min-h-11", "sm:min-h-6"); } }); @@ -107,3 +107,30 @@ it("keeps the years number in the display font (the mono font is not downloaded expect(screen.getByText("5").className).not.toContain("font-mono"); }); + +it("stacks the sector under each name from 640 px on, outside the link, and shows only the name for a client without one", () => { + render( + , + ); + + 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); +}); diff --git a/app/components/ClientStrip.tsx b/app/components/ClientStrip.tsx index 426ce95..372dab1 100644 --- a/app/components/ClientStrip.tsx +++ b/app/components/ClientStrip.tsx @@ -1,11 +1,19 @@ import HoverPrefetchLink from "./HoverPrefetchLink"; -export type ClientLink = { id: string; name: string }; +export type ClientLink = { + id: string; + name: string; + /** Ramo do cliente ("loja virtual"), sob o nome de 640 px em diante (lib/proof.ts). */ + sector?: string; +}; /** * Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na * mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado. - * Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link. + * De 640 px em diante cada cliente é um par empilhado: o nome (link) sobre o ramo, em 13 px e apagado; abaixo disso, + * só os nomes. Os links têm 44 px de altura de toque no celular e 24 px a partir de 640 px, onde o ramo divide a + * altura do item; a rota só é pré-carregada quando a pessoa chega no link. Os pontos, o rótulo e os anos se alinham + * pela linha dos nomes (`sm:items-start`). * * O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item * (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item @@ -30,12 +38,19 @@ export default function ClientStrip({ ...clients.map((client) => ({ key: client.id, node: ( - - {client.name} - + + + {client.name} + + {client.sector && ( + + {client.sector} + + )} + ), })), ...(years @@ -57,14 +72,16 @@ export default function ClientStrip({ return (
{clients.length > 0 &&

Já entregue para:

} -