Site institucional da LZdev: desenvolvimento de sites, landing pages e sistemas web sob medida. Página única focada em conversão, em nove seções nesta ordem:
- Hero — o que fazemos, em uma frase, e o CTA principal
- Serviços — o que se contrata: o que é, para quem é e o que entra
- Projetos — prova de entrega
- Tecnologias — a stack de trabalho
- O custo invisível — o prejuízo de não ter um site profissional
- Diferenciais — por que escolher a LZdev
- Equipe — quem escreve o código
- FAQ — accordion de objeções
- Fale com a gente — formulário, WhatsApp, e-mail e redes sociais
React 19 · Vite 7 · Tailwind CSS v4 — quatro dependências no total.
Sem biblioteca de animação, sem biblioteca de ícones, sem biblioteca de formulário e sem roteador: todas as interações são CSS moderno + hooks próprios, e os ícones são SVG inline. O resultado é um bundle pequeno e sem dependências para manter.
npm install # instala dependências
npm run dev # servidor de desenvolvimento
npm run build # build de produção em dist/ (SEO + prerender + check)
npm run preview # serve o build para conferência
npm run check # revisão automática do build (links, headings, alt, SEO)
npm run check:prod # testes HTTP do site no ar (domínio, redirects, 404, cabeçalhos)
npm run seo # reescreve o <head>, robots.txt, sitemap.xml e o manifest
npm run icons # regera os ícones da marca (e o favicon.ico) a partir de brand/logo.png
npm run og # regera a imagem de compartilhamento (og-cover.png)
npm run fonts # copia as fontes de node_modules para public/fonts
npm run images # gera os WebP das capturas de projeto (precisa de Chrome ou Edge)
npm run assets # icons + og + fonts de uma vezProdução, domínio e Search Console: ver
docs/SEO-PRODUCAO.md— diagnóstico, configuração da Vercel, procedimento de publicação e de acompanhamento da indexação.
npm run seo grava as meta tags, robots.txt, sitemap e manifest
vite build o bundle do site em dist/
vite build --ssr entry-server um bundle de servidor em dist-ssr/ (temporário)
node scripts/prerender.mjs renderiza a página dentro de dist/index.html
(e copia para dist/<rota>/ cada rota de ROUTES)
node scripts/check-seo.mjs a revisão automática — um ERRO reprova o build
O último passo é o que importa mais para o Google e para as redes sociais: sem
ele, o HTML publicado é literalmente <div id="root"></div>. O Google executa
JavaScript e acabaria vendo o conteúdo, mas o rastreador do WhatsApp, do
LinkedIn, do Facebook, do Discord e a maioria dos rastreadores de IA não
executam JavaScript — para eles a página não teria um parágrafo, e os dados
estruturados não existiriam. Depois do prerender o HTML sai do build com a página
inteira dentro, e o React apenas hidrata o que já está na tela (src/main.jsx).
O npm run check roda no fim de todo build: ele lê dist/ e reclama de âncora
ou rota que não existe, link sem destino, imagem sem alt, heading pulando
nível, title comprido, noindex acidental, canonical inconsistente, JSON-LD
inválido, sitemap fora do protocolo ou com URL não canônica, robots bloqueando
algo, og:image não publicada e hash da CSP do vercel.json desatualizado. Sai
com código 1 só em erro de verdade — e aí o deploy da Vercel falha, mantendo no
ar a versão anterior.
O conteúdo do site vive em src/data/site.js — textos, listas, links,
serviços, projetos e FAQ. Alterar copy nunca exige tocar em JSX. As seções estão
numeradas no arquivo na mesma ordem em que aparecem na página.
Os metadados vivem em src/data/seo.js — domínio, title, description,
Open Graph e a lista de URLs do sitemap. Nenhum outro arquivo-fonte repete o
domínio: npm run seo propaga o valor para o <head> do index.html, para o
robots.txt, para o sitemap.xml e para o site.webmanifest, e o JSON-LD lê o
mesmo arquivo em tempo de render. Trocar de domínio é editar uma linha.
O bloco do index.html entre <!-- SEO:START --> e <!-- SEO:END --> é
gerado. Editar ali funciona até o próximo build.
services em src/data/site.js alimenta ao mesmo tempo os cards da seção
Serviços e os nós Service do JSON-LD. O schema espelha a tela: anunciar ao
Google um serviço que a página não mostra é marcação enganosa e custa a
elegibilidade a resultados enriquecidos do site inteiro. Serviço novo entra ali
primeiro.
Por isso SaaS, automações e APIs saíram do title e do JSON-LD: estavam
anunciados no HTML antigo e nenhuma seção do site os sustentava. E pelo mesmo
motivo os nós Service não têm offers — o preço saiu dos cards, então saiu
também do schema.
Cada card de Serviços tinha um pé com "A partir de R$ 1.800" e "cerca de 1 semana". Os dois saíram por decisão da LZdev: valor e cronograma sem escopo definido são chute, e chute publicado vira âncora contra a própria proposta (o visitante decide por um número antes de saber o que está comprando) e promessa que alguém cobra depois. Os dois voltam na conversa, onde existe escopo para sustentá-los.
Isso é regra do site, não preferência de quem escreveu o texto:
npm run check varre o HTML publicado e reprova o build se um preço
(R$ …, "… reais"), um prazo ("em 7 dias", "2 a 3 semanas") ou um número de
vitrine sem comprovação ("30+ projetos", "100% de satisfação") reaparecer em
qualquer lugar da página — inclusive vindo de outro arquivo, de uma tradução ou
de um CMS. Os números do painel do Hero não contam: ele é um role="img" e a
varredura o ignora inteiro.
E-mail, WhatsApp, GitHub e Instagram dos dois desenvolvedores saem dos objetos
enzo e luis, no topo de src/data/site.js. Trocar o link ali atualiza o
site inteiro — cartão da pessoa na Equipe, redes da seção de contato, canais
diretos, botão flutuante e o JSON-LD — porque nenhum desses lugares repete a URL.
O primeiro item de contact.whatsapps é o canal principal: é dele que saem o
botão flutuante e o envio do formulário, que precisam de um destino só.
Nenhum dado obrigatório está faltando (npm run check avisa se um marcador
FALTA aparecer em src/data/site.js). Ficam vazios de propósito, como opcionais:
| O quê | Onde | Vazio significa |
|---|---|---|
| Foto real e aprovada de cada pessoa | team.members[].photo — arquivo dentro de public/ (a CSP bloqueia imagem de outro domínio) |
o cartão mostra as iniciais |
| Perfis oficiais da LZdev | contact.socials |
o bloco "Também estamos aqui" não aparece |
Perfil pessoal não entra em contact.socials: a área de contato é da empresa.
GitHub e portfólio de cada pessoa ficam no cartão da Equipe.
A seção "Nossos números" (30+ projetos, 20+ clientes, 10+ tecnologias e 100% de
satisfação) saiu em 21/10/2026: os números não tinham comprovação. O endereço
/numeros redireciona para a raiz (vercel.json), e npm run check reprova o
build se um número de vitrine desses voltar ao texto da página.
Kimori Korean Food e Tio Preto Barbearia saíram do portfólio em 21/10/2026: as
aplicações deixaram de responder. O portfólio só mostra projeto que abre — o
npm run check:prod abre cada botão de projeto da página publicada.
Link vazio nunca gera botão morto. Uma rede em contact.socials só aparece
com href preenchido; um projeto sem url mostra o selo "Publicação em breve"
em vez de um botão que não abre nada. Imagens são opcionais pelo mesmo motivo:
enquanto image / photo estiverem vazios, o card desenha a moldura de espera
(projetos) ou o avatar de iniciais (equipe) — nunca uma imagem quebrada.
O domínio já está verificado por DNS (propriedade do tipo Domínio) e o sitemap
https://lzdev.com.br/sitemap.xml já foi enviado. Por isso
googleSiteVerification em src/data/seo.js continua null: a tag HTML só seria
necessária para verificar uma propriedade de prefixo de URL por outro método.
Nunca preencha com um valor inventado — uma tag falsa não valida nada.
Acompanhamento da indexação, status esperados e diagnóstico:
docs/SEO-PRODUCAO.md, seção 7.
O site está na Vercel, com DNS na Cloudflare. O vercel.json define os
cabeçalhos de segurança (CSP, nosniff, Referrer-Policy, proteção contra iframe),
o cache de um ano dos assets com hash, /projetos/ → /projetos,
/index.html → / e /numeros (seção removida) → /. O host principal
(lzdev.com.br × www) não fica no vercel.json, e sim no painel da
Vercel — ver
docs/SEO-PRODUCAO.md, seção 4. Depois de cada deploy:
npm run check:prod.
O site é estático: dist/ funciona em qualquer host. Dois detalhes de
configuração que mudam o comportamento do Google:
404.html— Netlify, Vercel, GitHub Pages, Cloudflare Pages e Nginx (error_page 404 /404.html;) usam esse arquivo automaticamente. Ele é um HTML solto, com CSS embutido: funciona sem o bundle, que muda de nome a cada build.- NÃO configure reescrita de SPA (
/* → /index.html). Este site não tem roteador: com a reescrita, qualquer endereço errado devolveria a home com status 200, e o Google trataria isso como soft 404 — página inexistente indexada como conteúdo. Sem a reescrita, o host devolve 404 de verdade e a página de erro aparece.
brand/logo.png arte original da marca (fonte dos ícones, NÃO publicada)
docs/SEO-PRODUCAO.md produção: domínio, Vercel, Search Console, verificações
vercel.json cabeçalhos de segurança e cache, barra final, redirects
public/
├─ 404.html página de erro, com CSS embutido (não depende do bundle)
├─ fonts/ as duas famílias em .woff2 (subconjunto latino)
├─ diferenciais/ as ilustrações SVG da seção Diferenciais
├─ marcas/ logo que não cabe em SVG inline (Composer)
├─ projetos/ capturas em PNG + variantes WebP (`npm run images`)
└─ … ícones, favicon.ico, og-cover.png, robots.txt,
sitemap.xml e o manifest — todos GERADOS, não edite à mão
scripts/
├─ lib/png.mjs codec de PNG sem dependências
├─ lib/mark.mjs recorte e reamostragem da arte da marca
├─ generate-icons.mjs favicons, favicon.ico, apple-touch-icon e ícones do manifest
├─ generate-og.mjs public/og-cover.png (1200x630)
├─ generate-seo.mjs <head>, robots.txt, sitemap.xml, site.webmanifest
├─ copy-fonts.mjs as duas fontes latinas para public/fonts
├─ optimize-images.mjs WebP responsivo das capturas, via Chrome/Edge local
├─ prerender.mjs injeta o HTML renderizado em dist/index.html
├─ check-seo.mjs revisão automática do build (roda no `npm run build`)
└─ check-prod.mjs testes HTTP do site publicado
src/
├─ data/
│ ├─ site.js fonte única de conteúdo
│ ├─ seo.js fonte única de metadados e do domínio
│ └─ images.json variantes WebP das capturas (gerado)
├─ styles/
│ ├─ fonts.css @font-face das duas famílias (subconjunto latino)
│ ├─ index.css tokens de design, utilities, keyframes
│ ├─ hero.css estilos exclusivos do Hero (variáveis --hero-*)
│ └─ team.css fundo discreto da seção Equipe
├─ hooks/ reveal, scroll, seção ativa, altura da barra
├─ lib/anchors.js ROUTES (fonte única das rotas de seção) e a rolagem
├─ components/
│ ├─ primitives/ Section, Reveal, Card, Button, Carousel, Icon
│ ├─ layout/ Navbar, Footer, WhatsAppFab, GridBackdrop, Logo, StructuredData
│ └─ sections/ uma seção por arquivo
│ └─ hero/ composição do Hero: Backdrop, Stage, Dashboard
├─ App.jsx ordem das seções
├─ main.jsx hidrata (produção) ou monta (dev)
└─ entry-server.jsx entrada usada só pelo prerender
brand/logo.png é a arte original e a única fonte dos ícones. Ela fica fora
de public/ de propósito: tem 1,1 MB, nenhum navegador jamais a pede e em
public/ ela era publicada junto do site.
A arte vem num canvas 3:2 com ~300px de vazio de cada lado — usá-la direto em
qualquer slot quadrado distorce a marca. npm run icons recorta o canvas morto,
centra a arte num quadrado (sem esticar) e reamostra para cada destino:
logo-mark-256.png— transparente, usado pelo<Logo />na páginafavicon-16/32/48/180.pngeapple-touch-icon.png— sobre placa preta (#000000) com a estrela em branco, porque a arte nasce branca e sozinha desapareceria numa aba de tema claroicon-192.png,icon-512.png,icon-maskable-512.png— os tamanhos que osite.webmanifestexige para o site poder ser instalado na tela inicial
A marca é um merkaba em wireframe e é ela que vai em todos os tamanhos. O
problema é o traço: ~3% da largura do símbolo, ou seja meio pixel a 16px, que a
média de área devolve em cinza médio. Nos tamanhos de aba (16/32/48) o script
compensa em dois passos — engrossa o traço na arte em resolução cheia (filtro de
máximo) antes de reduzir, e estica o contraste do alpha depois, para a linha
chegar branca e o vão entre as arestas ficar preto. A dose é por tamanho e vive na
tabela TARGETS. A 180px nada disso entra, lá o traço já tem corpo.
ICON_THEME=white npm run icons inverte a placa (branca, arte em preto).
public/og-cover.png (1200×630) é gerada por npm run og com a linguagem visual
do site: preto puro, a malha de 72px, os halos de alfa baixo e a plataforma de luz
sob a marca. Ela não tem texto — rasterizar a wordmark exigiria as curvas da
Plus Jakarta Sans, que vivem comprimidas dentro do .woff2, e uma fonte
aproximada seria uma marca errada. As redes sociais desenham og:title e
og:description como texto ao lado da imagem.
Para uma capa desenhada com wordmark e headline, exporte 1200×630 e sobrescreva
public/og-cover.png: nada no código precisa mudar.
src/styles/fonts.css declara as duas famílias apontando para public/fonts, e
o index.html pré-carrega os dois arquivos. Isso substituiu
import '@fontsource-variable/inter', que trazia onze subconjuntos por
família (cirílico, grego, vietnamita…) para um site em português e com nome de
arquivo com hash — o que impedia o preload. Os pacotes npm continuam sendo a
fonte: atualizar é npm update + npm run fonts.
O Hero ocupa ~100vh e é montado por inteiro em JSX/CSS/SVG — a única imagem é a
marca d'água ao fundo, a 7,5% de opacidade. O dashboard inclinado usa
perspective + rotateX/Y/Z e escala por container query: a variável --s
(em hero.css) funciona como unidade, então todo o mockup encolhe
proporcionalmente de 320px ao desktop sem quebrar. Os números do painel são
decorativos e estão marcados como tal em HeroDashboard.jsx — o painel
inteiro é um role="img" com descrição, para nenhum leitor de tela ler
"R$ 48.750,00" como se fosse dado da empresa.
O painel responde ao visitante: a trilha lateral troca a tela inteira (cinco
telas, cada uma com KPIs, gráfico e atividades próprios), o gráfico tem leitura
por ponto (linha guia, bolinha e balão com o valor do mês apontado) e o palco se
inclina alguns graus atrás do cursor. Nada disso é focável pelo teclado, de
propósito: dentro de um role="img" os elementos não existem para o leitor de
tela, e botão fora da árvore de acessibilidade não pode receber foco. Como os
dados são fictícios, nenhuma informação fica inacessível — o porquê está escrito
no cabeçalho de HeroDashboard.jsx.
A curva do gráfico é calculada a partir dos valores (Catmull-Rom convertido
em Bézier), não escrita à mão: trocar um número em VIEWS redesenha a linha.
Os cartões não dividem áreas nem listam tecnologia — nada de "fulano cuida do front" ou "sicrano usa React". A seção promete que o cliente fala direto com quem escreve o código, e separar front de back cria exatamente a pergunta que ela existe para eliminar; a stack já é apresentada, uma vez e com descrição, na seção Tecnologias. O que fica no cartão é quem é a pessoa, que ela acompanha o projeto inteiro e por onde falar com ela.
O fundo da seção (team.css) é textura, não desenho: uma trama fina de
pontos que se dissolve nas bordas, uma luz de 3,5% vinda de cima e um halo largo
e quase transparente que deriva na direção do cursor, com quase um segundo de
atraso. Sem animação, sem mesclagem, sem contraste alto — esta é a seção em que
o visitante lê nomes e decide falar com alguém, e fundo que puxa o olho compete
exatamente com isso. Tudo em gradiente CSS: nenhuma imagem, nenhum canvas.
O formulário valida no cliente e monta uma mensagem formatada que abre no
WhatsApp (wa.me) — sem backend. A microcópia ao lado do botão avisa isso
antes do clique. Para enviar para um endpoint no futuro, troque o
window.open no onSubmit de src/components/sections/Contact.jsx por um
fetch; a montagem da mensagem já está pronta e separada.
O repertório de animação é deliberadamente curto: fade, slide, hover e scroll reveal, mais a troca de tela do painel do Hero.
Três coisas acompanham o cursor — o brilho dos cards, a inclinação do painel do
Hero e o halo do fundo da Equipe — e as três seguem a mesma regra: o evento de
ponteiro escreve duas custom properties num elemento que já existe, e o CSS
faz o resto. Nenhuma passa por estado do React, nenhuma re-renderiza nada, nenhuma
dispara layout. Todas ignoram dedo e caneta (pointerType !== 'mouse'), porque em
toque o efeito ficaria congelado no último ponto tocado.
- cada
<section>é uma região nomeada pelo próprio<h2>(aria-labelledby), então quem navega por regiões ouve "Serviços", "Projetos", "Equipe" em vez de "seção, seção, seção" - o menu marca a seção que está sendo lida em cor e traço (nunca só cor) e
com
aria-current - alvos de toque com no mínimo 24px, inclusive os pontinhos dos carrosséis
- foco visível em anel branco, o traço mais claro da tela
- toda animação respeita
prefers-reduced-motion: reduce, num único bloco emsrc/styles/index.css; com movimento reduzido o painel do Hero também para de seguir o cursor. Abaixo de 640px os halos do fundo param de derivar, porque redesenhar um borrão de 34rem é trabalho permanente de GPU num celular de entrada
src/components/primitives/Icon.jsx reúne dois conjuntos: ícones de traço
desenhados à mão (stroke) e logos de marca preenchidos (brand), estes com a
geometria oficial do Simple Icons embutida — evita carregar uma biblioteca de
ícones inteira só pelos logos da seção de Tecnologias e das redes sociais. Sem title, um ícone
é decorativo e sai da árvore de acessibilidade.