Apostila de arquivo único — Nível 2 — Desenvolvimento Web. Uma aula por vez; use j/k ou o menu lateral. Voltar ao índice.
Nível 2Unidade 1 · Web estática3 aulas de 50 min + 1 h EAD
Aula 01 — Apresentação, arquitetura web, ambiente de desenvolvimento e Git
Nível 2 — Desenvolvimento Web · WebLab
Esta é a primeira aula do Nível 2. Ao final dela você terá um repositório Git publicado na internet, o ambiente de trabalho do semestre inteiro montado e uma ideia clara de onde o Nível 2 vai chegar: uma aplicação full-stack com API própria, login do Google e CRUD persistido.
No Nível 1 você aprendeu a escrever HTML semântico, estilizar com CSS (layout, responsividade, animações) e programar o comportamento das páginas com JavaScript (variáveis, funções, eventos, validação de formulários). Hoje o Nível 2 pega esse repertório e o coloca dentro de um processo profissional: ambiente configurado, código versionado, projeto publicado. Nas próximas 16 aulas esse mesmo projeto vai crescer até virar uma aplicação com servidor próprio.
Checklist para começar:
[ ] Um notebook (ou uma máquina do laboratório) com Google Chrome ou Firefox atualizado.
[ ] Permissão para instalar programas na máquina — ou, no laboratório, saber que as instalações se perdem no reboot (a §7.5 resolve isso).
[ ] Uma conta de e-mail que você realmente acessa (vai virar sua conta do GitHub).
[ ] Uma ideia, mesmo vaga, de tema para o seu projeto autoral (§1.5). Você decide hoje ou até a próxima aula.
Você não precisa saber Git, terminal ou Node.js. Tudo isso começa do zero hoje.
O que esta trilha cobre (a mesma ementa da disciplina em que esta trilha nasceu, Desenvolvimento Web): arquitetura de uma aplicação web; tecnologias de back-end; tecnologias de front-end; bancos de dados para web.
Objetivo geral: ao concluir esta trilha você projeta, implementa e publica uma aplicação web completa — interface acessível e responsiva, comportamento dinâmico em JavaScript, API própria em Node.js/Express, autenticação com conta Google e operações de CRUD com persistência.
A palavra que resume o Nível 2 é profundidade. No Nível 1 você aprendeu as três linguagens da plataforma. Aqui você aprende a arquitetura em que elas vivem: como um site é servido, como duas máquinas conversam por HTTP, como o código sai da sua pasta e vai parar em um endereço público, e como o servidor deixa de ser um mistério e passa a ser código seu.
Apresentação; arquitetura web; ambiente de desenvolvimento e Git
02
Introdução ao desenvolvimento web moderno
03
Revisão de HTML: layout, links e formulários
04
Frameworks CSS
05
Animação e SVG
06
Acessibilidade e ARIA
07
Revisão de JavaScript: objetos, funções, eventos e DOM
08
Funções, arrow functions, callbacks e vetores
09
Promises e async/await
10
AJAX, JSON e Single Page Application
11
Introdução ao Express
12
Express estruturado e middlewares
13
Rotas e controladores
14
Autenticação com Google (front e back)
15
CRUD com front-end assíncrono (AJAX/SPA)
16
CRUD completo com autenticação Google
O conteúdo abaixo é o mesmo em qualquer turma ou semestre, e serve igualmente a quem estuda por conta própria, sem vínculo com nenhuma turma.
⚠️ Atenção
Se você depende da rede do laboratório para subir seu trabalho, não deixe para o último minuto: ela tende a cair justamente quando mais gente está enviando ao mesmo tempo. Rode git push assim que terminar cada parte, não só no fim da aula.
Website client-side em HTML e CSS: HTML semântico, layout responsivo, framework CSS, animação/SVG, acessibilidade.
2
Evolução do site com JavaScript: validação de formulários, DOM e eventos, programação assíncrona, SPA com AJAX/JSON.
3
Aplicação full-stack com Node.js e Express: rotas e controladores, autenticação Google, CRUD com persistência, front-end assíncrono.
Os três marcos são individuais, práticos e recaem sobre o mesmo projeto autoral. Cada um vive no mesmo repositório público do GitHub — não .zip, não pasta no Drive, não print de tela. O repositório é o produto, e o histórico de commits faz parte dele.
Esta trilha soma cerca de 60 h de estudo: aproximadamente 45 h acompanhando as 16 aulas — a construção guiada do projeto fio-condutor — e 15 h de prática independente ligada ao projeto autoral, em atividades de cerca de 1 h por aula.
⚠️ Atenção
A prática independente não é bônus: ela prepara direto o marco da unidade. Quem pula a atividade de uma aula chega na seguinte sem o pré-requisito, porque cada aula assume que a anterior foi concluída.
O projeto fio-condutor é o Café Cerrado, construído passo a passo ao longo das aulas. É uma cafeteria fictícia de Sinop/MT, que torra grãos do cerrado mato-grossense. Na Unidade 1 ela é um site estático publicado no GitHub Pages; na Unidade 2 ganha cardápio dinâmico, busca, filtros e navegação SPA; na Unidade 3 ganha uma API em Express, login com conta Google e um CRUD de produtos com persistência. Digite o código você mesmo — não copie e cole.
Em paralelo, você desenvolve um projeto autoral com a mesma arquitetura e um domínio diferente. Os marcos são sobre o projeto autoral.
Exemplos de temas que funcionam: catálogo de plantas do Pantanal, agenda de quadras esportivas, mural de estágios do curso, brechó, controle de pescarias no Teles Pires, loja de peças de bicicleta, biblioteca de uma escola, feira de produtores locais.
O critério para saber se um tema serve é objetivo: ele tem uma lista de coisas? Produtos, plantas, quadras, vagas, peças, livros. O semestre inteiro gira em torno de uma coleção de itens que é exibida, filtrada, criada, editada e apagada. Se o seu tema não tem uma lista clara, troque agora — e não na Unidade 3.
💡 Dica
Escolha um domínio sobre o qual você tenha conteúdo real: nomes, preços, descrições, fotos. O erro clássico é escolher "site de uma empresa" e travar na hora de escrever a terceira frase. Tema concreto gera projeto melhor — e mais fácil de defender quando alguém perguntar como funciona.
💻 Mão na massa — passo a passo guiado no Café Cerrado. Todo mundo faz junto, digitando (não colando).
🧪 Laboratório — exercícios práticos em três níveis: A (fixação), B (aplicação) e C (desafio para quem termina antes).
🏆 Desafios — extras opcionais, com estrelas de dificuldade: ⭐ (1–2 h), ⭐⭐ (uma tarde), ⭐⭐⭐ (um fim de semana). Não são obrigatórios, mas aprofundam a aula e ficam bem no portfólio.
🏠 Atividade assíncrona (1 h) — a tarefa da semana, ligada ao projeto autoral.
Uma rotina que funciona: leia os objetivos antes da aula; digite o código durante a aula; faça o Laboratório A no mesmo dia; faça o Nível B e a atividade assíncrona ao longo da semana; encare o Nível C e os Desafios se sobrar fôlego.
Toda entrega desta trilha é um link de repositório público no GitHub. Isso não é burocracia: é a forma como software é entregue no mercado. Um repositório bem cuidado — com README.md que explica o projeto, commits com mensagens legíveis e o site publicado — é a peça de portfólio mais barata que existe. Você vai terminar esta trilha com duas: o Café Cerrado e o seu projeto autoral.
A Web inteira funciona sobre um modelo de duas partes e uma regra:
Cliente: quem pede. Normalmente o navegador, mas também pode ser um aplicativo de celular, um script no terminal (curl), outro servidor ou — cada vez mais — um agente de IA.
Servidor: quem responde. Um programa que fica permanentemente escutando em uma porta, esperando requisições.
A regra: o cliente sempre inicia. Em HTTP clássico o servidor nunca manda nada de forma espontânea; ele só responde ao que foi pedido.
Na Unidade 3 você vai escrever o retângulo da direita. Até lá, ele é um serviço que outra pessoa mantém — o GitHub Pages, a partir de hoje.
2.2 O que acontece quando você digita um endereço¶
Esta sequência é uma das perguntas mais frequentes em entrevista técnica. Aprenda a narrá-la.
Passo 1 — Análise da URL. O navegador separa o endereço em partes (§3.4) e descobre qual protocolo usar e com quem falar.
Passo 2 — Resolução DNS. O nome cafecerrado.com.br não serve para roteamento; a rede trabalha com endereços IP. O navegador consulta o DNS (Domain Name System) para traduzir o nome em um IP, passando antes por caches: cache do navegador → cache do sistema operacional → arquivo hosts → servidor DNS do provedor.
Passo 3 — Conexão TCP. Com o IP em mãos, o navegador abre uma conexão TCP, normalmente na porta 80 (HTTP) ou 443 (HTTPS).
Passo 4 — Handshake TLS (só em HTTPS). Cliente e servidor negociam a criptografia, o servidor apresenta seu certificado digital e ambos combinam uma chave de sessão. É o que produz o cadeado na barra de endereço.
Passo 5 — Requisição HTTP. O navegador envia um texto (§3.1).
Passo 6 — Processamento no servidor. Se o recurso for um arquivo estático, o servidor apenas o lê do disco. Se for dinâmico, executa código, possivelmente consulta um banco e monta a resposta na hora.
Passo 7 — Resposta HTTP. Uma linha de status, cabeçalhos, uma linha em branco e o conteúdo.
Passo 8 — Renderização e sub-requisições. O navegador interpreta o HTML e dispara novas requisições para cada recurso referenciado: folhas de estilo, scripts, imagens, fontes. Cada uma repete os passos 5 a 7.
📌 Vale gravar
A ordem das oito etapas e o papel de cada uma. Os pontos mais cobrados: DNS traduz nome em IP (não "acha o site"); TLS é o que torna o HTTP seguro (HTTPS = HTTP + TLS); e uma única página dispara dezenas de requisições, não uma.
Abrir uma página comum dispara de 30 a 200 requisições. Isso muda a forma de pensar em desempenho: não adianta otimizar o HTML se a página baixa 4 MB de imagens e cinco fontes. Você vai medir isso na §7.6 e voltar ao tema na Aula 06, quando o Lighthouse entrar em cena.
HTTP (HyperText Transfer Protocol) é um protocolo de texto. Isso é uma decisão de projeto: qualquer pessoa consegue ler uma requisição sem ferramenta especial. Veja.
GET/cardapio.htmlHTTP/1.1Host:cafecerrado.exemplo.brUser-Agent:Mozilla/5.0 (X11; Linux x86_64) Chrome/139.0Accept:text/html,application/xhtml+xmlAccept-Language:pt-BR,pt;q=0.9Connection:keep-alive
A primeira linha diz o que se quer (GET), onde (/cardapio.html) e em qual versão do protocolo. As demais são cabeçalhos (headers): metadados no formato Nome: valor.
Três partes: linha de status (200 OK), cabeçalhos, e — depois de uma linha em branco — o corpo. Essa linha em branco é o que separa metadados de conteúdo. Guarde: na Unidade 3, quando você escrever res.status(201).json(produto) no Express, é exatamente esse texto que sai pela rede.
Atualizar um recurso existente (inteiro / parcialmente).
PUT /api/produtos/3
DELETE
Remover um recurso.
DELETE /api/produtos/3
Essas quatro operações são exatamente o CRUD (Create, Read, Update, Delete) que você vai implementar nas Aulas 13, 15 e 16. Repare que o método já diz a intenção: o caminho /api/produtos é o mesmo, o que muda é o verbo.
⚠️ AtençãoGET deve ser seguro: não pode alterar estado no servidor. Uma rota GET /apagar-produto/3 funciona tecnicamente e é um erro de projeto grave — buscadores, pré-carregadores do navegador e agentes de IA seguem links GET sozinhos, e apagariam seus produtos sem que ninguém clicasse em nada.
301 Moved Permanently, 302 Found, 304 Not Modified
4xx
Erro do cliente
400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found
5xx
Erro do servidor
500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable
Os que você mais vai usar (e devolver) ao longo desta trilha:
Código
Quando aparece
O que fazer
200 OK
Leitura bem-sucedida
Nada; é o caminho feliz
201 Created
Recurso criado por um POST
Devolver o recurso criado no corpo
400 Bad Request
Dados inválidos enviados pelo cliente
Explicar no corpo qual campo está errado
401 Unauthorized
Falta autenticação (Aula 14)
Cliente precisa fazer login
403 Forbidden
Autenticado, mas sem permissão (Aula 16)
Cliente está logado, mas o item é de outra pessoa
404 Not Found
Recurso inexistente
Verificar o caminho ou o id
500 Internal Server Error
Exceção não tratada no servidor
Ler o log do servidor: o erro é seu
💡 Dica
Mnemônico que resolve 90% das dúvidas: 4xx é culpa de quem pediu; 5xx é culpa de quem respondeu. Um 404 diz "você pediu algo que não existe". Um 500 diz "eu quebrei ao tentar responder".
Qual serviço na máquina (80 = HTTP, 443 = HTTPS; omitida quando é a padrão)
4
/cardapio/cafes
Caminho
Qual recurso dentro do servidor
5
?categoria=espresso&ordem=preco
Query string
Parâmetros chave=valor separados por &
6
#promocoes
Fragmento
Posição dentro da página. Nunca é enviado ao servidor
Dois desses elementos viram protagonistas mais adiante: a query string é como a busca do cardápio vai conversar com a API (GET /api/produtos?q=cafe, Aula 13), e o fragmento é o que faz a navegação SPA funcionar sem recarregar a página (#/cardapio, Aula 10). Guarde os dois.
HTTP é stateless: cada requisição é independente e o servidor, por si só, não lembra o que aconteceu na anterior. Isso parece uma limitação e é, na verdade, o que permitiu a Web escalar — qualquer servidor de um conjunto pode atender qualquer requisição.
Login, carrinho e sessão são construídos por cima disso: o cliente reenvia, a cada requisição, uma credencial (um cookie ou um token no cabeçalho Authorization). É exatamente o que você vai implementar na Aula 14, quando o Café Cerrado passar a exigir login do Google para escrever dados.
🔬 Investigue
Abra o site da sua universidade ou escola (ou outro site grande de sua preferência), pressione F12, vá à aba Network, marque Disable cache e recarregue com Ctrl+F5. Observe quatro coisas: (1) quantas linhas apareceram — cada uma é uma requisição completa dos passos 5 a 7 da §2.2; (2) a coluna Type (document, stylesheet, script, png, font); (3) clique na primeira linha, abra Headers e localize Content-Type, Server e o status; (4) na barra inferior, leia o total de requisições e o peso transferido. Anote os números: você vai comparar com os do seu próprio site no fim da aula.
Uma regra que vale para sempre: o navegador nunca fala com o banco de dados. Se ele falasse, qualquer usuário poderia ler e apagar tudo, porque todo código front-end é público — o usuário pode abrir o DevTools, ler seu JavaScript e alterar valores. Validação no cliente existe para conforto; validação no servidor existe para segurança. Você vai ver essa diferença doer na Aula 13.
⚠️ Atenção
"Dinâmico", em arquitetura, significa HTML montado no servidor. Uma página estática cheia de JavaScript, animações e busca continua sendo estática do ponto de vista arquitetural — é o caso do Café Cerrado até a Aula 10. É por isso que ele pode ficar hospedado de graça no GitHub Pages durante duas unidades inteiras.
Durante trinta anos a resposta era óbvia: do outro lado da requisição havia uma pessoa olhando uma tela. Isso mudou.
Relatórios de tráfego da web (Imperva/Thales, Cloudflare Radar) mostram que mais da metade do tráfego web já é gerada por máquinas — a primeira vez em uma década em que os bots ultrapassaram os humanos. O crescimento vem principalmente de agentes de IA: em 2025 o tráfego de bots de IA cresceu quase 190%, enquanto o tráfego humano cresceu cerca de 3%. O volume de rastreamento para treinar modelos chegou a várias vezes o volume de rastreamento dos buscadores tradicionais.
E essas máquinas já são clientes que pagam:
x402 (proposto pela Coinbase) ressuscitou o código de status HTTP 402 Payment Required, reservado desde os anos 90 e nunca usado de verdade. O agente faz uma requisição, recebe 402 com o preço, paga e refaz a requisição — sem checkout, sem cadastro, sem humano.
Redes de pagamento para agentes (Mastercard Agent Pay, Visa Trusted Agent Protocol, AP2 do Google) dão identidade e meio de pagamento a programas.
MCP (Model Context Protocol), aberto em 2024 e doado à Linux Foundation, padronizou a forma de conectar assistentes de IA a dados e ferramentas. Milhares de serviços já expõem "portas para agentes" em vez de telas para humanos.
🧠 Você sabia?
O código 402 Payment Required está na especificação do HTTP desde 1997 marcado como "reservado para uso futuro". Ficou quase três décadas sem uso prático — um número guardado à espera de um caso que só apareceu quando as máquinas viraram compradoras. Vale como lembrete de que o HTTP que você está aprendendo é um protocolo vivo: os mesmos verbos e os mesmos códigos de 1997 continuam sustentando o que se inventa hoje.
Por que isso importa para o que você vai construir aqui? Porque muda o que é "a fachada" de um sistema. O agente não quer o botão azul: ele quer o dado, em JSON, por uma API previsível e documentada. A interface humana continua importando — mas ela passa a ser uma das saídas, não a única.
A boa notícia é que tudo que você vai aprender aqui serve aos dois públicos. HTML semântico (Aula 03) é o que permite a uma máquina entender a estrutura da página. HTTP bem usado (§3) é o que torna a API previsível. JSON (Aula 10) é o formato que os dois lados falam. Express e rotas REST (Unidade 3) são a porta de entrada. Ninguém precisa mudar de assunto: precisa aprender o assunto direito.
A pergunta não é "pode usar IA?". Pode. A pergunta é "como usar bem?".
A favor de usar. Assistentes de código escrevem o repetitivo — estrutura inicial, testes, documentação, refatorações mecânicas — e liberam você para o que decide o resultado: arquitetura, modelagem de dados, experiência do usuário. Pesquisas de mercado apontam que a grande maioria dos desenvolvedores já usa algum assistente ao menos uma vez por mês, e que quem usa diariamente entrega bem mais mudanças por semana. Ficar de fora não é pureza técnica; é desvantagem competitiva.
Contra usar sem critério. Um estudo controlado da METR (2025) mediu desenvolvedores experientes trabalhando em bases de código que dominavam: quando usaram IA, ficaram em média 19% mais lentos — e ainda assim acharam que tinham ficado mais rápidos. Ferramenta sem domínio do problema atrapalha e dá a sensação contrária.
Então por que estudar HTTP, DOM e Express a fundo, se a IA escreve isso? Pelo mesmo motivo pelo qual você estuda Arquitetura de Computadores sem programar em Assembly no dia a dia: para entender o que acontece por baixo quando quebra. A IA escreve o código; quem julga se ele está correto, seguro e bem arquitetado é você. E só revisa bem quem entende a base.
Use IA como apoio, não como atalho: peça para ela explicar, não para resolver o que você ainda não entende. O teste real é simples — se você não consegue explicar uma linha do seu código, ela ainda não é sua.
💡 Dica
Um uso honesto e produtivo desde já: peça ao assistente para explicar um erro do terminal, não para "consertar o projeto". Cole a mensagem literal, pergunte o que ela significa e o que a causa. Você aprende a ler a mensagem — que é a habilidade que vai sobrar quando a ferramenta mudar de nome.
Sobre o Node.js: baixe sempre a versão marcada como LTS (Long Term Support). LTS significa suporte prolongado e estabilidade — é a versão que se usa em produção. Nesta trilha usamos o Node.js 22 LTS.
Instalar não basta: é preciso confirmar que o sistema encontra os programas. Abra um terminal (no VS Code, Ctrl+') e execute:
Terminal
node-v
npm-v
git--version
code--version
Saída esperada (os números podem variar um pouco):
Texto
v22.13.0
10.9.2
git version 2.43.0
1.96.2
Se algum comando responder command not found (Linux/macOS) ou não é reconhecido como um comando interno ou externo (Windows), o programa foi instalado mas não está no PATH — normalmente basta fechar e abrir o terminal de novo, porque o PATH só é lido na abertura. Se persistir, reinstale marcando a opção "adicionar ao PATH".
🔎 Por baixo do capôPATH é uma variável de ambiente com uma lista de pastas. Quando você digita node, o sistema não procura no computador inteiro: ele percorre essa lista, na ordem, procurando um executável com esse nome. É por isso que a instalação precisa "adicionar ao PATH" e por que o terminal aberto antes da instalação não enxerga o programa novo — ele guardou a lista antiga.
Instale-as com Ctrl+Shift+X. Depois, em File → Preferences → Settings, procure format on save e marque a opção: seu código passa a ser formatado a cada Ctrl+S. Isso elimina uma categoria inteira de discussão inútil sobre indentação — inclusive com você mesmo daqui a três semanas.
Regra permanente: no VS Code, use File → Open Folder e abra a pasta raiz do projeto. Nunca abra um .html solto. Live Server, caminhos relativos, terminal integrado e Git dependem de haver uma pasta raiz — e metade dos problemas de aula 1 vem de ignorar isso.
Isso é controle de versão manual — e ele falha em tudo o que importa: você não sabe o que mudou entre duas versões, nem por quê, nem quando, nem como voltar a um estado intermediário sem quebrar o resto.
O Git resolve isso registrando fotografias do projeto inteiro ao longo do tempo. Cada fotografia é um commit: o estado completo dos arquivos, mais autor, data e uma mensagem explicando a intenção. Com isso você consegue voltar no tempo, comparar duas versões linha a linha, descobrir quando um bug entrou e trabalhar com segurança porque nada se perde.
working directory staging area repositório
┌────────────────┐ add ┌───────────────┐ commit ┌──────────────┐
│ MODIFICADO │ ─────> │ PREPARADO │ ───────> │ VERSIONADO │
│ você editou │ │ vai entrar no │ │ histórico │
│ o arquivo │ │ próximo commit│ │ permanente │
└────────────────┘ └───────────────┘ └──────────────┘
A staging area (área de preparação) é o que confunde no começo e o que dá poder ao Git: ela deixa você escolher quais mudanças entram no próximo commit. Editou cinco arquivos mas só três formam uma mudança coerente? Prepare esses três, faça o commit, depois cuide do resto. Commits coerentes são o que torna o histórico legível.
8.3 Configuração inicial (uma única vez por máquina)¶
Terminal
gitconfig--globaluser.name"Seu Nome Completo"
gitconfig--globaluser.email"seu-email@exemplo.com"
gitconfig--globalinit.defaultBranchmain
gitconfig--globalcore.editor"code --wait"
Confira o que ficou gravado e de onde veio cada configuração:
Terminal
gitconfig--list--show-origin
⚠️ Atenção
Use o mesmo e-mail da sua conta do GitHub. É por ele que o GitHub associa os commits ao seu perfil. Com um e-mail diferente, seus commits aparecem como se fossem de um desconhecido — e, em uma entrega avaliada, isso vira dúvida sobre autoria.
# 1. transformar a pasta atual em um repositório Git
gitinit
# 2. ver o que mudou e em que estado está
gitstatus
# 3. preparar arquivos para o commit
gitaddindex.html# um arquivo específico
gitadd.# tudo que mudou na pasta atual e subpastas# 4. registrar a fotografia com uma mensagem descritiva
gitcommit-m"Cria a página inicial do Café Cerrado"# 5. ver o histórico
gitlog--oneline
Dois comandos que não estavam no roteiro clássico e que você vai usar muito:
Terminal
# ver exatamente o que mudou, linha a linha, antes de preparar
gitdiff
# descartar as alterações não preparadas de um arquivo
gitrestoreindex.html
git status é o comando mais importante da lista. Ele não só mostra o estado como sugere o comando seguinte. Quando estiver perdido, rode git status e leia com calma — a resposta costuma estar ali.
8.5 Mensagens de commit que servem para alguma coisa¶
Uma mensagem de commit responde à pergunta "o que este commit faz?". Escreva no imperativo, como se completasse a frase "Este commit…".
Ruim
Boa
alterações
Cria a estrutura inicial do site
aula 3
Adiciona formulário de contato com validação nativa
arrumei
Corrige quebra do menu em telas menores que 480px
.
Remove imagens não utilizadas da pasta img
Commits pequenos e frequentes valem mais do que um commit gigante no fim da semana. Regra prática: um commit por ideia concluída. Terminou o cabeçalho? Commit. Terminou o rodapé? Commit.
🔎 Por baixo do capô
Um commit não é "a diferença desde o anterior": é um instantâneo completo da árvore de arquivos, identificado por um hash (aquela sequência tipo a3f9c21) calculado a partir do conteúdo, do autor, da data, da mensagem e do commit anterior. Como o hash depende do commit anterior, alterar qualquer coisa no passado muda todos os hashes seguintes — é por isso que o histórico do Git é praticamente à prova de adulteração, e é por isso que git log conta uma história confiável.
Nem tudo deve entrar no repositório: arquivos gerados, dependências baixadas, configurações da sua máquina e — nunca esqueça — segredos. O arquivo .gitignore, na raiz do projeto, lista o que o Git deve ignorar:
Texto
# Sistema operacional
.DS_Store
Thumbs.db
# Editor
.vscode/
# Node.js (a partir da Unidade 3)
node_modules/
# Segredos: chaves e senhas nunca entram no repositório
.env
Duas dessas linhas evitam desastres reais. node_modules/ costuma ter dezenas de milhares de arquivos e é reconstruído com um npm install — versionar isso é entupir o repositório à toa. E .env é onde, na Aula 14, ficará o seu Client ID do Google: um arquivo desses vazado em repositório público é o tipo de erro que rende notícia.
O Git é a ferramenta; o GitHub é um serviço que hospeda repositórios Git na internet. Ele dá três coisas ao mesmo tempo: backup, portfólio público e o canal de entrega desta trilha.
Terminal
# conectar o repositório local ao remoto criado no GitHub
gitremoteaddoriginhttps://github.com/SEU-USUARIO/cafe-cerrado.git
gitbranch-Mmain
gitpush-uoriginmain
# nos próximos envios, basta:
gitpush
O que cada linha faz:
git remote add origin URL cadastra um apelido (origin) para o endereço do repositório remoto.
git branch -M main renomeia a branch atual para main, que é o nome padrão no GitHub.
git push -u origin main envia os commits e memoriza a associação; o -u é o que permite escrever só git push depois.
E, para trazer para a máquina um repositório que já existe (o caso de trocar de computador ou usar o laboratório):
💡 Dica
O GitHub não aceita mais senha da conta na linha de comando. Ao pedir autenticação, use um Personal Access Token (em Settings → Developer settings → Personal access tokens) no lugar da senha, ou configure uma chave SSH. No Windows, o Git Credential Manager instalado junto com o Git abre uma janela de login do navegador e resolve isso sozinho.
Todo repositório público do GitHub pode virar um site estático publicado, de graça:
No repositório, acesse Settings → Pages.
Em Source, escolha Deploy from a branch, a branch main e a pasta raiz (/ (root)).
Salve. Em alguns minutos o site estará em https://SEU-USUARIO.github.io/cafe-cerrado/.
A partir daí, cada git push republica o site automaticamente. Esse é o seu primeiro fluxo de deploy: você edita, commita, envia, e o mundo vê. Guarde a sensação — na Unidade 3 o mesmo raciocínio vale para uma API, com algumas camadas a mais.
💻 Mão na massa — o repositório cafe-cerrado no ar¶
Objetivo do bloco: sair da aula com o Café Cerrado publicado em um endereço público, versionado, com dois commits e um README.md decente.
Crie, em um lugar que você encontre depois (por exemplo Documentos/dev-web/), a pasta cafe-cerrado. Abra o VS Code e use File → Open Folder apontando para ela.
Texto
dev-web/
├── cafe-cerrado/ ← projeto fio-condutor (construído ao longo das aulas)
└── meu-projeto/ ← projeto autoral (tema seu, mesma arquitetura)
⚠️ Atenção
Nomes de arquivos e pastas em minúsculas, sem espaços, sem acentos, com hífen separando palavras: cafe-cerrado, nunca Café Cerrado. Servidores Linux — inclusive o do GitHub Pages — diferenciam maiúsculas de minúsculas, e o que funciona no seu Windows quebra ao publicar.
Crie o arquivo index.html na raiz da pasta. Digite (não cole):
cafe-cerrado/index.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Café Cerrado — cafeteria em Sinop/MT</title></head><body><h1>Café Cerrado</h1><p>Cafeteria de grãos torrados do cerrado mato-grossense, em Sinop/MT.</p><p>Site em construção no Nível 2 do WebLab (Desenvolvimento Web).</p></body></html>
O nome index.html não é decorativo: é a convenção que todo servidor web segue para decidir o que entregar quando alguém pede a pasta em vez de um arquivo. Pedir https://exemplo.br/ entrega https://exemplo.br/index.html.
Abra a página com o Live Server (botão Go Live, canto inferior direito) e confirme que os acentos aparecem corretos. Leia a URL que abriu — algo como http://127.0.0.1:5500/index.html — com a §3.4 na cabeça: esquema http, host 127.0.0.1, porta 5500, caminho /index.html. Você acabou de subir um servidor web na sua própria máquina.
O README.md é a primeira coisa que o GitHub mostra a quem abre o repositório — e a primeira coisa que qualquer pessoa lê para decidir se vale a pena continuar explorando o projeto. Crie-o na raiz:
cafe-cerrado/README.md
Markdown
# Café Cerrado
Site da cafeteria fictícia **Café Cerrado** (Sinop/MT), construído aula a aula
no Nível 2 do WebLab (Desenvolvimento Web).
## O projeto
Uma cafeteria que torra grãos do cerrado mato-grossense. O site apresenta a
casa, o cardápio e um canal de contato. Ao longo do semestre ele evolui de
página estática para aplicação com API própria.
## Site publicado
https://SEU-USUARIO.github.io/cafe-cerrado/
## Tecnologias
HTML5 e CSS3. JavaScript entra na Unidade 2; Node.js e Express, na Unidade 3.
## Como executar localmente1. Clone o repositório.
2. Abra a pasta no VS Code.
3. Clique em "Go Live" (extensão Live Server).
## Autoria
Seu Nome — Desenvolvimento Web.
Troque SEU-USUARIO e Seu Nome pelos seus. O .md é Markdown, a mesma linguagem em que estas aulas são escritas: # faz título, **texto** deixa em negrito, - faz lista.
No terminal integrado do VS Code (Ctrl+'), confirme que você está na pasta certa e execute:
Terminal
gitinit
gitstatus
gitadd.
gitstatus
gitcommit-m"Cria a estrutura inicial do site do Cafe Cerrado"
gitlog--oneline
Rode git statusduas vezes, como está acima, e compare as saídas. Na primeira, os arquivos aparecem em vermelho sob Untracked files; na segunda, em verde sob Changes to be committed. Você acabou de ver a staging area da §8.2 funcionando.
A saída de git log --oneline deve ser parecida com:
Texto
7c1f4ab (HEAD -> main) Cria a estrutura inicial do site do Cafe Cerrado
Esse 7c1f4ab é o início do hash do commit. É o endereço permanente desta fotografia.
Não marque "Add a README file", "Add .gitignore" nem "Choose a license" — você já criou o que precisa localmente, e marcar essas opções cria commits no remoto que vão conflitar com os seus.
Clique em Create repository e copie a URL exibida.
Repita os passos 1 a 7 na pasta meu-projeto, com o seu tema: nome do projeto no <title> e no <h1>, um parágrafo dizendo o que é e para quem é, um README.md com o tema e as páginas que você pretende ter. Repositório público, GitHub Pages ligado.
A partir de hoje a regra é: o que o Café Cerrado ganha em aula, o seu projeto ganha em paralelo.
A4. Diga qual método HTTP e qual código de status você usaria em cada situação: (a) listar os produtos do cardápio; (b) cadastrar um produto novo com sucesso; (c) pedir um produto que não existe; (d) enviar um formulário com o campo preço em branco; (e) o servidor lançou uma exceção não tratada.
A5. O que significa dizer que o HTTP é stateless? Cite uma consequência prática disso para uma tela de login.
A6. Explique os três estados de um arquivo no Git e diga qual comando move o arquivo de um estado para o outro.
A7. Reescreva estas mensagens de commit no padrão da §8.5: alterações, aula 1, arrumei o css, commit final agora vai.
A8. Cite três tipos de arquivo que não devem entrar em um repositório e explique o porquê de cada um.
A9. Um site estático e uma aplicação dinâmica devolvem HTML para o navegador. Qual é, então, a diferença entre os dois? Classifique: SIGAA, cardápio do Café Cerrado na Unidade 1, portfólio pessoal, loja virtual.
A10. O que o comando git push -u origin main faz? Explique cada uma das quatro partes (push, -u, origin, main).
B1. Use a aba Network do DevTools para comparar três sites: um portal de notícias, um e-commerce e o seu https://SEU-USUARIO.github.io/cafe-cerrado/. Para cada um, registre: número de requisições, peso total transferido, tempo até o Load e qual foi o maior recurso. Escreva um parágrafo levantando hipóteses para as diferenças.
Resultado esperado: uma tabela com três linhas e quatro medidas, mais um parágrafo de análise (por exemplo, "o portal fez 4× mais requisições por causa de anúncios e rastreadores").
Dica
Marque Disable cache antes de medir, senão a segunda visita vem do cache e distorce tudo. A barra inferior da aba Network resume "N requests | X MB transferred | Finish: Y s". Para achar o maior recurso, clique no cabeçalho da coluna Size para ordenar.
B2. Plante e resolva um erro: no seu index.html publicado, apague a linha <meta charset="UTF-8">, faça commit e push. Espere o Pages republicar e abra o site. Descreva o que aconteceu com os acentos e explique por quê. Depois, desfaça — e faça o commit da correção com uma mensagem que descreva o conserto.
Resultado esperado: dois novos commits no histórico — o que quebra e o que conserta, ambos com mensagens claras — mais uma explicação de 3 a 5 linhas sobre o papel do charset na interpretação dos bytes.
Dica
Sem a declaração de charset, o navegador precisa adivinhar a codificação e frequentemente escolhe uma tabela de um byte por caractere, exibindo ç no lugar de ç. Para desfazer antes do commit, git restore index.html devolve o arquivo ao último estado versionado.
B3. Escreva o histórico de commits de uma tarefa real. Adicione ao index.html do Café Cerrado, em quatro etapas independentes, cada uma com seu próprio commit: (1) um parágrafo com o endereço da cafeteria; (2) um parágrafo com o horário de funcionamento; (3) um link mailto: para contato; (4) o ano de fundação. Ao final, rode git log --oneline e confira se o histórico conta a história sozinho.
Resultado esperado: quatro commits, um por mudança, com mensagens no imperativo; git log --oneline legível por alguém que não viu o código.
Dica
Use git add index.html e git commit depois de cada alteração, não no fim. Se você fizer as quatro e commitar uma vez só, refaça o exercício — o objetivo é justamente sentir a diferença de granularidade.
B4. Investigue o certificado HTTPS do seu site publicado. Clique no cadeado do navegador e registre: quem emitiu o certificado, para qual domínio ele é válido, até quando vale. Explique em três linhas o que o Passo 4 da §2.2 tem a ver com esses dados.
Resultado esperado: três dados anotados e uma explicação ligando certificado, autoridade certificadora e a chave de sessão negociada no handshake TLS.
Dica
No Chrome: cadeado → A conexão é segura → O certificado é válido. O emissor é a autoridade certificadora; é ela que garante ao navegador que o servidor é mesmo quem diz ser. Compare com o certificado de outro site grande de sua escolha (um banco, uma universidade, um grande portal de notícias).
B5. Escreva um README.md decente para o seu projeto autoral, com: título, um parágrafo explicando o tema e o público, a lista de páginas previstas, as tecnologias, como executar localmente e o link do site publicado. Commit e push.
Resultado esperado: o README.md renderizado na página inicial do repositório, com pelo menos quatro seções e nenhum trecho copiado do modelo do Café Cerrado sem adaptação.
Dica
O GitHub renderiza Markdown: # e ## para títulos, - para listas, **negrito**, e links no formato [texto](url). Prévia no VS Code: Ctrl+Shift+V.
C1. Recuperação de desastre. Simule a perda da máquina: apague (de verdade, ou renomeie) a pasta local cafe-cerrado, clone o repositório do GitHub em outro diretório, confirme que os dois commits estão lá, faça uma alteração no README.md, commite e envie. Depois, responda por escrito: o que exatamente foi recuperado no git clone — só os arquivos atuais, ou o histórico inteiro? Comprove sua resposta com a saída de um comando.
Dica
git clone traz a pasta .git inteira, e é ela que guarda o histórico. Comprove com git log --oneline dentro do clone: se os dois commits originais aparecem com os mesmos hashes, você recuperou o histórico, não apenas os arquivos.
C2. Meça o custo do HTTPS. No terminal, use curl para medir os tempos de uma requisição ao seu site publicado e a um site qualquer em HTTP simples, comparando o tempo de resolução DNS, de conexão TCP e de handshake TLS. Monte uma tabela com os três tempos para cada site e escreva um parágrafo sobre onde o tempo é gasto.
Dica
curl aceita um formato de saída com variáveis de tempo: experimente curl -o /dev/null -s -w "dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} total=%{time_total}\n" https://SEU-USUARIO.github.io/cafe-cerrado/. Rode duas vezes seguidas e observe o efeito do cache de DNS na segunda.
Um recrutador abre o seu GitHub e olha um repositório por, em média, alguns segundos. Nesse tempo ele decide se você sabe trabalhar ou se apenas entregou uma tarefa. A diferença raramente está no código: está no README.md, no histórico de commits e em haver (ou não) um link funcionando. Pegue o repositório do seu projeto autoral e transforme-o em uma peça de portfólio.
Critérios de pronto
README.md com: título, descrição do tema em um parágrafo, público-alvo, lista de páginas previstas, tecnologias, instruções para executar localmente e o link do site publicado, funcionando.
Pelo menos seis commits, todos com mensagens no imperativo, nenhuma com menos de três palavras, nenhuma genérica (alterações, update, aula).
.gitignore presente e adequado ao projeto.
A descrição curta do repositório (campo About, no topo da página do GitHub) preenchida, com o link do site publicado no campo Website.
Um parágrafo, entregue junto, explicando por que você organizou os commits daquela forma.
Pistas
Leia três README.md de projetos populares no GitHub e anote o que os três têm em comum.
O campo About fica no canto superior direito da página do repositório, na engrenagem ao lado do nome.
Se o seu histórico já estiver ruim, não apague nada: faça os próximos commits bem feitos e explique a virada no parágrafo final. Histórico é biografia, não maquiagem.
git log --oneline é o teste final: leia a saída em voz alta. Se ela conta a história do projeto, está pronto.
O navegador esconde tudo o que a §2.2 descreve. Hoje você faz o trabalho dele na mão: dispara requisições HTTP sem navegador, lê a resposta crua e mede onde o tempo é gasto. A ferramenta é o curl, que já vem no Windows 10+, no macOS e na maioria das distribuições Linux. Ao final você deve conseguir explicar cada uma das oito etapas com evidência na tela.
Critérios de pronto
A saída de curl -v https://SEU-USUARIO.github.io/cafe-cerrado/ salva em arquivo, com anotações marcando: resolução do nome, conexão TCP, handshake TLS (protocolo negociado e emissor do certificado), requisição enviada (linhas com >) e resposta recebida (linhas com <).
A saída de curl -I no mesmo endereço, com a explicação, em uma linha cada, de pelo menos cinco cabeçalhos de resposta.
Uma requisição a um caminho inexistente do seu site e a interpretação do status recebido, comparada com o que o navegador mostra na mesma situação.
Uma comparação entre curl --http1.1 -I e curl --http2 -I no mesmo endereço: qual versão o servidor aceitou e como você sabe.
Um texto de dez linhas ligando cada evidência ao passo correspondente da §2.2, escrito como se você fosse explicar o processo para outra pessoa que não acompanhou este trecho.
Pistas
curl --version confirma a instalação e lista os protocolos suportados (procure HTTP2 na linha Features).
Em -v, linhas com * são informações da conexão; > é o que foi enviado; < é o que voltou.
Se houver redirecionamento (301/302), acrescente -L para seguir e observe as duas respostas em sequência.
A primeira linha da resposta (HTTP/2 200 ou HTTP/1.1 200 OK) já denuncia a versão negociada.
Toda resposta HTTP carrega pistas sobre a infraestrutura que a produziu: qual software serviu, se passou por uma CDN, quanto tempo o arquivo pode ficar em cache. Vire detetive: escolha quatro sites (o da sua universidade ou escola, um jornal, um e-commerce e o seu site publicado) e descubra, só pelos cabeçalhos e por consultas de DNS, como cada um é entregue.
Critérios de pronto
Para cada site, uma tabela com Server, Content-Type, Cache-Control e pelo menos um cabeçalho que revele CDN (cf-ray, x-served-by, via, x-cache).
A classificação de cada site em "servido direto" ou "servido via CDN", com a evidência que sustenta a conclusão.
O resultado de nslookup (ou dig) para cada domínio, com o IP resolvido, e uma observação sobre quantos IPs cada nome devolve.
Uma linha por site explicando o que o valor de Cache-Control significa na prática para quem visita a página duas vezes.
Uma conclusão de cinco linhas sobre por que sites grandes usam CDN.
Pistas
Aba Network → clique na primeira linha (o documento) → Headers → role até Response Headers.
Server ausente também é resposta: alguns sites escondem o software de propósito, por segurança.
Um nome que devolve vários IPs geralmente está atrás de balanceamento ou de uma CDN — compare os IPs consultando de redes diferentes (celular e Wi-Fi).
O valor real do Git não aparece quando tudo dá certo — aparece às 23h30 do dia da entrega, quando você apaga o arquivo errado. Este desafio é um treino de emergência: você vai quebrar o próprio projeto de quatro formas diferentes e recuperá-lo de quatro formas diferentes, documentando cada uma.
Critérios de pronto
Cenário 1: você editou um arquivo e quer descartar a edição antes de preparar. Recupere e documente o comando.
Cenário 2: você já rodou git add e quer tirar o arquivo da staging area sem perder a edição. Recupere e documente.
Cenário 3: você commitou uma mudança ruim que já foi enviada com push. Desfaça criando um commit que reverte, sem reescrever o histórico público, e explique por que reescrever seria pior.
Cenário 4: você apagou um arquivo há três commits e só percebeu agora. Encontre o commit em que ele existia e traga o arquivo de volta.
Um documento docs/git-socorro.md no seu repositório, com os quatro cenários, o comando de cada um, a saída obtida e uma frase explicando o que o comando faz.
Bônus: um quinto cenário com git switch -c — você começou a trabalhar em uma ideia arriscada e quer isolá-la em uma branch, sem sujar a main.
Pistas
Comece por git status: em quase todos os cenários ele sugere o comando certo na própria saída.
Para o cenário 3, procure na documentação a diferença entre git revert e git reset. Só um dos dois é seguro para histórico já publicado.
Para o cenário 4, git log --oneline -- caminho/do/arquivo lista só os commits que tocaram naquele arquivo; git checkout <hash> -- caminho/do/arquivo traz uma versão antiga de volta para a working directory.
O livro Pro Git tem um capítulo inteiro chamado "Desfazendo Coisas", em português, gratuito em https://git-scm.com/book/pt-br.
Parte 1 — Ambiente (20 min). Instale, na sua máquina pessoal, VS Code, Node.js 22 LTS e Git. Rode os quatro comandos de verificação da §7.2 e tire uma captura de tela do terminal com as quatro saídas visíveis.
Parte 2 — Projeto autoral (30 min). Crie o repositório público do seu projeto autoral no GitHub, seguindo os passos 1 a 7 do Mão na massa com o seu tema:
index.html com a estrutura mínima, <title>, <h1> e dois parágrafos apresentando o tema.
README.md no formato do exercício B5.
.gitignore.
Pelo menos dois commits com mensagens no imperativo.
GitHub Pages ligado e o site abrindo.
Parte 3 — Leitura dirigida (10 min). Se você tem acesso a uma biblioteca virtual pela sua instituição: QUEIRÓS & PORTELA, capítulo introdutório sobre a evolução e a arquitetura da Web; PUREWAL, capítulo 1, sobre o fluxo de trabalho do desenvolvedor (editor, terminal e Git). Anote duas ideias de cada texto que não apareceram nesta aula — elas voltam na discussão da próxima.
Critério de pronto: os dois links abrem (repositório público e site no ar); o git log mostra pelo menos dois commits com mensagens descritivas; a captura de tela mostra Node, npm, Git e VS Code respondendo com suas versões.
Guarde no seu repositório: o link do repositório do projeto autoral, o link do site publicado e a captura de tela do terminal. Sem .zip.
CHACON, S.; STRAUB, B. Pro Git, 2ª ed., gratuito em português: https://git-scm.com/book/pt-br — capítulos 1 e 2 cobrem tudo da §8; o capítulo "Desfazendo Coisas" salva entregas.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web: do front-end ao back-end, uma visão global. FCA, 2018 — capítulo introdutório: evolução e arquitetura da Web.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — conceitos de cliente-servidor e planejamento de projetos web.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — capítulo 1: o fluxo de trabalho do desenvolvedor.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — leitura de fôlego para quem quer ver onde a arquitetura desta aula escala.
Na próxima aula você entra no lado do cliente: o que o navegador faz com o código depois que a resposta HTTP chega, quem define os padrões da Web, como um site é servido de verdade e como organizar as pastas de um projeto. O index.html mínimo de hoje vira uma página com cabeçalho, navegação, conteúdo principal e rodapé, com uma folha de estilo própria — e cada git push continua publicando tudo sozinho.
Nível 2Unidade 1 · Web estática3 aulas de 50 min + 1 h EAD
Aula 02 — Introdução ao desenvolvimento web moderno
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 01 você acompanhou a requisição saindo do navegador e a resposta voltando do servidor. Hoje a câmera vira para o outro lado: o que acontece depois que a resposta chega — como o navegador transforma texto em pixels, quem decide as regras dessa transformação e como organizar um projeto para que ele funcione tanto na sua máquina quanto em um servidor real.
Na aula passada você montou o ambiente, aprendeu o ciclo do Git e publicou o Café Cerrado no GitHub Pages com um index.html de oito linhas. Hoje esse arquivo mínimo vira uma página de verdade — com cabeçalho, navegação, conteúdo principal e rodapé — e ganha a primeira folha de estilo do projeto. Cada git push continua republicando o site sozinho.
Checklist antes de começar:
[ ] VS Code com Live Server e Prettier funcionando; node -v, git --version respondendo no terminal.
[ ] Repositório cafe-cerrado no GitHub, com pelo menos dois commits e o GitHub Pages ligado.
[ ] O site em https://SEU-USUARIO.github.io/cafe-cerrado/ abrindo.
[ ] Repositório do projeto autoral criado, com o tema definido.
[ ] Do Nível 1: seletores CSS, o modelo de caixa e o que faz um <div>. Se estiver enferrujado, revise antes da Aula 03.
A Web nasceu entre 1989 e 1991, no CERN, das mãos de Tim Berners-Lee, combinando três invenções que continuam sustentando tudo o que você faz hoje: o HTML (a linguagem dos documentos), o HTTP (o protocolo de transporte, que você viu na Aula 01) e a URL (o endereçamento universal). Três coisas simples, encaixadas — e é isso que sobreviveu a três décadas de mudança.
Fase
O que caracteriza
Exemplo típico
Web 1.0 (anos 90)
Páginas estáticas, somente leitura; o usuário consome
Sites institucionais, portais de notícias
Web 2.0 (anos 2000)
Conteúdo gerado pelo usuário, interatividade, requisições em segundo plano
Redes sociais, wikis, blogs
Web moderna
Aplicações completas no navegador, APIs, mobile, tempo real
Webmail, mensageiros web, sistemas bancários
Do ponto de vista técnico, o que muda de uma fase para a outra é onde o HTML é montado e quem tem iniciativa:
Na Web 1.0, o servidor entrega um arquivo pronto e a conversa acaba.
Na Web 2.0, o JavaScript passa a fazer requisições em segundo plano e a alterar a página já carregada sem recarregá-la. É o que se chamou de AJAX, e é o assunto da Aula 10.
Na Web moderna, o servidor muitas vezes entrega só um esqueleto e dados (JSON), e o navegador monta a interface. É a arquitetura SPA — que você vai construir na Unidade 2 e sofisticar no Nível 3.
O Café Cerrado vai atravessar as três fases ao longo do curso, nessa mesma ordem. Hoje ele está firmemente na primeira: arquivos estáticos entregues pelo GitHub Pages.
A Web é a única plataforma de software relevante que não pertence a ninguém. Nenhuma empresa é dona do HTML. Isso é o que faz o seu site funcionar no Chrome, no Firefox, no Safari do iPhone de outra pessoa, em um leitor de tela e em um navegador que ainda vai ser lançado.
Organização
Cuida de
WHATWG
HTML e DOM, mantidos como living standard
W3C
CSS, acessibilidade (WCAG/ARIA) e diversas APIs da plataforma
Ecma International (TC39)
ECMAScript, a especificação da linguagem JavaScript, com edições anuais
IETF
Protocolos de rede, publicados como RFCs — HTTP, TCP/IP, TLS
O HTML deixou de ter versões. Depois do HTML5, não veio um "HTML6": a especificação virou um documento vivo, atualizado continuamente conforme navegadores implementam e a comunidade valida. Na prática, isso significa três coisas para você:
Não existe "meu site é HTML 5.2". Existe HTML, e existe o que os navegadores implementam hoje.
A pergunta certa nunca é "isso é padrão?", e sim "isso já funciona nos navegadores que meu público usa?".
A documentação de referência precisa ser viva também. Por isso a MDN (https://developer.mozilla.org/pt-BR/) é a fonte desta trilha, e não um livro impresso.
🧠 Você sabia?
No começo dos anos 2000 o W3C decidiu abandonar o HTML e apostar no XHTML 2.0, uma linguagem mais rígida e incompatível com o que já existia. Em 2004, gente da Opera, da Mozilla e da Apple discordou publicamente e fundou um grupo paralelo, o WHATWG, para continuar evoluindo o HTML de forma compatível com as páginas existentes. O grupo paralelo ganhou: o trabalho deles virou o HTML5, o XHTML 2.0 foi abandonado, e em 2019 o próprio W3C encerrou sua especificação de HTML e reconheceu o living standard da WHATWG como o documento oficial. A Web escolheu não quebrar o passado — e essa decisão é a razão de uma página de 1995 ainda abrir hoje.
Escrever "para o Chrome" é uma armadilha antiga e cara. O caminho seguro tem três passos, e você vai repeti-los o semestre inteiro:
Consulte a MDN antes de usar algo que você não domina. Cada página tem uma tabela de compatibilidade no final.
Valide a marcação no validador do W3C (https://validator.w3.org/). Ele aponta tags não fechadas, atributos inválidos e aninhamentos ilegais que o navegador silenciosamente "conserta" — de formas diferentes em cada navegador.
Teste em dois navegadores diferentes, sempre. Chrome e Firefox usam motores distintos (Blink e Gecko).
🔬 Investigue
Abra a página da MDN sobre um recurso qualquer de CSS — por exemplo gap (https://developer.mozilla.org/pt-BR/docs/Web/CSS/gap) — e role até o fim. Você vai encontrar uma tabela de compatibilidade com navegadores, com versões e datas de suporte. Agora repita com um recurso recente que você nunca usou (procure :has() na MDN). Compare as duas tabelas e responda: qual dos dois você usaria hoje em um site que precisa funcionar no celular antigo de um cliente? Essa consulta de 30 segundos é o que separa uma decisão técnica de um chute.
Você publicou um site na Aula 01 sem entender exatamente o que o GitHub Pages faz. Vamos abrir essa caixa, porque na Unidade 3 quem vai fazer esse trabalho é o seu próprio código Express.
Um servidor estático faz uma coisa só: mapeia o caminho da URL para um arquivo dentro de uma pasta e devolve o conteúdo. Pedir /cardapio.html entrega o arquivo cardapio.html.
Quando a URL termina em uma pasta (/ ou /promocoes/), não há arquivo nomeado. Aí entra a convenção mais antiga da Web: o servidor procura um arquivo chamado index.html dentro dessa pasta e entrega esse. É por isso que o seu site abre em https://SEU-USUARIO.github.io/cafe-cerrado/ sem você escrever o nome do arquivo.
Se não houver index.html, o servidor faz uma de duas coisas, dependendo da configuração: lista o conteúdo da pasta (directory listing) ou responde 404. O GitHub Pages responde 404.
3.2 Tipos MIME: como o navegador sabe o que recebeu¶
O navegador não decide pela extensão do arquivo. Ele obedece ao cabeçalho Content-Type da resposta, que carrega um tipo MIME:
Arquivo
Content-Type
O que o navegador faz
index.html
text/html; charset=UTF-8
Interpreta como documento e renderiza
css/estilo.css
text/css
Aplica como folha de estilo
js/app.js
text/javascript
Executa como script
img/logo.png
image/png
Decodifica e exibe como imagem
Isso explica um erro que você vai encontrar mais cedo ou mais tarde. Se o caminho da folha de estilo estiver errado, o servidor responde a página de erro 404 — que é HTML — e o navegador reclama:
Texto
Refused to apply style from 'http://127.0.0.1:5500/css/estilo.css' because its MIME
type ('text/html') is not a supported stylesheet MIME type, and strict MIME checking
is enabled.
Traduzindo: "você me mandou HTML e disse que era CSS; não vou aplicar". A causa quase nunca é o MIME em si — é o caminho errado. Verifique na aba Network se o estilo.css voltou com status 200 ou 404.
Caminho é a causa da maioria dos "funciona na minha máquina".
Notação
Significado
Exemplo
arquivo.html
Mesma pasta do arquivo atual
cardapio.html
pasta/arquivo.css
Subpasta a partir do arquivo atual
css/estilo.css
../arquivo.html
Uma pasta acima
../index.html
/arquivo.html
A partir da raiz do site, não da pasta
/cardapio.html
O ponto de partida de um caminho relativo é sempre a pasta do arquivo onde o caminho está escrito. E há uma armadilha específica do GitHub Pages: o seu site não fica na raiz do domínio, e sim em https://SEU-USUARIO.github.io/cafe-cerrado/. Um caminho absoluto como /css/estilo.css aponta para https://SEU-USUARIO.github.io/css/estilo.css — fora do seu projeto. Resultado: funciona no Live Server e quebra no site publicado.
⚠️ Atenção
Regra desta trilha: use sempre caminhos relativos (css/estilo.css, img/logo.png) enquanto o projeto for publicado em subpasta. E jamais use caminho de disco (C:\Users\...): isso não existe na Web — o servidor só conhece a pasta do site.
Dar duplo clique em um .html abre a página com o esquema file://, direto do disco, sem servidor nenhum. Parece funcionar — até parar de funcionar:
Requisições fetch para arquivos locais são bloqueadas por política de segurança (você sentiria isso na Aula 10).
Módulos ES (<script type="module">) não carregam.
Caminhos que começam com / apontam para a raiz do disco.
Nada do que você vê corresponde ao que o servidor real vai entregar.
Por isso a regra: sempre pelo Live Server, mesmo para uma página de uma linha. O botão Go Live sobe um servidor em http://127.0.0.1:5500 e você passa a testar no mesmo esquema em que o site vai viver.
🔎 Por baixo do capô127.0.0.1 é o endereço de loopback: todo computador o usa para se referir a si mesmo (o apelido é localhost). Quando o Live Server escuta na porta 5500 e o navegador pede http://127.0.0.1:5500/index.html, os pacotes nem chegam à placa de rede — o sistema operacional os devolve internamente. É por isso que funciona sem internet e é instantâneo. E é exatamente o que vai acontecer na Aula 11, quando o servidor na porta 3000 for código seu.
Recebida a resposta, o navegador executa um pipeline. Entender essas etapas é o que permite explicar por que uma página "pisca", por que ela demora a aparecer e por que um script na posição errada trava tudo.
Texto
HTML ──parsing──> DOM ┐
├──> Render Tree ──> Layout ──> Paint ──> Composite
CSS ──parsing──> CSSOM ┘
▲
JavaScript ─────────────────┘ (pode alterar DOM e CSSOM a qualquer momento)
Parsing do HTML → DOM. O navegador lê o HTML caractere a caractere e monta uma árvore de objetos: o DOM (Document Object Model). Cada tag vira um nó.
Parsing do CSS → CSSOM. As regras de estilo viram outra árvore, o CSSOM, com a cascata e a especificidade já resolvidas.
Render tree. DOM e CSSOM são combinados, descartando o que não é visível (por exemplo, o que tem display: none).
Layout (ou reflow). Cálculo da posição e do tamanho exatos de cada caixa, em pixels.
Duas regras de ouro, que explicam metade das dúvidas de desempenho:
CSS bloqueia a renderização. O navegador não pinta nada enquanto não tiver o CSSOM, porque pintar antes causaria um "flash" de página sem estilo. É por isso que a folha de estilo vai no <head> e precisa ser pequena.
JavaScript bloqueia o parsing. Quando o parser encontra um <script> sem atributos, ele para de montar o DOM, baixa e executa o script, e só então continua. Por isso a boa prática de usar o atributo defer (o script é baixado em paralelo e executado só depois que o DOM estiver pronto) ou de colocar o <script> antes de </body>.
A partir da Unidade 2 todo script do Café Cerrado será carregado com defer. Guarde o motivo agora; você vai medir o efeito no desafio ⭐⭐ desta aula.
Esta é a ideia mais importante da aula, e ela vai reaparecer em todas as aulas da Unidade 2:
O arquivo .html no disco é texto. Ele nunca muda sozinho.
O DOM é a árvore em memória que o navegador construiu a partir desse texto. Ele muda o tempo todo.
A aba Elements do DevTools mostra o DOM atual, não o arquivo. É por isso que você pode editar um texto ali, ver a página mudar e, ao recarregar, tudo voltar ao que era: você alterou a memória, não o disco.
🔬 Investigue
Abra o seu site publicado e faça três coisas em sequência. (1) Digite view-source:https://SEU-USUARIO.github.io/cafe-cerrado/ na barra de endereço: isso mostra o arquivo, exatamente como veio do servidor. (2) Pressione F12 e vá em Elements: isso é o DOM. Compare — por enquanto são idênticos. (3) No Console, execute document.body.append("Isto não está no arquivo") e pressione Enter. Olhe de novo o Elements (mudou) e o view-source (não mudou). Você acabou de ver, com evidência, a diferença entre o arquivo e o DOM. Recarregue a página e o texto some.
5. O trio fundamental e a separação de responsabilidades¶
O princípio que organiza os três é a separação de responsabilidades: estrutura no .html, estilo no .css, comportamento no .js. Não é preciosismo — é o que permite trocar o visual inteiro sem tocar no conteúdo, reaproveitar uma folha de estilo em vinte páginas e ter duas pessoas trabalhando no mesmo projeto sem se atropelar.
Um exemplo mínimo, completo, com os três arquivos integrados:
exemplo/index.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Separação de responsabilidades</title><linkrel="stylesheet"href="css/estilo.css"><scriptsrc="js/app.js"defer></script></head><body><h1>Bem-vindo ao Café Cerrado</h1><buttonid="botao-saudacao"type="button">Diga olá</button><pid="saida"></p></body></html>
constbotao=document.getElementById("botao-saudacao");constsaida=document.getElementById("saida");botao.addEventListener("click",()=>{saida.textContent="Olá! Este texto veio do JavaScript.";});
Três arquivos, três papéis, uma página. Repare que o <script> está no <head>com defer: o navegador começa a baixar o arquivo cedo, mas só o executa depois que o HTML inteiro virou DOM. Sem o defer, um script no <head> rodaria antes de o <button> existir e document.getElementById devolveria null — e null.addEventListener estoura TypeError. (A outra saída é pôr o <script> sem atributos logo antes de </body>, quando o botão já existe; defer no <head> é a forma preferida porque o download acontece em paralelo com a análise do HTML.) Você vai reencontrar exatamente esse bug na Aula 07 — e agora já sabe o nome dele.
Todo documento desta trilha precisa ter, no mínimo:
Elemento
Função
<!DOCTYPE html>
Declara HTML5 e liga o modo padrão de renderização
<html lang="pt-BR">
Elemento raiz; lang informa o idioma a leitores de tela e buscadores
<head>
Metadados — nada aqui aparece na página
<meta charset="UTF-8">
Codificação de caracteres; garante a acentuação
<meta name="viewport" …>
Adapta a página à largura real da tela do celular
<title>
Título na aba do navegador e nos resultados de busca
<meta name="description" …>
Resumo usado por buscadores e ao compartilhar o link
<link rel="stylesheet" …>
Conecta a folha de estilo
<body>
Todo o conteúdo visível
Três observações que valem gravar — entram no Marco 1 do projeto:
lang="pt-BR" não é decoração. É o que faz um leitor de tela pronunciar "pão" como português e não como inglês, e o que informa ao navegador qual dicionário usar na correção ortográfica de campos de formulário.
Sem <meta name="viewport"> o celular mente. O navegador móvel finge ter 980 px de largura e encolhe a página inteira, deixando o texto ilegível. Essa única linha é o pré-requisito de todo layout responsivo (Aula 04).
<title> é conteúdo, não enfeite. É o que aparece na aba, no histórico, nos favoritos e como primeiro link no resultado de busca. Escreva algo útil: Cardápio — Café Cerrado, não Documento.
Dentro do <body>, quatro elementos definem as regiões da página. São chamados de landmarks porque leitores de tela permitem saltar diretamente entre eles:
Elemento
Papel na página
<header>
Cabeçalho da página ou de uma seção: marca, título, navegação principal
<nav>
Bloco de links de navegação
<main>
Conteúdo principal — único por página, e não repetido entre páginas
<footer>
Rodapé: contato, créditos, links institucionais
O esqueleto típico:
HTML
<body><header><p>Nome do site</p><navaria-label="Navegação principal"><ul><li><ahref="index.html">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li></ul></nav></header><main><h1>Título da página</h1><p>Conteúdo principal.</p></main><footer><p>Rodapé com contato e créditos.</p></footer></body>
Um <div> não diz nada sobre o próprio conteúdo; um <nav> declara "isto é a navegação do site". A Aula 03 aprofunda a semântica (section, article, aside, hierarquia de títulos) e a Aula 06 mostra o efeito disso em um leitor de tela. Por hoje, basta o esqueleto — mas ele já entra certo.
💡 Dica
O aria-label no <nav> dá nome à região. Uma página com dois <nav> (principal e rodapé) sem rótulo produz, no leitor de tela, duas entradas idênticas chamadas "navegação". Com rótulo, viram "Navegação principal" e "Navegação do rodapé". Custo: um atributo.
7. CSS moderno de base: variáveis, reset e unidades¶
A folha de estilo do projeto começa hoje, e ela começa por três decisões que valem o semestre inteiro.
:root é o seletor do elemento raiz (<html>), então tudo declarado ali fica disponível na página inteira. Três motivos para começar assim:
Um lugar só para mudar. Trocar a paleta do site inteiro vira uma edição de cinco linhas.
Elas são vivas. Diferente das variáveis de pré-processadores (Sass, Less), que somem na compilação, as variáveis CSS existem em tempo de execução: o navegador as resolve na hora, elas respeitam a cascata e o JavaScript pode alterá-las com document.documentElement.style.setProperty('--cor-marca', '#c2703d'). É assim que se faz um seletor de tema claro/escuro sem recarregar a página.
Elas documentam a intenção.var(--cor-marca) diz mais do que #6f4e37.
⚠️ Atenção
Os dois hifens fazem parte do nome. --cor-marca declara; var(--cor-marca) lê. Escrever var(cor-marca) ou color: --cor-marca não dá erro visível: o navegador simplesmente ignora a declaração inválida e você fica olhando para uma cor que não mudou. Quando isso acontecer, abra o DevTools → Elements → aba Styles: a declaração inválida aparece riscada.
box-sizing: border-box faz com que width inclua padding e borda — o comportamento que todo mundo espera e que o padrão original não tem. body { margin: 0 } remove a margem de 8 px que os navegadores aplicam por conta própria. E as três linhas de img impedem que uma foto grande estoure o layout no celular.
Use rem como padrão. O motivo é de acessibilidade: quem aumenta o tamanho de fonte nas configurações do navegador — e isso é comum — vê o layout inteiro acompanhar. Layout medido em px ignora essa preferência.
Hoje o index.html de oito linhas vira uma página com landmarks, o projeto ganha as pastas que vai usar até o fim do semestre e nasce a folha de estilo base.
O .gitkeep é um arquivo vazio com um propósito curioso: o Git não versiona pastas, só arquivos. Uma pasta vazia simplesmente não existe para ele. Colocar um arquivo vazio dentro dela é a convenção usada para que a estrutura chegue ao repositório. js/ e img/ ganham conteúdo real nas próximas aulas.
Regras de nomenclatura, que valem para o semestre inteiro: minúsculas, sem espaços, sem acentos, hífen separando palavras (sobre-nos.html, nunca Sobre Nós.html). O servidor do GitHub Pages roda Linux e diferencia maiúsculas de minúsculas.
/* Café Cerrado — folha de estilo base Variáveis do projeto, reset mínimo e estilos das regiões da página. *//* ---------- 1. Variáveis do projeto ---------- */:root{--cor-marca:#6f4e37;--cor-marca-escura:#4a3325;--cor-destaque:#c2703d;--cor-fundo:#fdfaf6;--cor-superficie:#ffffff;--cor-texto:#2b2118;--cor-texto-suave:#5c4b3c;--borda-suave:rgba(111,78,55,0.15);--fonte-base:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;--espaco-1:0.5rem;--espaco-2:1rem;--espaco-3:2rem;--espaco-4:4rem;--largura-maxima:60rem;--raio:8px;}/* ---------- 2. Reset mínimo ---------- */*,*::before,*::after{box-sizing:border-box;}body{margin:0;font-family:var(--fonte-base);font-size:1rem;line-height:1.6;color:var(--cor-texto);background-color:var(--cor-fundo);}img{max-width:100%;height:auto;display:block;}a{color:var(--cor-marca);}/* ---------- 3. Cabeçalho e navegação ---------- */.cabecalho{display:flex;flex-wrap:wrap;gap:var(--espaco-2);align-items:center;justify-content:space-between;padding:var(--espaco-2)var(--espaco-3);background-color:var(--cor-marca);color:var(--cor-superficie);}.marca{margin:0;font-size:1.25rem;font-weight:700;letter-spacing:0.02em;}.navegacaoul{display:flex;gap:var(--espaco-2);margin:0;padding:0;list-style:none;}.navegacaoa{color:var(--cor-superficie);text-decoration:none;padding:var(--espaco-1);}.navegacaoa:hover,.navegacaoa:focus{text-decoration:underline;}/* ---------- 4. Conteúdo principal ---------- */main{max-width:var(--largura-maxima);margin:0auto;padding:var(--espaco-3)var(--espaco-2);}.destaque{padding:var(--espaco-4)var(--espaco-3);border-radius:var(--raio);background-color:var(--cor-superficie);border:1pxsolidvar(--borda-suave);text-align:center;}.destaqueh1{margin-top:0;color:var(--cor-marca-escura);font-size:2.25rem;}.destaquep{color:var(--cor-texto-suave);max-width:40rem;margin-left:auto;margin-right:auto;}.botao{display:inline-block;margin-top:var(--espaco-2);padding:var(--espaco-1)var(--espaco-3);border-radius:var(--raio);background-color:var(--cor-destaque);color:var(--cor-superficie);text-decoration:none;font-weight:600;}.botao:hover,.botao:focus{background-color:var(--cor-marca);}.sobre{margin-top:var(--espaco-4);}.sobreh2{color:var(--cor-marca-escura);}/* ---------- 5. Rodapé ---------- */.rodape{margin-top:var(--espaco-4);padding:var(--espaco-3)var(--espaco-2);background-color:var(--cor-marca-escura);color:var(--cor-superficie);text-align:center;}.rodapep{margin:var(--espaco-1)0;font-size:0.9rem;}
Substitua o conteúdo de index.html por este. Digite, não cole — e repare em cada tag nova.
cafe-cerrado/index.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="description"content="Café Cerrado: cafeteria de grãos torrados do cerrado mato-grossense, em Sinop/MT."><title>Café Cerrado — cafeteria em Sinop/MT</title><linkrel="stylesheet"href="css/estilo.css"></head><body><headerclass="cabecalho"><pclass="marca">Café Cerrado</p><navclass="navegacao"aria-label="Navegação principal"><ul><li><ahref="index.html">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li><li><ahref="contato.html">Contato</a></li></ul></nav></header><main><sectionclass="destaque"><h1>Café que nasce no cerrado</h1><p>
Torramos em Sinop grãos colhidos na Chapada dos Parecis e no médio-norte
de Mato Grosso. Cada lote tem origem, data de torra e ficha de sabor.
</p><aclass="botao"href="cardapio.html">Ver o cardápio</a></section><sectionclass="sobre"><h2>A casa</h2><p>
Somos uma cafeteria de bairro com torrefação própria. Servimos espresso,
métodos filtrados e uma pequena confeitaria feita no dia.
</p><p>
Funcionamos de terça a sábado, das 8h às 19h, na Avenida dos
Jacarandás, 1200, no Setor Comercial de Sinop.
</p></section></main><footerclass="rodape"><p>Café Cerrado — Avenida dos Jacarandás, 1200, Sinop/MT</p><p>Projeto do Nível 2 do WebLab — Desenvolvimento Web.</p></footer></body></html>
Os links para cardapio.html e contato.html ainda apontam para páginas que não existem: clicar neles produz um 404. Isso é intencional — as duas páginas nascem na Aula 03, e ver o 404 acontecer é uma boa oportunidade para reler a §3.1.
O cabeçalho marrom, com a navegação alinhada à direita.
O bloco de destaque centralizado, com o botão laranja.
O rodapé escuro no fim da página.
Agora abra o DevTools na aba Network e recarregue com Ctrl+F5. Você deve ver duas requisições: index.html e estilo.css. Clique em estilo.css e confirme, nos cabeçalhos de resposta, Content-Type: text/css — exatamente o que a §3.2 previu.
Passo 5 — Provar que o CSS bloqueia a renderização¶
Um experimento de 30 segundos que fixa a §4.1. No DevTools, aba Network, mude o seletor de velocidade de No throttling para Slow 4G e recarregue. Observe a ordem: o HTML chega, mas a página só aparece pintada quando o CSS termina de baixar.
Agora troque a linha do <link> de lugar: mova-a do <head> para logo antes de </body>, salve e recarregue com a mesma simulação de rede lenta. Você vai ver a página aparecer sem estilo por um instante e depois "pular" para o visual correto. Isso se chama FOUC (flash of unstyled content), e é a razão de a folha de estilo ficar no <head>. Desfaça a alteração e volte o <link> para o <head>.
Acesse https://validator.w3.org/nu/, escolha a aba Validate by Direct Input, cole o conteúdo do seu index.html e clique em Check. A meta é: zero erros. Avisos (warnings) merecem leitura, mas nem todos exigem ação.
Se aparecer algo, o validador diz a linha e o motivo. Os erros mais comuns nesta etapa são tag não fechada, atributo escrito errado e <li> fora de <ul>.
gitstatus
gitadd.
gitcommit-m"Estrutura a pagina inicial com landmarks e folha de estilo base"
gitpush
Espere um a três minutos e recarregue https://SEU-USUARIO.github.io/cafe-cerrado/. O site publicado agora tem o mesmo visual da sua máquina.
⚠️ Atenção
Se o site publicado aparecer sem estilo, o problema quase sempre é caminho: você escreveu /css/estilo.css (absoluto) em vez de css/estilo.css (relativo). Confirme na aba Network do site publicado: se estilo.css voltou 404, é isso. A §3.3 explica por quê.
Repita os passos 1 a 7 no repositório do seu projeto autoral, com o seu tema: mesmas pastas, mesmos landmarks, sua paleta de variáveis, seu conteúdo. Não copie os textos do Café Cerrado — copie a estrutura.
A aba Network mostra index.html e estilo.css, ambos com status 200.
estilo.css responde com Content-Type: text/css.
O validador do W3C aponta zero erros.
No DevTools → Elements → Styles, clicar em var(--cor-marca) mostra o valor #6f4e37 resolvido.
Reduzir a janela para 400 px de largura não gera barra de rolagem horizontal.
O site publicado no GitHub Pages tem o mesmo visual da máquina local.
Resultado esperado: o Café Cerrado com estrutura semântica, folha de estilo baseada em variáveis, pastas organizadas e tudo isso no ar, atualizado por um git push.
A1. Explique, em duas frases, a diferença entre o arquivo index.html e o DOM. Cite a aba do DevTools que mostra cada um.
A2. Um servidor recebe a requisição GET /promocoes/. Não existe arquivo com esse nome. O que ele procura, e o que responde se não encontrar?
A3. Ordene as etapas do pipeline de renderização: paint, parsing do HTML, layout, render tree, parsing do CSS, composite.
A4. O que acontece com a página se você remover a linha <meta name="viewport" content="width=device-width, initial-scale=1.0"> e abrir no celular? E se remover <meta charset="UTF-8">?
A5. Qual a diferença entre css/estilo.css e /css/estilo.css? Em qual dos dois cenários o segundo quebra?
A6. Complete o código para que a cor de fundo do botão venha de uma variável chamada --cor-destaque:
B1. Crie a página sobre.html no Café Cerrado, reaproveitando o mesmo cabeçalho, a mesma navegação e o mesmo rodapé do index.html, com um <main> contendo um <h1> e três parágrafos sobre a história da cafeteria. Acrescente o link "Sobre" à navegação das duas páginas.
Resultado esperado: as duas páginas navegam entre si nos dois sentidos, com o mesmo visual, e o validador do W3C aponta zero erros em ambas.
Dica
Copie o index.html, troque o conteúdo do <main> e ajuste o <title>. O <link> para o CSS é o mesmo caminho relativo, porque as duas páginas estão na mesma pasta. Na Aula 03 você vai marcar qual item do menu corresponde à página atual.
B2. Troque a identidade visual do Café Cerrado alterando apenas o bloco :root do estilo.css: transforme a paleta marrom em uma paleta verde (ou a que você preferir), mantendo contraste legível entre texto e fundo. Nenhuma outra regra do arquivo pode ser modificada.
Resultado esperado: o site inteiro muda de cor com a edição de um único bloco; nenhuma cor literal (#rrggbb) aparece fora do :root.
Dica
Se alguma cor não mudar, é porque ela está escrita direto na regra em vez de vir de var(). Use Ctrl+F no arquivo procurando por # e converta cada ocorrência fora do :root em uma variável.
B3. Meça o efeito do CSS bloqueante. Na aba Network, com Slow 4G ativado, registre o tempo até a página aparecer pintada em três cenários: (a) <link> no <head>, como está; (b) <link> antes de </body>; (c) <link> no <head> mas com o CSS colado dentro de uma tag <style> no próprio HTML. Escreva um parágrafo comparando os três.
Resultado esperado: três medidas anotadas com o critério de medição declarado, mais uma conclusão sobre qual cenário é melhor e por quê.
Dica
Use a coluna Waterfall da aba Network e o marcador de eventos na barra inferior. O cenário (c) elimina uma requisição inteira, mas cria outro problema: o CSS deixa de ser cacheado separadamente entre páginas. Mencione esse custo na conclusão.
B4. Escreva uma variação do estilo.css que respeite o tema escuro do sistema operacional, sem duplicar nenhuma regra: dentro de @media (prefers-color-scheme: dark), redefina apenas as variáveis do :root.
Resultado esperado: alternar o tema do sistema entre claro e escuro muda o site inteiro; o bloco @media contém somente declarações de variáveis.
Dica
A estrutura é @media (prefers-color-scheme: dark) { :root { --cor-fundo: #1c1714; } }. No Chrome dá para simular sem mexer no sistema: DevTools → menu de três pontinhos → More tools → Rendering → Emulate CSS media feature prefers-color-scheme.
B5. Investigue a diferença entre file:// e http:// na prática. Abra o index.html das duas formas (duplo clique no arquivo e pelo Live Server) e compare, em uma tabela: o que aparece na barra de endereço, o que a aba Network mostra em cada caso, e o valor de window.location.protocol no Console.
Resultado esperado: uma tabela com três linhas de comparação e um parágrafo explicando por que esta trilha exige o uso do Live Server.
Dica
Em file:// a aba Network normalmente não registra nada, porque não houve requisição HTTP nenhuma — o navegador leu o disco. Esse é o ponto central da resposta.
C1. Reproduza uma página real. Escolha a página inicial de uma cafeteria ou restaurante que você conheça, abra o DevTools e identifique: quais landmarks ela usa (ou deixa de usar), quantas requisições faz, qual o peso total e quantas fontes externas carrega. Depois reescreva o esqueleto dessa página — só a estrutura HTML com os landmarks corretos, sem copiar textos nem imagens — em um arquivo exercicios/aula02/estrutura-analisada.html, e escreva cinco linhas apontando o que você faria diferente.
Dica
No Console, document.querySelectorAll('header, nav, main, footer').length conta os landmarks de uma vez. Muitos sites comerciais têm zero: são <div> de ponta a ponta. Isso é matéria-prima para a sua análise, não motivo para imitar.
C2. Sirva o seu site sem o Live Server. Usando o Node.js já instalado, suba um servidor estático na pasta do projeto (por exemplo com npx serve ou com o módulo http do Node) e acesse pelo endereço que ele indicar. Depois responda, com evidência da aba Network: qual Content-Type ele devolve para o HTML e para o CSS, o que acontece ao pedir um arquivo inexistente e o que acontece ao pedir a pasta raiz.
Dica
npx serve baixa e executa o pacote sem instalar nada permanentemente. Compare a resposta de /nao-existe.html com a do GitHub Pages: os dois respondem 404, mas o corpo da resposta é diferente. Na Aula 11 você vai escrever esse servidor com quatro linhas de Express.
Um cliente pede: "gostei do site, mas a marca agora é verde-oliva, e queremos um modo escuro". Quanto tempo isso custa? Em um CSS bem escrito, cinco minutos; em um CSS com cores espalhadas por 300 linhas, uma tarde. Descubra em qual dos dois o seu projeto autoral está — e conserte.
Critérios de pronto
Nenhuma cor literal (#rrggbb, rgb(), nomes como white) aparece fora do bloco :root do arquivo de estilos.
Pelo menos oito variáveis nomeadas por função (--cor-marca, --cor-superficie, --espaco-2), nunca por aparência (--marrom, --cor1).
Um bloco @media (prefers-color-scheme: dark) que redefine apenas variáveis e produz um tema escuro legível.
Contraste entre texto e fundo verificado nos dois temas, com o resultado anotado (a aba Elements → Styles do Chrome mostra a razão de contraste ao inspecionar uma cor de texto).
Uma captura de tela do site nos dois temas.
Pistas
Comece procurando # no arquivo de estilos: cada ocorrência fora do :root é uma variável esperando para nascer.
Nomes por função sobrevivem à troca de paleta; nomes por cor viram mentira no dia em que o verde vira azul.
Para o tema escuro, não basta inverter: fundos escuros pedem texto levemente acinzentado e sombras mais sutis.
A MDN tem uma página sobre prefers-color-scheme com exemplos prontos para adaptar.
Por que um site com pouquíssimo conteúdo demora dois segundos para aparecer? Quase sempre porque alguma coisa está bloqueando o caminho até a primeira pintura. Hoje você vira perito: mede, identifica o culpado e comprova a melhora com número.
Critérios de pronto
A medição do seu site em três configurações (<link> no <head>, <link> antes de </body>, CSS embutido em <style>), com a rede simulada em Slow 4G e o critério de medição declarado.
Uma tabela com os tempos das três configurações e a indicação de qual venceu.
A aba Coverage do DevTools usada para descobrir qual porcentagem do seu CSS é efetivamente utilizada na página inicial, com o número anotado.
Um teste com um <script> sem defer no <head> (pode ser um script que só imprime uma mensagem), mostrando na aba Network que ele atrasa o resto, e o mesmo teste com defer para comparação.
Uma conclusão de dez linhas dizendo o que você mudaria em um site real, na ordem de prioridade.
Pistas
A aba Coverage fica em DevTools → menu de três pontinhos → More tools → Coverage. Ela mostra, em vermelho, o CSS baixado e não usado.
Para simular latência, use o seletor de throttling da aba Network; para resultados comparáveis, marque também Disable cache.
O painel Performance grava a linha do tempo e marca os eventos de primeira pintura. Não precisa dominar a ferramenta: basta achar o marcador e ler o tempo.
Cuidado com a conclusão fácil: embutir o CSS acelera a primeira visita e prejudica as seguintes, porque o estilo deixa de ser cacheado à parte.
Um teste brutal e revelador: desligue a folha de estilo do seu site e leia a página resultante. Se ela continuar fazendo sentido — títulos na ordem certa, links com texto claro, conteúdo antes do rodapé — a estrutura é boa. Se virar uma sopa ilegível, o significado estava no CSS, e não no HTML. É assim que um leitor de tela e um buscador enxergam a sua página.
Critérios de pronto
Captura de tela do seu site com o CSS desabilitado (DevTools → Elements → desmarque a folha, ou remova o <link> temporariamente).
Uma análise escrita de dez a quinze linhas respondendo: a ordem do conteúdo faz sentido? Os títulos formam uma hierarquia? Dá para navegar só pelos links?
A saída de document.querySelectorAll('h1, h2, h3') no Console, listando a hierarquia de títulos, com um comentário sobre saltos de nível.
Pelo menos três correções aplicadas ao HTML como resultado da análise, cada uma com o commit correspondente.
O relatório do validador do W3C sem erros, anexado.
Pistas
Só deve existir um <h1> por página, e os níveis não devem pular (h1 → h3 sem h2 é um problema).
Textos de link como "clique aqui" e "saiba mais" são inúteis fora de contexto: um leitor de tela pode listar todos os links da página de uma vez.
Se o conteúdo principal aparecer depois do rodapé na página sem CSS, a ordem no HTML está errada — e nenhum order de flexbox conserta isso para quem não vê a tela.
Este é um ensaio geral da Aula 06, quando o Lighthouse vai pontuar exatamente esses itens.
O GitHub Pages e o Live Server fazem a mesma coisa que você vai programar na Unidade 3: receber um caminho, achar um arquivo, devolver com o cabeçalho certo. Antecipe o assunto. Escreva, em Node.js puro (sem Express, sem instalar nada), um servidor estático de umas 40 linhas que sirva a pasta do Café Cerrado — e depois compare o comportamento dele com o do GitHub Pages.
Critérios de pronto
Um arquivo servidor.js que sobe um servidor HTTP em uma porta local usando o módulo http do Node e serve os arquivos da pasta atual.
Tipos MIME corretos para .html, .css, .js, .png, .svg e .json, comprovados na aba Network.
A convenção index implementada: pedir / entrega index.html.
Resposta 404 com uma mensagem própria para arquivos inexistentes, comprovada na aba Network.
Um README curto no repositório do desafio comparando, em tabela, três comportamentos do seu servidor com os do GitHub Pages: Content-Type do CSS, resposta a /, resposta a um caminho inexistente.
Uma reflexão de cinco linhas sobre o que o seu servidor não faz e um servidor de produção faz (cache, compressão, HTTPS, segurança de caminho).
Pistas
Comece pela documentação do módulo http do Node: https://nodejs.org/api/http.html. O núcleo é http.createServer((req, res) => { }) seguido de servidor.listen(porta).
req.url traz o caminho pedido; o módulo fs/promises lê o arquivo; res.writeHead(200, { 'Content-Type': tipo }) define o cabeçalho.
Um objeto simples mapeando extensão para tipo MIME resolve a tabela: { '.html': 'text/html', '.css': 'text/css' }.
Cuidado com um caminho como /../../etc/senha: pense em como impedir que alguém saia da pasta do site. Esse é um problema de segurança real, com nome próprio (path traversal).
Quando chegar à Aula 11, compare o seu código com quatro linhas de express.static. A comparação é o prêmio do desafio.
Parte 1 — Estrutura do projeto autoral (35 min). No repositório do seu projeto:
Crie as pastas css/, js/ e img/, com .gitkeep nas duas últimas.
Reescreva o index.html com a anatomia completa (doctype, lang, charset, viewport, description, title) e os quatro landmarks: header com nav, main com um <h1> e pelo menos duas <section>, e footer.
Crie css/estilo.css com, no mínimo, oito variáveis no :root, o reset da §7.2 e estilos para cabeçalho, conteúdo e rodapé. Nenhuma cor literal fora do :root.
Crie a segunda página sobre.html, com a mesma estrutura e navegação funcionando nos dois sentidos.
Parte 2 — Publicação (15 min). Commit com mensagem descritiva, push e verificação do site publicado. Abra o site publicado no celular e confira que não há rolagem horizontal.
Parte 3 — Leitura dirigida (10 min). Se você tem acesso a uma biblioteca virtual pela sua instituição: QUEIRÓS & PORTELA, capítulos sobre a evolução da Web e a camada de estrutura (HTML); PUREWAL, capítulos 1 e 2, sobre fluxo de trabalho e primeiras páginas. Anote duas diferenças entre o que os livros descrevem e o que você fez hoje.
Critério de pronto: as duas páginas do projeto autoral abrem no site publicado, navegam entre si, passam no validador sem erros e usam variáveis CSS para todas as cores.
Guarde no seu repositório: commit + push, com o site publicado atualizado. Sem .zip.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — evolução da Web e camada de estrutura.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — capítulos 1 e 2: fluxo de trabalho e primeiras páginas.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — organização de projetos e estrutura de sites.
Na próxima aula o HTML entra em profundidade: hierarquia de títulos, listas, tabelas, imagens com figure, links em todas as suas formas e formulários com validação nativa. O Café Cerrado ganha as duas páginas que hoje respondem 404 — cardapio.html, com os produtos organizados em tabelas e listas, e contato.html, com um formulário completo — e o menu passa a indicar em qual página você está.
Escolher a tag correta para cada trecho de conteúdo e justificar a escolha pelos três benefícios da semântica: acessibilidade, indexação e manutenção.
Montar o esqueleto de uma página com os landmarksheader, nav, main, section, article, aside e footer, e explicar o que cada um significa para um leitor de tela.
Estruturar conteúdo com listas (ul, ol, dl), imagens com alt descritivo, figure/figcaption e tabelas de dados com caption, thead, tbody e th scope.
Usar links em todas as suas formas — página interna, âncora, URL absoluta, mailto:, tel:, download — e escrever textos de link que fazem sentido fora do contexto.
Construir um formulário completo com os tipos de campo do HTML5, label para cada controle, agrupamento com fieldset/legend e validação nativa (required, pattern, minlength, min/max).
Explicar por que a validação do navegador nunca substitui a validação no servidor, e onde cada camada entra no Café Cerrado.
Validar o HTML no W3C e corrigir os erros mais comuns lendo a mensagem do validador.
[ ] Repositório cafe-cerrado clonado na máquina, com index.html, README.md, e as pastas css/, js/ e img/ criadas na Aula 02.
[ ] css/estilo.css já ligado ao index.html por <link rel="stylesheet" href="css/estilo.css">.
[ ] GitHub Pages ativo no repositório (Aula 01) e o endereço público anotado no README.md.
[ ] VS Code com a extensão Live Server e um navegador com DevTools (Chrome ou Firefox).
[ ] Git configurado com o seu nome e e-mail (git config --global user.name, git config --global user.email).
Na aula passada você viu o caminho completo de uma requisição — do Enter na barra de endereços até o paint na tela — escreveu a anatomia mínima de um documento HTML5 válido e organizou o repositório do Café Cerrado em css/, js/ e img/. O index.html funciona, mas ainda é um esqueleto de uma página só. Hoje ele vira um site: três páginas ligadas entre si, com estrutura semântica de verdade, um cardápio com listas e tabela e um formulário de contato que o navegador valida sozinho.
Para quem enxerga a página com CSS aplicado, os dois são idênticos. Para todo o resto do mundo, não: o primeiro é uma pilha de caixas anônimas; o segundo declara "isto é o cabeçalho", "isto é o título principal", "isto é a navegação", "isto é uma lista de dois itens".
Semântica é significado. Uma <div> não diz nada sobre o seu conteúdo — o nome vem de division, divisão. Um <nav> diz: este bloco é a navegação do site. Essa diferença rende três benefícios concretos:
Acessibilidade. Leitores de tela constroem, a partir do HTML, uma lista de regiões da página. Quem usa NVDA, VoiceOver ou TalkBack pressiona uma tecla e pula direto para a navegação, ou direto para o conteúdo principal. Com div, essa lista sai vazia e a pessoa precisa percorrer a página inteira, elemento por elemento.
Indexação. Buscadores usam a estrutura para decidir o que é conteúdo e o que é enfeite. Um <h1> dentro de <main> pesa mais do que um texto grande dentro de um <div> do rodapé.
Manutenção. O código se autodocumenta. Você abre um arquivo de 400 linhas escrito há seis meses e encontra o rodapé procurando por <footer> — não por class="bottom-area-2".
🧠 Você sabia?
As tags semânticas do HTML5 não foram inventadas no vácuo. Em 2005 o Google analisou mais de um bilhão de páginas para descobrir quais nomes de classe as pessoas mais usavam em <div>. O ranking foi dominado por footer, menu, nav, header, content, main e sidebar — exatamente os nomes que viraram tags no HTML5. Em outras palavras: a especificação não impôs uma estrutura, ela oficializou a estrutura que a comunidade já usava na mão. Quando você escreve <footer> hoje, está usando um padrão que nasceu de um levantamento estatístico do que os desenvolvedores já faziam.
Cabeçalho da página ou de uma seção: logo, título, navegação. Pode haver vários.
<nav>
Bloco de links de navegação (menu principal, rodapé, trilha de navegação).
<main>
Conteúdo principal — único e não repetido entre páginas. Um por documento.
<section>
Seção temática do conteúdo, com título próprio (h2/h3).
<article>
Conteúdo independente e autocontido: um post, uma notícia, um card de produto.
<aside>
Conteúdo complementar: barra lateral, box de curiosidade, links relacionados.
<footer>
Rodapé da página ou de uma seção: créditos, contato, links institucionais.
<div> / <span>
Contêineres sem significado. Só quando nenhuma tag semântica couber.
Três dúvidas aparecem sempre:
section ou article? O teste: o conteúdo faria sentido sozinho, publicado fora desta página (num feed RSS, num resultado de busca, num aplicativo agregador)? Se sim, é article. Um card de produto do cardápio é um article; a seção "Nossa história" da página inicial é uma section. Na dúvida entre os dois, prefira section — ela é o contêiner temático genérico.
section ou div? Toda section deveria ter um título (h2, h3) que a nomeia. Se você não consegue escrever um título para o bloco, provavelmente ele não é uma seção temática: é um agrupamento visual, e aí div é a tag honesta.
header só um por página? Não. <header> é o cabeçalho de qualquer coisa: da página, de um <article>, de uma <section>. O que é único por página é o <main>.
1.3 Landmarks: o mapa que o leitor de tela enxerga¶
Cinco tags viram automaticamente landmarks (marcos) na árvore de acessibilidade:
Tag HTML
Landmark anunciado
<header> (filho direto do body)
banner
<nav>
navigation
<main>
main
<aside>
complementary
<footer> (filho direto do body)
contentinfo
Repare no detalhe entre parênteses: <header> e <footer> só viram landmarks quando são filhos diretos do <body>. Um <header> dentro de um <article> é apenas o cabeçalho daquele artigo — o que é correto e desejado.
Quando a página tem mais de uma navegação, cada <nav> precisa de um rótulo, senão o usuário ouve "navegação", "navegação", "navegação" e não sabe qual é qual:
HTML
<navaria-label="Principal"><ul><li><ahref="index.html">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li></ul></nav><navaria-label="Rodapé"><ul><li><ahref="#politica">Política de privacidade</a></li></ul></nav>
O aria-label é um dos poucos atributos ARIA que você vai usar hoje. A aula de Acessibilidade e ARIA aprofunda o assunto; por enquanto, guarde a regra: rotule toda navegação quando houver mais de uma.
🔬 Investigue
Abra qualquer site grande (um portal de notícias, o site da sua universidade ou escola) e pressione F12. No Chrome, vá em Elements e, no painel da direita, abra a aba Accessibility; no Firefox, use a aba Acessibilidade e ative "Mostrar tabulações". Procure a árvore de acessibilidade e conte quantos landmarks a página tem. Depois abra o seu index.html do Café Cerrado e faça a mesma contagem. Anote a diferença — no fim desta aula, refaça o teste na sua página e compare com o número de agora.
1.4 Hierarquia de títulos: estrutura, não tamanho¶
<h1> a <h6> formam o sumário da página, como os capítulos e seções de um livro. Regras:
Um único <h1> por página, dizendo do que a página trata.
<h2> para as seções principais, <h3> para subseções — sem pular níveis (nunca h2 direto para h4).
Tamanho de fonte é problema do CSS. Se um <h2> está grande demais, mude o CSS; não troque para <h4>.
Um <h1> mal escolhido custa caro: leitores de tela oferecem "listar todos os títulos" como forma rápida de entender a página. Se a lista sai como h1: Café Cerrado, h3: Cafés, h5: Espresso, o usuário conclui que faltam níveis — e não faltam, é só desleixo.
Sumário correto de cardapio.html:
Texto
h1 Cardápio
├── h2 Cafés
│ ├── h3 Espresso do Cerrado
│ ├── h3 Coado da Casa
│ └── h3 Cappuccino Sinop
├── h2 Bebidas geladas
│ ├── h3 Cold Brew da Chapada
│ └── h3 Frappê de Café
├── h2 Salgados
└── h2 Doces
📌 Vale gravar
Duas confusões comuns: (a) "pode haver mais de um <h1> por página" — na prática deste material, não: um por página; (b) "<h1> é o texto maior da página" — falso, h1 é o título mais importante, e o tamanho é decisão do CSS.
Semântica não é uma corrida para eliminar div. Ela continua sendo a ferramenta correta quando o agrupamento existe só para o layout: um contêiner que centraliza o conteúdo, uma linha do grid, um invólucro para aplicar um fundo. O mesmo vale para <span>, o contêiner genérico em linha.
HTML
<footer><divclass="container"><p>Café Cerrado — Avenida dos Jacarandás, 1200, Sinop, MT</p><p>Preço a partir de <spanclass="destaque">R$ 6,00</span></p></div></footer>
Aqui div.container existe para o CSS limitar a largura e centralizar; span.destaque existe para colorir um pedaço de texto. Nenhum dos dois carrega significado — e está certo assim. O erro é o inverso: usar div onde existe uma tag com significado.
<!-- Lista não ordenada: a ordem não importa --><ul><li>Wi-Fi liberado</li><li>Tomadas em todas as mesas</li><li>Espaço para estudo</li></ul><!-- Lista ordenada: a ordem importa --><ol><li>Escolha os grãos no balcão</li><li>Peça a moagem na hora</li><li>Retire o café no guichê</li></ol><!-- Lista de definições: pares termo/descrição --><dl><dt>Torra clara</dt><dd>Realça a acidez e as notas frutadas do grão.</dd><dt>Torra média</dt><dd>Equilibra doçura, corpo e acidez. É a nossa torra padrão.</dd><dt>Torra escura</dt><dd>Mais amarga e encorpada, com notas de chocolate amargo.</dd></dl>
A <dl> (description list) é subutilizada e resolve muito bem glossários, especificações e — no nosso caso — a descrição das torras. Cada <dt> é um termo; cada <dd>, a descrição correspondente. Um <dt> pode ter vários <dd> e vice-versa.
Listas também são a base semântica de menus: um menu é uma lista de links. O leitor de tela anuncia "lista com 4 itens" ao entrar no <nav> — o usuário sabe o tamanho da navegação antes de percorrê-la.
<imgsrc="img/fachada.jpg"alt="Fachada do Café Cerrado com toldo verde e mesas na calçada"width="1200"height="800">
Quatro atributos que valem nota:
src — o caminho do arquivo, relativo à página (seção 3.1).
alt — o texto alternativo. Descreve a imagem para quem não a vê: leitores de tela, buscadores e o próprio navegador quando o arquivo falha. É obrigatório.
width/height — as dimensões reais do arquivo, em pixels e sem unidade. Não servem para redimensionar (isso é papel do CSS): servem para o navegador reservar o espaço antes de a imagem carregar, evitando que o texto "pule" na tela.
loading="lazy" — opcional; adia o download de imagens que estão fora da tela. Use em galerias longas, nunca na imagem principal do topo.
Como escrever um bom alt: descreva o que a imagem comunica naquele contexto, em uma frase, sem começar com "imagem de" (o leitor de tela já anuncia que é uma imagem).
Situação
alt correto
Foto de produto no cardápio
alt="Xícara de espresso com creme dourado sobre pires branco"
Logo dentro de um link para a home
alt="Café Cerrado — página inicial"
Gráfico com dados
alt="Gráfico de barras: vendas de café coado sobem de 120 para 310 xícaras por semana"
Imagem puramente decorativa
alt="" — vazio, mas presente
O alt="" merece explicação: um alt vazio diz ao leitor de tela "ignore esta imagem, ela não acrescenta informação". Já omitir o atributo faz o leitor anunciar o nome do arquivo — e ouvir "i-m-g-underline-2-0-2-4-underline-final-ponto-jpg" é pior do que ouvir nada.
⚠️ Atenção
Se a imagem está dentro de um link e é o único conteúdo dele, o alt deixa de descrever a imagem e passa a descrever o destino do link. Um logo clicável tem alt="Café Cerrado — página inicial", não alt="Logo". Quem navega por leitor de tela ouve o alt como se fosse o texto do link.
<figure><imgsrc="img/grao-cerrado.jpg"alt="Grãos de café verdes sendo peneirados em uma bandeja de metal"width="1000"height="667"><figcaption>
Grãos do Cerrado mato-grossense recém-beneficiados, antes da torra.
</figcaption></figure>
<figure> agrupa um conteúdo autocontido (imagem, trecho de código, tabela, vídeo) com sua legenda, e o <figcaption> é a legenda — que deve ser o primeiro ou o último filho da <figure>.
A diferença entre alt e figcaption confunde: o altdescreve a imagem para quem não a vê; o figcaptioncomenta a imagem para todo mundo. Eles não devem repetir um ao outro. No exemplo acima, o alt diz o que aparece na foto; a legenda diz de onde vieram os grãos.
Tabela serve para dados tabulares: informação que faz sentido em linhas e colunas. Layout é problema de CSS (Flexbox e Grid, que você estudou no Nível 1). Uma tabela usada para layout destrói a leitura em leitor de tela, que anuncia "linha 3, coluna 2" para cada pedaço da página.
HTML
<table><caption>Horário de atendimento do Café Cerrado</caption><thead><tr><thscope="col">Dia</th><thscope="col">Abertura</th><thscope="col">Fechamento</th></tr></thead><tbody><tr><thscope="row">Segunda a sexta</th><td>07h00</td><td>20h00</td></tr><tr><thscope="row">Sábado</th><td>08h00</td><td>18h00</td></tr><tr><thscope="row">Domingo</th><tdcolspan="2">Fechado</td></tr></tbody></table>
Os elementos que fazem a tabela ser acessível:
<caption> — o título da tabela, primeiro filho de <table>. Anunciado pelo leitor de tela ao entrar na tabela.
<thead> / <tbody> / <tfoot> — separam cabeçalho, corpo e rodapé. Permitem que o navegador repita o cabeçalho ao imprimir e que o CSS fixe o cabeçalho ao rolar.
<th> com scope — scope="col" diz "sou o cabeçalho desta coluna"; scope="row", "sou o cabeçalho desta linha". É isso que faz o leitor de tela anunciar "Sábado, Abertura: 08h00" em vez de só "08h00".
colspan / rowspan — mesclagem de células. Use com parcimônia: tabelas muito mescladas ficam impossíveis de navegar.
💡 Dica
Tabelas largas estouram a tela do celular. A solução de uma linha: envolva a <table> em uma <div class="tabela-rolavel"> com overflow-x: auto no CSS. A tabela rola sozinha no eixo horizontal em vez de esticar a página inteira. Você vai usar exatamente isso no cardapio.html.
Antes das formas de link, o assunto que mais gera erro 404 em trabalho de aluno: como escrever o caminho.
Escrita
Significa
cardapio.html
Arquivo na mesma pasta da página atual
img/fachada.jpg
Arquivo na subpasta img/, a partir da pasta atual
../index.html
Sobe uma pasta e procura ali
/cafe-cerrado/index.html
A partir da raiz do domínio (cuidado no GitHub Pages)
A armadilha clássica: caminhos que começam com / são absolutos em relação ao domínio, não ao seu projeto. Se você escreve /img/logo.svg e publica em https://seuusuario.github.io/cafe-cerrado/, o navegador procura em https://seuusuario.github.io/img/logo.svg — que não existe. Funciona no Live Server (onde o projeto é a raiz) e quebra no GitHub Pages. Regra do curso: use sempre caminhos relativos (img/logo.svg, css/estilo.css), sem a barra inicial.
Outra armadilha: Linux e macOS diferenciam maiúsculas de minúsculas; Windows não.img/Fachada.JPG funciona na sua máquina e quebra no GitHub Pages, que roda em Linux. Padronize: nomes de arquivo em minúsculas, sem espaços e sem acentos, com hífen no lugar do espaço (pao-de-queijo.jpg).
<!-- 1. Outra página do site (caminho relativo) --><ahref="cardapio.html">Ver o cardápio completo</a><!-- 2. Âncora interna: rola até o elemento com esse id --><ahref="#horarios">Horários de atendimento</a><!-- 3. Âncora em outra página --><ahref="cardapio.html#doces">Doces do dia</a><!-- 4. Site externo (URL absoluta) --><ahref="https://www.wikipedia.org">Wikipédia</a><!-- 5. Abrir em nova aba --><ahref="https://www.wikipedia.org"target="_blank"rel="noopener">
Wikipédia (abre em nova aba)
</a><!-- 6. E-mail e telefone --><ahref="mailto:contato@cafecerrado.exemplo.br">contato@cafecerrado.exemplo.br</a><ahref="tel:+556699999000">(66) 9 9999-9000</a><!-- 7. Download de arquivo --><ahref="docs/cardapio.pdf"download>Baixar o cardápio em PDF</a>
Detalhes que importam:
mailto: aceita assunto e corpo pré-preenchidos: mailto:contato@cafecerrado.exemplo.br?subject=Reserva%20de%20mesa. O %20 é o espaço codificado para URL.
tel: deve trazer o número em formato internacional, sem espaços nem parênteses: tel:+556699999000. No celular, o link abre o discador; no desktop, costuma abrir o aplicativo de chamadas configurado.
download sugere ao navegador baixar em vez de abrir. Com valor (download="cardapio-cafe-cerrado.pdf"), define o nome do arquivo salvo. Só funciona para arquivos do mesmo domínio.
3.3 target="_blank" e por que o rel é obrigatório¶
Abrir link em nova aba parece inofensivo, mas tem duas consequências:
Segurança. A página aberta recebe uma referência à página que a abriu (window.opener) e pode reescrever o endereço da aba original — técnica conhecida como tabnabbing. O rel="noopener" corta essa referência. Navegadores modernos já aplicam noopener por padrão em target="_blank", mas escrever explicitamente é a prática correta: o seu HTML não deveria depender do comportamento padrão de uma versão específica de navegador.
Acessibilidade. Abrir uma aba nova sem avisar quebra o botão "voltar", que é a forma como muita gente navega. Sinalize no texto do link, no title ou com um ícone que tenha texto alternativo.
HTML
<ahref="https://developer.mozilla.org/pt-BR/"target="_blank"rel="noopener">
MDN Web Docs (abre em nova aba)
</a>
🔎 Por baixo do capôrel="noreferrer" faz mais do que noopener: além de cortar o window.opener, ele impede que o navegador envie o cabeçalho HTTP Referer — o campo que informa ao site de destino de onde o visitante veio. Isso protege a privacidade, mas apaga a sua origem das estatísticas do site linkado. Para links externos comuns, noopener basta. Se a página tiver informação sensível na URL (um identificador de sessão, por exemplo), use noopener noreferrer.
Regras do id: único no documento inteiro, sem espaços, começando por letra. Um id duplicado é erro no validador do W3C e faz o navegador rolar sempre para a primeira ocorrência.
Duas melhorias de uma linha cada:
CSS
html{scroll-behavior:smooth;/* rolagem suave até a âncora */scroll-padding-top:5rem;/* compensa a altura do cabeçalho fixo */}
Sem scroll-padding-top, a âncora leva o título exatamente para o topo da janela — e o cabeçalho fixo cobre o título. Esse é um dos bugs mais frequentes e mais fáceis de corrigir.
3.5 O texto do link, aria-current e o estado da página atual¶
Leitores de tela oferecem "listar todos os links da página". Nessa lista, cada link aparece sozinho, fora do contexto do parágrafo. Uma página com dez "clique aqui" produz uma lista inútil.
Ruim
Bom
Para ver o cardápio, <a href="cardapio.html">clique aqui</a>.
Veja o <a href="cardapio.html">cardápio completo</a>.
<a href="docs/menu.pdf">Leia mais</a>
<a href="docs/menu.pdf" download>Baixar o cardápio em PDF</a>
O atributo aria-current="page" faz o leitor de tela anunciar "Cardápio, página atual". Quem enxerga recebe a mesma informação por cor — e a cor sozinha nunca basta, porque cerca de 8% dos homens têm alguma forma de daltonismo. Bônus: o CSS pode usar o próprio atributo como seletor (nav a[aria-current="page"]), então o destaque visual sai de graça, sem classe extra.
Formulário é onde o site deixa de ser um panfleto. Toda a Unidade 3 desta trilha vive de dados que chegam por formulários: na Unidade 2 você vai validá-los com JavaScript e, na Unidade 3, recebê-los no Express. Estruturar o formulário direito agora é investimento nos três marcos do projeto.
action — para onde os dados vão. Em um site estático ainda não há servidor para recebê-los; na Unidade 3, isso vira /api/contatos.
method — get coloca os dados na URL (?nome=Ana&assunto=reserva), o que serve para buscas e páginas compartilháveis; post envia no corpo da requisição, o que serve para cadastros, senhas e qualquer coisa que não deva ficar no histórico do navegador.
name — o nome do dado enviado. Sem name, o campo simplesmente não é enviado. É o erro silencioso número um de formulário: tudo parece certo, e o servidor recebe menos campos do que a tela mostra.
id — identifica o elemento no documento, para o <label> e para o JavaScript. id e name costumam ter o mesmo valor, mas são coisas diferentes: id é para o navegador, name é para o servidor.
📌 Vale gravar
A diferença entre id e name em campos de formulário é pergunta recorrente. Resposta curta: id é único no documento e serve para label for, CSS e JavaScript; name é a chave com que o dado viaja para o servidor, pode se repetir (é assim que um grupo de radio funciona) e, se faltar, o campo não é enviado.
<!-- Forma 1: label com for apontando para o id do campo --><labelfor="email">E-mail</label><inputtype="email"id="email"name="email"><!-- Forma 2: label envolvendo o campo (dispensa for/id) --><label>
E-mail
<inputtype="email"name="email"></label>
O <label> faz três coisas ao mesmo tempo:
O leitor de tela anuncia o rótulo quando o campo recebe foco. Sem ele, a pessoa ouve apenas "caixa de edição" e não sabe o que digitar.
Clicar no rótulo foca o campo — e, em checkbox/radio, marca a opção. Isso multiplica a área clicável, o que é decisivo no celular.
O rótulo permanece visível enquanto a pessoa digita, ao contrário do placeholder.
⚠️ Atençãoplaceholdernão é rótulo. Ele desaparece assim que a pessoa digita a primeira letra, tem contraste baixo por padrão (falhando nos critérios de acessibilidade) e alguns leitores de tela o ignoram. Use placeholder só para mostrar o formato esperado — placeholder="78550-000" ao lado do rótulo "CEP" — nunca no lugar do <label>.
Abre teclado numérico. Não valida sozinho — use pattern.
url
Exige um endereço completo, com https://.
number
Numérico com min, max e step; setas de incremento.
range
Controle deslizante entre min e max.
date / time
Seletores nativos de data e de hora.
checkbox
Escolha múltipla, independente.
radio
Escolha única dentro do grupo definido pelo mesmo name.
file
Envio de arquivo; accept filtra os tipos.
search
Campo de busca; alguns navegadores mostram um "x" para limpar.
color
Seletor de cor; devolve o valor no formato #rrggbb.
hidden
Valor enviado sem aparecer na tela.
Exemplos completos dos que você vai usar hoje:
HTML
<labelfor="pessoas">Quantas pessoas</label><inputtype="number"id="pessoas"name="pessoas"min="1"max="40"step="1"value="2"><labelfor="data">Data desejada</label><inputtype="date"id="data"name="data"><labelfor="horario">Horário</label><inputtype="time"id="horario"name="horario"min="07:00"max="20:00"step="900"><labelfor="foto">Foto do evento (opcional)</label><inputtype="file"id="foto"name="foto"accept="image/png, image/jpeg">
O step="900" no campo de hora significa 900 segundos, ou seja, intervalos de 15 minutos.
🧠 Você sabia?type="tel"não valida nada. Ele existe apenas para dizer ao celular "abra o teclado numérico". A razão é cultural: formatos de telefone variam demais entre países (o Brasil tem números de 10 e de 11 dígitos, com e sem o nono dígito), e a especificação decidiu que qualquer regra embutida excluiria alguém. type="email", por outro lado, valida — e valida de forma bem mais permissiva do que a maioria imagina: a@b passa, porque endereços de intranet sem ponto no domínio são válidos segundo a especificação. Se você precisa de uma regra mais estrita, ela é sua, via pattern — e depois no servidor.
<fieldset><legend>Como prefere ser respondido?</legend><label><inputtype="radio"name="canal"value="email"checked>
E-mail
</label><label><inputtype="radio"name="canal"value="telefone">
Telefone
</label><label><inputtype="radio"name="canal"value="whatsapp">
WhatsApp
</label></fieldset>
<fieldset> agrupa campos relacionados e <legend> dá o título do grupo — que o leitor de tela anuncia junto com cada opção ("Como prefere ser respondido? E-mail, botão de opção 1 de 3"). Para grupos de radio e checkbox, o par fieldset/legendnão é decoração: é o que dá sentido às opções.
Os três radio compartilham o mesmo name="canal" — é isso que os torna mutuamente exclusivos — e cada um tem um value diferente, que é o dado que chega ao servidor.
HTML
<labelfor="assunto">Assunto</label><selectid="assunto"name="assunto"required><optionvalue="">Selecione um assunto</option><optgrouplabel="Atendimento"><optionvalue="reserva">Reserva de mesa</option><optionvalue="encomenda">Encomenda de bolos e tortas</option></optgroup><optgrouplabel="Institucional"><optionvalue="evento">Evento ou parceria</option><optionvalue="trabalhe">Trabalhe conosco</option></optgroup></select><labelfor="mensagem">Mensagem</label><textareaid="mensagem"name="mensagem"rows="5"maxlength="500"placeholder="Conte o que você precisa"></textarea>
Dois detalhes do <select>:
A primeira <option> tem value="". Isso é o que faz o required funcionar: sem uma opção de valor vazio, o primeiro item já vem selecionado e a validação nunca reclama.
<optgroup> agrupa opções sob um rótulo não selecionável. Em listas longas (estados, categorias), organiza muito.
O <textarea> não tem atributo value: o conteúdo inicial vai entre as tags. E cuidado — qualquer espaço ou quebra de linha entre <textarea> e </textarea> vira conteúdo do campo. Por isso escrevemos as duas tags coladas.
4.5 Validação nativa: o navegador trabalhando por você¶
O navegador valida antes de enviar, sem uma linha de JavaScript:
Atributo
O que exige
required
Campo preenchido (ou, em checkbox, marcado)
minlength / maxlength
Número mínimo/máximo de caracteres
min / max
Valor mínimo/máximo em number, date, time, range
step
Incremento válido em number, date, time
pattern
Expressão regular que o valor precisa casar
type
email e url já trazem regra de formato embutida
HTML
<labelfor="cep">CEP</label><inputtype="text"id="cep"name="cep"requiredpattern="[0-9]{5}-?[0-9]{3}"placeholder="78550-000"title="Digite um CEP no formato 78550-000"inputmode="numeric"autocomplete="postal-code">
Quatro observações sobre esse bloco:
O pattern casa o valor inteiro — não precisa de ^ nem $, eles são implícitos. [0-9]{5}-?[0-9]{3} aceita 78550000 e 78550-000.
O title vira a mensagem de erro exibida pelo navegador quando o pattern falha. Sem title, a pessoa lê apenas "Corresponda ao formato solicitado", que não ajuda ninguém.
inputmode="numeric" abre o teclado numérico no celular sem mudar o type — o que seria errado aqui, porque type="number" remove zeros à esquerda e mostra setas de incremento.
autocomplete="postal-code" deixa o navegador preencher o campo com o CEP já salvo pelo usuário.
O CSS reage aos estados de validação com pseudoclasses:
CSS
input:invalid,select:invalid{border-color:#b42318;}input:user-invalid{outline:2pxsolid#b42318;/* só depois que a pessoa interagiu com o campo */}input:valid{border-color:#1a7f37;}input:required+.marca-obrigatorio::after{content:" *";color:#b42318;}
:invalid casa desde o carregamento da página — todo campo required vazio já nasce inválido, e pintar tudo de vermelho antes de a pessoa digitar é hostil. :user-invalid resolve isso: só casa depois que a pessoa interagiu com o campo e saiu dele. Prefira :user-invalid sempre que puder.
🔬 Investigue
Crie um arquivo teste-validacao.html com um único campo: <form><input type="email" required><button>Enviar</button></form>. Abra no navegador e clique em Enviar com o campo vazio — anote a mensagem exata que aparece. Agora digite ana@ e envie: outra mensagem. Digite a@b: passa, e é isso mesmo (seção 4.3). Por fim, abra o console (F12 → Console) e execute document.querySelector('input').validity — o navegador devolve um objeto ValidityState com um campo booleano para cada tipo de erro (valueMissing, typeMismatch, patternMismatch, tooShort). Esse objeto é exatamente o que você vai usar para escrever mensagens de erro personalizadas na Unidade 2.
Para testar o formulário sem que o navegador atrapalhe, existe o novalidate:
Ele desliga a validação nativa do formulário inteiro. Use durante o desenvolvimento e para assumir o controle das mensagens com JavaScript — jamais como forma de "resolver" um campo que insiste em não validar.
Este é o conceito mais importante da seção, e o que separa quem entende de segurança de quem não entende:
Camada
Onde roda
Serve para
Validação nativa (HTML)
Navegador
Feedback imediato, teclado certo no celular
Validação com JavaScript
Navegador
Mensagens personalizadas, regras de negócio, máscaras
Validação no servidor
Servidor
A única em que se pode confiar
As duas primeiras rodam na máquina do usuário, e tudo que roda na máquina do usuário pode ser burlado: basta abrir o DevTools e remover o atributo required, ou enviar a requisição direto por curl sem passar pela página. A validação do cliente existe para melhorar a experiência de quem está agindo de boa-fé; a validação do servidor existe para proteger o sistema de quem não está.
Você vai ver isso literalmente acontecer: na Unidade 3, ao construir a API do Café Cerrado, o primeiro teste será enviar um POST /api/produtos com o corpo vazio, sem passar por formulário nenhum.
⚠️ Atenção
Nunca confie em dados vindos do cliente. Nem no hidden, nem no select (o usuário pode trocar o value de uma <option> no DevTools), nem no maxlength. A regra vale para o resto da sua vida profissional.
5. Validando o HTML: o W3C como corretor automático¶
O validador oficial (https://validator.w3.org/nu/) lê o seu HTML e aponta erros de sintaxe, aninhamento inválido, atributos inexistentes e id duplicado. É gratuito, roda no navegador e aceita três entradas: URL pública, upload de arquivo ou texto colado.
Fluxo recomendado no Café Cerrado:
Escreva a página.
Cole o conteúdo em "Validate by direct input" (o site ainda não está publicado com as mudanças).
Corrija do primeiro erro para o último — erros de aninhamento costumam gerar cascatas, e resolver o primeiro apaga vários.
Só depois faça commit e push.
Aprenda a ler a mensagem. Ela sempre tem três partes: a linha, o que o validador esperava e o que encontrou.
Texto
Error: Element "figcaption" not allowed as child of element "div" in this context.
From line 42, column 5; to line 42, column 17
Tradução: figcaption só pode ser filho de figure. Você trocou a figure por uma div em algum momento.
Texto
Error: Duplicate ID "nome".
Tradução: dois elementos têm id="a03-nome". Provavelmente você copiou um bloco de campo e esqueceu de trocar o id — e, de quebra, o label for="nome" agora aponta para o campo errado.
Vale também rodar o Lighthouse (DevTools → aba Lighthouse → Accessibility). Ele não substitui o validador, mas encontra outra categoria de problemas: contraste, campos sem rótulo, links sem texto discernível. A aula de Acessibilidade e ARIA vai exigir nota ≥ 90 nesse relatório — começar a olhar para ele agora facilita a vida depois.
O Café Cerrado — o projeto-fio apresentado na Aula 01 e iniciado na Aula 02 — sai hoje de uma página só para três páginas ligadas entre si, com estrutura semântica completa.
Ao final você terá:
index.html — hero, sobre, destaques e horários.
cardapio.html — produtos em listas por categoria, tabela de torras e uma figure.
contato.html — formulário completo com validação nativa.
Um menu igual nas três páginas, com aria-current="page" no item certo.
⚠️ Cuidado
O index.html de hoje substitui o da Aula 02, com nomes de classe novos. Antes de colar o código do Passo 1, abra css/estilo.css e apague as cinco regras da Aula 02 que ficaram sem dono: .cabecalho, .marca (a versão antiga, de uma linha só), .navegacao ul, .navegacao a (e o :hover/:focus dela), .destaque, .destaque h1, .destaque p, .sobre, .sobre h2, .botao (todas as declarações antigas dela) e .rodape/.rodape p. Ficam de pé, sem alteração: o bloco :root, o reset da §7.2, body, img, a e a regra de main. As classes novas — .topo, .topo__interno, .hero, .cartoes, .rodape__grade — entram no Passo 4. Duas regras com o mesmo nome no mesmo arquivo não dão erro: a última vence em silêncio, e você passa a tarde caçando um estilo que "não aplica".
Se você fez o laboratório B1 da Aula 02 e criou sobre.html, mantenha o item Sobre no menu das três páginas (um <li> a mais, sem aria-current) e repita nele o mesmo <header>/<footer>. O menu de três itens abaixo é o mínimo, não o teto.
Escreva a página inicial inteira. O <header> e o <footer> deste arquivo são idênticos nas outras duas páginas — só muda em qual item do menu fica o aria-current="page".
cafe-cerrado/index.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><metaname="description"content="Café Cerrado: torrefação artesanal de grãos do Cerrado mato-grossense em Sinop, MT. Cafés, bebidas geladas, salgados e doces."><title>Café Cerrado — Torrefação artesanal em Sinop, MT</title><linkrel="stylesheet"href="css/estilo.css"></head><body><headerclass="topo"><divclass="container topo__interno"><aclass="marca"href="index.html"><spanclass="marca__nome">Café Cerrado</span><spanclass="marca__slogan">Torrefação artesanal · Sinop, MT</span></a><navaria-label="Principal"><ulclass="menu"><li><ahref="index.html"aria-current="page">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li><li><ahref="contato.html">Contato</a></li></ul></nav></div></header><mainclass="container"><sectionclass="hero"><divclass="hero__texto"><h1>Café do Cerrado, torrado em Sinop</h1><pclass="hero__chamada">
Compramos grãos de produtores da região, torramos em pequenos lotes e
moemos na hora do seu pedido. Sem pressa e sem atalho.
</p><p><aclass="botao"href="cardapio.html">Ver o cardápio completo</a><aclass="botao botao--vazado"href="contato.html">Reservar uma mesa</a></p></div><imgsrc="img/fachada.jpg"alt="Fachada do Café Cerrado com toldo verde e mesas na calçada"width="1200"height="800"></section><sectionid="sobre"><h2>Nossa história</h2><p>
O Café Cerrado nasceu em uma garagem no Setor Comercial de Sinop, com um
torrador de dois quilos e a teimosia de provar que o café produzido no
Mato Grosso pode brigar com os grãos mais famosos do país.
</p><p>
Hoje trabalhamos com quatro sítios parceiros no cerrado mato-grossense e
torramos, em média, sessenta quilos por semana — tudo consumido aqui
mesmo, no balcão ou nas mesas da calçada.
</p><h3>O que você encontra aqui</h3><ul><li>Wi-Fi liberado e tomada em todas as mesas</li><li>Moagem na hora, com escolha da torra</li><li>Grãos embalados para levar para casa</li><li>Opções sem lactose e sem glúten identificadas no cardápio</li></ul></section><sectionid="destaques"><h2>Destaques da semana</h2><ulclass="cartoes"><li><articleclass="cartao"><h3>Cold Brew da Chapada</h3><pclass="cartao__preco">R$ 15,00</p><p>Extração a frio por dezoito horas, servido com gelo e rodela de laranja.</p><p><ahref="cardapio.html#geladas">Ver nas bebidas geladas</a></p></article></li><li><articleclass="cartao"><h3>Pão de Queijo Mineiro</h3><pclass="cartao__preco">R$ 7,00</p><p>Massa de polvilho azedo com queijo canastra, assado de hora em hora.</p><p><ahref="cardapio.html#salgados">Ver nos salgados</a></p></article></li><li><articleclass="cartao"><h3>Bolo de Milho Verde</h3><pclass="cartao__preco">R$ 9,50</p><p>Receita da avó da Dona Marli, com milho comprado na feira do produtor.</p><p><ahref="cardapio.html#doces">Ver nos doces</a></p></article></li></ul></section><sectionid="horarios"><h2>Horário de atendimento</h2><divclass="tabela-rolavel"><table><caption>Horário de atendimento do Café Cerrado</caption><thead><tr><thscope="col">Dia</th><thscope="col">Abertura</th><thscope="col">Fechamento</th></tr></thead><tbody><tr><thscope="row">Segunda a sexta</th><td>07h00</td><td>20h00</td></tr><tr><thscope="row">Sábado</th><td>08h00</td><td>18h00</td></tr><tr><thscope="row">Domingo</th><tdcolspan="2">Fechado</td></tr></tbody></table></div><p>
Precisa de um horário fora do expediente para um evento?
<ahref="contato.html">Fale com a gente pelo formulário de contato</a>.
</p></section></main><footerclass="rodape"><divclass="container rodape__grade"><section><h2>Café Cerrado</h2><p>Torrefação artesanal de grãos do Cerrado mato-grossense. Projeto fictício
usado como estudo de caso do Nível 2 do WebLab (Desenvolvimento Web).</p></section><section><h2>Onde estamos</h2><address>
Avenida dos Jacarandás, 1200 — Setor Comercial<br>
Sinop — MT<br><ahref="tel:+556699999000">(66) 9 9999-9000</a><br><ahref="mailto:contato@cafecerrado.exemplo.br">contato@cafecerrado.exemplo.br</a></address></section><navaria-label="Rodapé"><h2>Navegação</h2><ul><li><ahref="index.html">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li><li><ahref="contato.html">Contato</a></li><li><ahref="index.html#horarios">Horários</a></li></ul></nav></div><pclass="rodape__creditos">Café Cerrado · Sinop/MT · Projeto acadêmico</p></footer></body></html>
Repare em duas escolhas:
Os cartões de destaque são <article> dentro de <li>: cada cartão é autocontido (faria sentido sozinho num feed), e a lista informa quantos são.
A <meta name="description"> não aparece na tela, mas é o texto que o buscador exibe abaixo do título nos resultados. Uma frase de até 160 caracteres.
Duplique index.html como cardapio.html e faça quatro trocas: o <title> vira Cardápio — Café Cerrado; a <meta name="description"> descreve o cardápio; o aria-current="page" sai do item Início e vai para o item Cardápio; e o <main> inteiro é substituído pelo bloco abaixo. O <footer> fica exatamente igual.
cafe-cerrado/cardapio.html — conteúdo do <main>
HTML
<mainclass="container"><h1>Cardápio</h1><p>
Preços válidos para consumo no local. Todos os cafés podem ser preparados
com leite vegetal por R$ 2,00 adicionais.
</p><navaria-label="Seções do cardápio"><ulclass="atalhos"><li><ahref="#cafes">Cafés</a></li><li><ahref="#geladas">Bebidas geladas</a></li><li><ahref="#salgados">Salgados</a></li><li><ahref="#doces">Doces</a></li><li><ahref="#torras">Guia de torras</a></li></ul></nav><sectionid="cafes"><h2>Cafés</h2><ulclass="produtos"><li><articleclass="produto"><h3>Espresso do Cerrado</h3><pclass="produto__preco">R$ 6,00</p><p>Cinquenta mililitros de grãos de altitude, torra média, extraídos em
vinte e cinco segundos.</p></article></li><li><articleclass="produto"><h3>Coado da Casa</h3><pclass="produto__preco">R$ 8,50</p><p>Duzentos mililitros em coador de papel, moagem média feita na hora
do pedido.</p></article></li><li><articleclass="produto"><h3>Cappuccino Sinop</h3><pclass="produto__preco">R$ 12,00</p><p>Espresso duplo, leite vaporizado e canela do Cerrado por cima.</p></article></li><li><articleclass="produto"><h3>Latte de Baunilha</h3><pclass="produto__preco">R$ 14,00</p><p>Espresso, leite vaporizado e calda de baunilha feita na casa.</p></article></li></ul></section><sectionid="geladas"><h2>Bebidas geladas</h2><ulclass="produtos"><li><articleclass="produto"><h3>Cold Brew da Chapada</h3><pclass="produto__preco">R$ 15,00</p><p>Extração a frio por dezoito horas, servida com gelo e rodela de laranja.</p></article></li><li><articleclass="produto"><h3>Frappê de Café</h3><pclass="produto__preco">R$ 16,00</p><p>Espresso batido com gelo, leite e chantili. Também sai sem lactose.</p></article></li></ul></section><sectionid="salgados"><h2>Salgados</h2><ulclass="produtos"><li><articleclass="produto"><h3>Pão de Queijo Mineiro</h3><pclass="produto__preco">R$ 7,00</p><p>Porção com quatro unidades de polvilho azedo com queijo canastra.</p></article></li><li><articleclass="produto"><h3>Torta de Frango</h3><pclass="produto__preco">R$ 13,00</p><p>Fatia generosa com massa amanteigada e recheio de frango desfiado.</p></article></li></ul></section><sectionid="doces"><h2>Doces</h2><ulclass="produtos"><li><articleclass="produto"><h3>Bolo de Milho Verde</h3><pclass="produto__preco">R$ 9,50</p><p>Fatia de bolo cremoso feito com milho da feira do produtor.</p></article></li><li><articleclass="produto"><h3>Brownie de Castanha</h3><pclass="produto__preco">R$ 11,00</p><p>Chocolate meio amargo com castanha-do-pará. Sem glúten.</p></article></li></ul></section><sectionid="torras"><h2>Guia de torras</h2><figure><imgsrc="img/grao-cerrado.jpg"alt="Grãos de café verdes sendo peneirados em uma bandeja de metal"width="1000"height="667"loading="lazy"><figcaption>
Grãos do Cerrado mato-grossense recém-beneficiados, antes da torra.
</figcaption></figure><dl><dt>Torra clara</dt><dd>Realça a acidez e as notas frutadas do grão. Boa para métodos coados.</dd><dt>Torra média</dt><dd>Equilibra doçura, corpo e acidez. É a nossa torra padrão.</dd><dt>Torra escura</dt><dd>Mais amarga e encorpada, com notas de chocolate amargo. Boa para espresso com leite.</dd></dl><divclass="tabela-rolavel"><table><caption>Grãos disponíveis para moagem e venda em pacote de 250 g</caption><thead><tr><thscope="col">Sítio parceiro</th><thscope="col">Torra</th><thscope="col">Preço do pacote</th></tr></thead><tbody><tr><thscope="row">Sítio Santa Rita</th><td>Clara</td><td>R$ 38,00</td></tr><tr><thscope="row">Fazenda Vale Verde</th><td>Média</td><td>R$ 35,00</td></tr><tr><thscope="row">Sítio Boa Esperança</th><td>Escura</td><td>R$ 33,00</td></tr></tbody></table></div><p>
Quer levar grãos para casa em outra moagem?
<ahref="contato.html">Peça pelo formulário de contato</a>.
</p></section></main>
💡 Dica
Guarde esses dez produtos com carinho: nome, categoria, preço e descrição. Na Aula 07 eles voltam como um array de objetos em js/app.js, renderizados como cartões pelo próprio JavaScript; na Unidade 3, viram o data/produtos.json servido pela sua API Express. Quanto mais consistente o cardápio ficar hoje, menos retrabalho depois.
Mesma receita: duplique index.html como contato.html, ajuste <title> e <meta name="description">, mova o aria-current="page" para o item Contato e troque o <main> pelo bloco abaixo.
cafe-cerrado/contato.html — conteúdo do <main>
HTML
<mainclass="container"><h1>Fale com a gente</h1><p>
Reservas para grupos, encomendas de bolos e tortas, eventos e parcerias.
Respondemos em até um dia útil. Campos marcados com asterisco são obrigatórios.
</p><formclass="formulario"action="contato.html"method="post"><fieldset><legend>Seus dados</legend><pclass="campo"><labelfor="nome">Nome completo *</label><inputtype="text"id="nome"name="nome"requiredminlength="3"maxlength="80"autocomplete="name"placeholder="Ana Beatriz Souza"></p><pclass="campo"><labelfor="email">E-mail *</label><inputtype="email"id="email"name="email"requiredautocomplete="email"placeholder="voce@exemplo.com"></p><pclass="campo"><labelfor="telefone">Telefone com DDD</label><inputtype="tel"id="telefone"name="telefone"pattern="\(?[0-9]{2}\)?\s?9?[0-9]{4}-?[0-9]{4}"title="Digite o telefone com DDD, no formato (66) 99999-0000"inputmode="tel"autocomplete="tel"placeholder="(66) 99999-0000"></p><pclass="campo"><labelfor="cep">CEP</label><inputtype="text"id="cep"name="cep"pattern="[0-9]{5}-?[0-9]{3}"title="Digite um CEP no formato 78550-000"inputmode="numeric"autocomplete="postal-code"placeholder="78550-000"></p></fieldset><fieldset><legend>Sobre o seu pedido</legend><pclass="campo"><labelfor="assunto">Assunto *</label><selectid="assunto"name="assunto"required><optionvalue="">Selecione um assunto</option><optgrouplabel="Atendimento"><optionvalue="reserva">Reserva de mesa</option><optionvalue="encomenda">Encomenda de bolos e tortas</option><optionvalue="graos">Compra de grãos em pacote</option></optgroup><optgrouplabel="Institucional"><optionvalue="evento">Evento ou parceria</option><optionvalue="trabalhe">Trabalhe conosco</option></optgroup></select></p><pclass="campo"><labelfor="pessoas">Quantas pessoas</label><inputtype="number"id="pessoas"name="pessoas"min="1"max="40"step="1"value="2"></p><pclass="campo"><labelfor="data">Data desejada</label><inputtype="date"id="data"name="data"></p><pclass="campo"><labelfor="horario">Horário</label><inputtype="time"id="horario"name="horario"min="07:00"max="20:00"step="900"></p><pclass="campo"><labelfor="mensagem">Mensagem *</label><textareaid="mensagem"name="mensagem"rows="5"maxlength="500"requiredminlength="10"placeholder="Conte o que você precisa"></textarea></p></fieldset><fieldset><legend>Como prefere ser respondido?</legend><pclass="campo campo--linha"><label><inputtype="radio"name="canal"value="email"checked>
E-mail
</label><label><inputtype="radio"name="canal"value="telefone">
Telefone
</label><label><inputtype="radio"name="canal"value="whatsapp">
WhatsApp
</label></p></fieldset><pclass="campo"><label><inputtype="checkbox"name="novidades"value="sim">
Quero receber avisos de novos lotes de café por e-mail
</label></p><pclass="campo"><label><inputtype="checkbox"name="consentimento"value="sim"required>
Autorizo o Café Cerrado a usar meus dados para responder a este contato *
</label></p><inputtype="hidden"name="origem"value="site-contato"><pclass="campo"><buttontype="submit"class="botao">Enviar mensagem</button><buttontype="reset"class="botao botao--vazado">Limpar formulário</button></p></form><sectionid="outros-canais"><h2>Outros canais</h2><ul><li><ahref="tel:+556699999000">Ligar para (66) 9 9999-9000</a></li><li><ahref="mailto:contato@cafecerrado.exemplo.br?subject=Reserva%20de%20mesa">
Enviar e-mail já com o assunto "Reserva de mesa"
</a></li><li><ahref="index.html#horarios">Conferir o horário de atendimento</a></li></ul></section></main>
O action="contato.html" é um placeholder honesto: o site ainda é estático, não há servidor para receber os dados. Ao enviar, o navegador recarrega a própria página — e é exatamente isso que você deve observar no teste. Na Aula 07, o JavaScript vai interceptar o envio; na Unidade 3, o action aponta para a sua API Express.
Passo 4 — Um mínimo de CSS para enxergar a estrutura¶
Na próxima aula o Café Cerrado adota o Bootstrap, e boa parte deste CSS será substituída. Por enquanto, o suficiente para que a estrutura fique legível — escrito com as variáveis da Aula 02, porque o checkpoint daquela aula ("nenhuma cor literal fora do :root") continua valendo.
Primeiro, acrescente três variáveis ao :root que já existe no alto do arquivo. Não crie um segundo :root: edite o que está lá.
cafe-cerrado/css/estilo.css — dentro do :root da Aula 02
CSS
:root{/* … as variáveis da Aula 02 continuam aqui … *//* Novas nesta aula */--cor-borda-campo:#8a7a68;/* 4,1:1 sobre o branco: a WCAG exige 3:1 na borda de um campo */--cor-erro:#b42318;--realce-invalido:rgba(180,35,24,0.25);}
Agora acrescente as regras abaixo ao final do arquivo, depois de ter apagado as regras órfãs da Aula 02 (o aviso do início do Mão na massa).
cafe-cerrado/css/estilo.css — acrescente ao final
CSS
/* Ancoragem suave, compensando a altura do cabeçalho */html{scroll-behavior:smooth;scroll-padding-top:5rem;}.container{width:100%;max-width:var(--largura-maxima);margin-inline:auto;padding-inline:var(--espaco-2);}/* Cabeçalho e menu */.topo{background:var(--cor-superficie);border-bottom:1pxsolidvar(--borda-suave);}.topo__interno{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:var(--espaco-2);padding-block:var(--espaco-2);}.marca{color:var(--cor-marca-escura);text-decoration:none;}.marca__nome{display:block;font-size:1.4rem;font-weight:700;}.marca__slogan{display:block;font-size:.8rem;color:var(--cor-texto-suave);}.menu{display:flex;flex-wrap:wrap;gap:1.5rem;list-style:none;margin:0;padding:0;}.menua{display:block;padding:.25rem0;color:var(--cor-marca-escura);font-weight:500;text-decoration:none;}.menua:hover,.menua:focus-visible{text-decoration:underline;}/* O destaque da página atual sai do atributo, sem classe extra */.menua[aria-current="page"]{color:var(--cor-destaque);border-bottom:2pxsolidvar(--cor-destaque);}/* Listas de cartões e de produtos */.cartoes,.produtos,.atalhos{list-style:none;margin:0;padding:0;}.cartoes,.produtos{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:var(--espaco-2);margin-block:var(--espaco-2)var(--espaco-3);}.atalhos{display:flex;flex-wrap:wrap;gap:var(--espaco-2);margin-block:var(--espaco-2);}.cartao,.produto{height:100%;padding:var(--espaco-2);background:var(--cor-superficie);border:1pxsolidvar(--borda-suave);border-radius:var(--raio);}.cartao__preco,.produto__preco{font-weight:700;color:var(--cor-destaque);}/* Tabelas: rolam sozinhas em vez de estourar a tela */.tabela-rolavel{overflow-x:auto;margin-block:var(--espaco-2);}table{border-collapse:collapse;width:100%;min-width:22rem;}caption{text-align:left;font-weight:600;padding-bottom:var(--espaco-1);}th,td{border:1pxsolidvar(--borda-suave);padding:.5rem.75rem;text-align:left;}theadth{background:var(--cor-fundo);}/* Figuras */figure{margin:1.5rem0;}figureimg{max-width:100%;height:auto;border-radius:var(--raio);}figcaption{font-size:.9rem;color:var(--cor-texto-suave);padding-top:var(--espaco-1);}/* Formulário */.formulariofieldset{padding:var(--espaco-2);margin-block:1.5rem;border:1pxsolidvar(--borda-suave);border-radius:var(--raio);}.formulariolegend{font-weight:600;padding-inline:var(--espaco-1);}.campo{display:flex;flex-direction:column;gap:.35rem;margin-block:var(--espaco-2);}.campo--linha{flex-direction:row;flex-wrap:wrap;gap:1.5rem;}.campolabel{font-weight:500;}.campoinput,.camposelect,.campotextarea{font:inherit;max-width:32rem;padding:var(--espaco-1);border:1pxsolidvar(--cor-borda-campo);border-radius:6px;}/* :user-invalid só marca o campo DEPOIS da interação (seção 4.5) */.campoinput:user-invalid,.camposelect:user-invalid,.campotextarea:user-invalid{border-color:var(--cor-erro);outline:2pxsolidvar(--realce-invalido);}/* Botões */.botao{display:inline-block;padding:.6rem1.2rem;background:var(--cor-marca);color:var(--cor-superficie);border:2pxsolidvar(--cor-marca);border-radius:999px;font:inherit;font-weight:600;text-decoration:none;cursor:pointer;}.botao--vazado{background:transparent;color:var(--cor-marca);}.botao:hover,.botao:focus-visible{background:var(--cor-marca-escura);border-color:var(--cor-marca-escura);color:var(--cor-superficie);}/* Rodapé */.rodape{margin-top:var(--espaco-4);padding-block:var(--espaco-3);background:var(--cor-marca-escura);color:var(--cor-superficie);}.rodape__grade{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--espaco-3);}.rodapeh2{font-size:1rem;}.rodapeul{list-style:none;padding:0;}.rodapea{color:var(--cor-fundo);}.rodapeaddress{font-style:normal;line-height:1.7;}.rodape__creditos{text-align:center;font-size:.85rem;opacity:.8;padding-top:1.5rem;}
🧠 Você sabia?
As duas regras .rodape — a da Aula 02 e esta — não podem coexistir. Se você não apagou a antiga, o navegador aplica as duas, na ordem do arquivo, e o resultado é uma mistura: text-align: center da primeira sobrevive porque a segunda não o redefine. Esse é o tipo de bug que não aparece no validador, não aparece no console e só some quando alguém lê o CSS inteiro. Na Aula 04 o problema desaparece pela raiz: o estilo.css é reescrito do zero.
Abra https://validator.w3.org/nu/, escolha Validate by direct input e cole o conteúdo de cada uma das três páginas, uma por vez. Corrija tudo o que aparecer como Error. Os Warning merecem leitura, mas nem todos exigem ação — o aviso sobre <section> sem título, por exemplo, é legítimo e você deve resolver; o aviso sobre codificação de caracteres desaparece quando a página é servida por HTTP de verdade.
Abra index.html com o Live Server. Os três links do menu funcionam e "Início" está destacado. Em cardapio.html, o destaque muda para "Cardápio"; em contato.html, para "Contato".
Clique em "Ver nas bebidas geladas" no cartão do Cold Brew: você vai para cardapio.html e a página rola até a seção Bebidas geladas, com o título visível abaixo do cabeçalho (é o scroll-padding-top funcionando).
No cardapio.html, os cinco atalhos rolam para as seções corretas. Reduza a janela para 380 px de largura: a tabela de grãos ganha uma barra de rolagem própria e a página não rola na horizontal.
Em contato.html, clique em Enviar mensagem com tudo em branco. O navegador deve bloquear o envio e mostrar a mensagem "Preencha este campo" (ou equivalente) no campo Nome completo.
Preencha o nome com duas letras e envie: a mensagem muda para algo como "Use pelo menos 3 caracteres". Digite ana no e-mail: "Inclua um '@' no endereço de e-mail".
Digite 123 no telefone e envie: aparece o texto do title — "Digite o telefone com DDD, no formato (66) 99999-0000".
Preencha tudo corretamente, sem marcar o consentimento, e envie: o navegador bloqueia no checkbox. Marque e envie: a página recarrega (comportamento esperado por enquanto).
Clique no texto do rótulo "Quero receber avisos de novos lotes": o checkbox marca. Se não marcar, o label não está associado ao campo.
Navegue a página inteira apenas com Tab. Todo campo deve mostrar foco visível, e a ordem deve ser de cima para baixo.
Cole as três páginas no validador do W3C: zero erros.
A1. Escreva o esqueleto semântico de uma página de notícia: cabeçalho com o nome do jornal e o menu, conteúdo principal com o artigo (título, autor, corpo), uma barra lateral com "leia também" e o rodapé. Use apenas tags semânticas — zero div.
A2. Qual a diferença entre <section> e <article>? Dê um exemplo de cada tirado do cardapio.html que você escreveu e justifique.
A3. Explique por que <header> pode aparecer várias vezes em uma página, mas <main> não. O que muda na árvore de acessibilidade quando o <header>não é filho direto do <body>?
A4. Corrija a hierarquia de títulos abaixo e explique cada mudança:
HTML
<h1>Café Cerrado</h1><h3>Cardápio</h3><h2>Espresso do Cerrado</h2><h5>Ingredientes</h5><h1>Contato</h1>
A5. Para cada situação, escreva o alt adequado (ou justifique o alt=""): (a) foto do cappuccino no cardápio; (b) ícone de xícara ao lado do texto "Cafés", que já está escrito na tela; (c) logo do Café Cerrado dentro de um link para index.html; (d) gráfico com o consumo semanal por tipo de bebida.
A6. Escreva a marcação completa de uma tabela com o consumo semanal de três produtos, incluindo caption, thead, tbody e th com scope correto nas duas direções. Depois explique, em uma frase, o que muda para o leitor de tela por causa do scope.
A7. Dê o código de cinco links diferentes a partir de cardapio.html: (a) para o index.html; (b) para a seção #doces da própria página; (c) para a seção #horarios da página inicial; (d) para o site da sua universidade ou escola em nova aba, com segurança; (e) para o e-mail do café já com o assunto "Encomenda".
A8. Qual a diferença entre id e name em um campo de formulário? O que acontece se faltar cada um deles?
A9. Escreva um campo de CPF com validação nativa: obrigatório, teclado numérico no celular, aceitando 000.000.000-00 ou 00000000000, com mensagem de erro clara. Explique cada atributo que você usou.
A10. Prevê a saída: o formulário abaixo é enviado com o campo preenchido. Quais pares chave/valor chegam ao servidor? Por quê?
A11. Explique, em três frases, por que a validação no navegador não substitui a validação no servidor. Descreva um passo a passo concreto de como alguém burlaria a validação nativa do contato.html.
B1. Construa a página sobre.html do Café Cerrado com estrutura semântica completa: cabeçalho e rodapé iguais aos das outras três páginas (com aria-current no lugar certo), um <main> com a história em duas <section>, uma <aside> com a caixa "Nossos parceiros" (lista de três sítios), uma <figure> com foto e legenda, e a página adicionada ao menu de todas as outras.
Resultado esperado: quatro páginas navegáveis entre si, aria-current="page" correto em cada uma, zero erros no validador do W3C, e a árvore de acessibilidade do DevTools mostrando os landmarks banner, navigation, main, complementary e contentinfo.
Dica
Copie o cabeçalho de index.html e troque duas coisas: o aria-current sai do "Início" e vai para o novo item, e o novo item entra em todos os quatro menus. Se esquecer de um, o teste falha — faça uma passada final abrindo as quatro páginas em sequência.
B2. Crie encomendas.html com um formulário de encomenda de bolos, contendo ao menos oito campos de tipos diferentes: texto, e-mail, telefone com pattern, data, número com min/max, select com optgroup, grupo de radio dentro de fieldset, checkbox obrigatório de consentimento e textarea com maxlength. Todos com label e autocomplete quando fizer sentido.
Resultado esperado: o navegador bloqueia o envio em cada campo obrigatório vazio, com mensagem específica; o campo de telefone recusa 123 e aceita (66) 99999-0000; clicar em qualquer rótulo foca ou marca o campo; a página passa no validador do W3C.
Dica
Comece pela lista dos oito campos em papel, escrevendo ao lado de cada um o type, o name e a regra de validação. Só depois escreva o HTML. Para o select com required, lembre-se da primeira <option value="">.
B3. Refatore o HTML abaixo, que é o cardápio de um concorrente. Ele produz o visual certo e a semântica errada. Reescreva usando as tags corretas, sem mudar o texto exibido, e escreva um comentário HTML de uma linha acima de cada bloco explicando a troca.
HTML
<divclass="topo"><divclass="titulo-grande">Padaria do Bairro</div><divclass="links"><ahref="index.html">Home</a> | <ahref="menu.html">Menu</a></div></div><divclass="miolo"><divclass="titulo-medio">Pães</div><divclass="item"><divclass="titulo-pequeno">Pão francês</div><div>R$ 0,90 a unidade</div><imgsrc="pao.jpg"></div><divclass="tabela"><divclass="linha"><div>Dia</div><div>Abre</div></div><divclass="linha"><div>Segunda</div><div>06h</div></div></div></div><divclass="rodape">Copyright Padaria do Bairro</div>
Resultado esperado: a nova versão usa header, nav, ul, main, section, article, h1–h3, img com alt, table com caption/thead/tbody/th scope e footer; passa no validador do W3C; e a árvore de acessibilidade mostra pelo menos quatro landmarks.
Dica
Faça de trás para frente: primeiro identifique o papel de cada div pelo nome da classe, depois escolha a tag. titulo-grande no topo é o h1; linha dentro de tabela é tr. A img sem alt é erro de validação, não apenas má prática.
B4. Escreva um "guia de estilo do projeto" no arquivo docs/html.md do seu repositório, com as decisões de marcação que você vai seguir o semestre inteiro: quando usar section × article × div, como nomear arquivos de imagem, o padrão de alt, quando usar tabela, o padrão do menu (lista + aria-label + aria-current) e o checklist de validação antes de cada commit. Mínimo de uma página, com um exemplo de código para cada regra.
Resultado esperado: o arquivo existe no repositório, tem pelo menos seis regras, cada uma com um exemplo curto de código, e você consegue apontar no index.html do Café Cerrado uma linha que segue cada regra.
Dica
Escreva as regras como frases imperativas curtas ("Toda imagem informativa tem alt descritivo em uma frase") e não como teoria. Um guia de estilo que ninguém consegue conferir em dez segundos não é usado por ninguém — nem por você.
C1. Estrutura completa do projeto autoral. Defina o domínio do seu projeto (o seu "Café Cerrado": um brechó, um viveiro de mudas, uma escolinha de futebol, um estúdio de tatuagem, uma banda) e construa as três páginas equivalentes: inicial, catálogo e contato. Exigências: HTML semântico com todos os landmarks; um <h1> por página e hierarquia de títulos sem saltos; pelo menos uma <figure>, uma tabela de dados com caption e th scope, uma <dl> e três tipos de lista; menu idêntico nas três páginas com aria-label e aria-current; formulário de contato com no mínimo oito campos de tipos diferentes, todos com label, required onde faz sentido e ao menos dois pattern; e as três páginas com zero erros no validador do W3C. Este é o esqueleto que será estilizado na Aula 04 e chega pronto no Marco 1.
Dica
Não comece pelo código: liste em papel o conteúdo real das três páginas (títulos, seções, itens do catálogo com preço e descrição) e só então escolha as tags. Os itens do seu catálogo vão virar objetos JavaScript na Aula 07 — se você escrever nomes, categorias, preços e descrições consistentes agora, ganha tempo depois. Copie a estrutura do Café Cerrado e troque o conteúdo; o que se aprende aqui é a arquitetura, não o texto.
Quantas regiões um leitor de tela consegue enxergar no seu site? E no site da sua faculdade? Sem instalar nada, o DevTools mostra a mesma árvore que o leitor de tela usa — e a diferença entre um site bem marcado e um mar de div fica escandalosa em trinta segundos. Faça essa comparação e, no caminho, descubra se o seu projeto está mais para um lado ou para o outro.
Critérios de pronto
Uma tabela em docs/landmarks.md comparando três sites (o seu projeto autoral, o portal da sua universidade ou escola e um site de sua escolha) com as colunas: site, quantidade de landmarks, landmarks encontrados.
Para cada site, a captura de tela da árvore de acessibilidade do DevTools.
Um parágrafo apontando o site com melhor estrutura e explicando por quê, citando tags específicas.
No mínimo duas correções aplicadas ao seu próprio projeto a partir do que você observou, listadas no arquivo com o antes e o depois.
Pistas
No Chrome: F12 → Elements → painel direito → aba Accessibility → marque "Enable full-page accessibility tree" e clique no ícone no topo do painel Elements.
No Firefox, a aba Acessibilidade tem um filtro por tipo de item — escolha "Landmarks" para ver só as regiões.
Sites com muitos <div role="navigation"> estão simulando com ARIA o que a tag nativa faria de graça. Anote quando encontrar: isso vira assunto na aula de Acessibilidade e ARIA.
Existe um formulário famoso por ser insuportável: o de cadastro que rejeita o seu telefone porque você digitou parênteses, recusa a senha porque tem um caractere "inválido" que ele não diz qual é, e apaga tudo quando você erra um campo. O oposto disso — um formulário que ajuda — é feito de escolhas pequenas: inputmode certo, autocomplete certo, pattern permissivo com title explicativo, e nenhum campo obrigatório sem necessidade. Reprojete o contato.html do Café Cerrado com essa obsessão.
Critérios de pronto
Todos os campos têm autocomplete com o valor correto da especificação HTML (pesquise a lista de tokens; nome, email, tel, cep e data têm token próprio).
Todo campo de texto com formato definido tem inputmode adequado e um title que descreve o formato em linguagem natural, não em expressão regular.
Os pattern aceitam as variações que uma pessoa real digita: telefone com e sem parênteses, com e sem espaço, com e sem hífen; CEP com e sem hífen.
Um arquivo docs/formulario.md com uma tabela de três colunas: campo, o que era exigido antes, o que passou a ser aceito.
Teste no celular (ou no modo dispositivo do DevTools) documentado com capturas: cada campo abre o teclado correto.
Pistas
A lista completa de valores de autocomplete está na especificação do WHATWG e na página "atributo autocomplete" da MDN — são mais de cinquenta tokens.
inputmode aceita numeric, tel, email, decimal, search e url. Ele muda o teclado sem mudar a semântica do campo, o que é diferente de trocar o type.
Para o telefone, pense na expressão regular em partes: DDD opcionalmente entre parênteses, espaço opcional, nono dígito opcional, quatro dígitos, hífen opcional, quatro dígitos.
Teste cada pattern isoladamente em um arquivo separado antes de colocar no formulário. Um pattern errado bloqueia o envio de valores corretos e é difícil de perceber.
O arquivo abaixo abre no navegador sem nenhum erro visível no console e parece funcionar. Mas ele tem sete problemas: alguns o validador do W3C aponta, outros só aparecem quando você navega por teclado ou clica nos rótulos. Encontre todos os sete, corrija e documente cada um. Regra: você só pode usar o validador do W3C, o DevTools e a tecla Tab — nada de pedir a resposta pronta.
caca-ao-bug.html
HTML
<!DOCTYPE html><html><head><title>Reserva</title></head><body><divclass="topo"><h2>Café Cerrado</h2><ahref="index.html">Início</a><ahref="Cardapio.html">Cardápio</a></div><h1>Reserva de mesa</h1><formmethod="post"><labelfor="nome">Nome</label><inputtype="text"id="nome"required><labelfor="email">E-mail</label><inputtype="text"id="nome"name="email"required><label>Telefone</label><inputtype="number"name="telefone"pattern="[0-9]{11}"><selectname="mesa"><option>Mesa interna</option><option>Mesa na calçada</option></select><imgsrc="/img/mesa.jpg"><button>Reservar</button></form></body></html>
Critérios de pronto
Uma versão corrigida do arquivo, com um comentário HTML de uma linha acima de cada correção, explicando o problema.
O arquivo corrigido passa no validador do W3C com zero erros e zero avisos evitáveis.
Um documento docs/bugs-marcacao.md com os sete problemas, cada um classificado como "erro de validação", "erro de acessibilidade" ou "erro que só aparece em produção".
O formulário corrigido envia todos os campos: prove com a aba Network do DevTools, mostrando o corpo da requisição.
Pistas
Comece pelo validador. Ele acha três de uma vez, e um deles envolve dois elementos com o mesmo identificador.
Depois pressione Tab e clique em cada rótulo. Um dos rótulos não faz nada quando clicado — por quê?
Um dos campos nunca chega ao servidor, mesmo preenchido. Compare os atributos dele com os dos outros (revise a seção 4.1).
Um dos problemas só aparece depois do git push: o link funciona na sua máquina e dá 404 no GitHub Pages. Há dois motivos possíveis nesse arquivo — o caminho da imagem e o nome de um arquivo.
pattern não funciona em qualquer type. O validador avisa; leia a mensagem inteira.
Escolha uma página de um site que você usa toda semana — um portal de notícias, uma loja, o sistema acadêmico, um blog técnico — e reproduza a estrutura semântica dela: só HTML, sem uma linha de CSS, com conteúdo escrito por você (nada de copiar textos ou imagens). O objetivo não é ficar parecido: é descobrir que a maior parte do que você vê na tela é decoração por cima de uma árvore de umas quarenta tags. Ao terminar, você vai olhar para qualquer site com raio-X.
Critérios de pronto
O arquivo reproducao.html renderiza sem CSS e continua compreensível: dá para ler a página inteira de cima a baixo e entender a hierarquia só pelos títulos.
Um documento com o sumário de títulos (h1 a h4) da página original e o da sua reprodução, lado a lado, apontando as diferenças.
Pelo menos oito tags semânticas diferentes usadas corretamente, listadas com uma justificativa de uma linha cada.
Uma tabela comparando a quantidade de landmarks da página original com a da sua reprodução, com um parágrafo dizendo qual das duas um leitor de tela navegaria melhor e por quê.
Zero erros no validador do W3C e nenhuma <div> que pudesse ser substituída por uma tag semântica.
Para ir além: aplique a mesma reprodução à página inicial do seu projeto autoral e compare — o que o site profissional faz que o seu ainda não faz?
Pistas
Comece pelo DevTools na página original: colapse todos os nós do painel Elements (Ctrl + clique na seta) e vá abrindo nível por nível. A estrutura macro aparece em menos de um minuto.
Desligue o CSS da página original para ver o esqueleto: no Firefox, menu Exibir → Estilo da página → Sem estilo; no Chrome, desmarque a folha de estilo na aba Network e recarregue, ou use o modo leitor.
Extraia o sumário de títulos com a árvore de acessibilidade do DevTools, filtrando por "headings" — é mais rápido que ler o HTML.
Se a página usa muito <div role="...">, anote: é um site simulando com ARIA o que a tag nativa faria sozinha. Na sua reprodução, use a tag nativa e mostre a diferença na contagem de landmarks.
Parte 1 — Leitura (20 min). QUEIRÓS e PORTELA, Introdução ao Desenvolvimento Moderno para a Web, seções sobre a camada de estrutura (HTML) e a camada de apresentação (CSS) — esta segunda serve de preparação para a próxima aula. Na MDN em pt-BR, leia "Estrutura de um documento e sites" e "Seu primeiro formulário". Anote duas tags ou atributos que aparecem nas leituras e não apareceram nesta aula.
Parte 2 — Entrega (30 min). No repositório do seu projeto autoral:
As três páginas do exercício C1 completas e navegáveis entre si.
O guia de marcação do exercício B4 em docs/html.md.
As três páginas validadas no W3C — cole no README.md o print ou o texto "Document checking completed. No errors or warnings to show." para cada uma.
README.md atualizado com: nome do projeto, uma frase sobre o domínio escolhido, a lista de páginas e o link do GitHub Pages.
Parte 3 — Explicar a decisão (10 min). Em docs/decisao.md, escreva um trecho de dez a vinte linhas do HTML do seu projeto e explique uma decisão de marcação que você tomou e por quê (por exemplo: por que aquele bloco é article e não section). Se puder, compare com outra pessoa que esteja estudando (ou releia depois de um dia) e anote uma melhoria concreta que encontrar.
Critério de pronto: as três páginas do projeto autoral abrem pelo endereço do GitHub Pages; o menu leva de qualquer página para qualquer outra e marca a atual com aria-current; o formulário bloqueia o envio quando algum campo obrigatório está vazio, com mensagem específica; e o validador do W3C não aponta nenhum erro em nenhuma das três.
Ao fim desta aula, o repositório do seu projeto autoral deve ter:
[ ] Três páginas HTML ligadas entre si, todas com <!DOCTYPE html>, lang="pt-BR", <meta charset>, <meta name="viewport">, <title> distinto e <meta name="description">.
[ ] Landmarks completos em todas as páginas: header, nav, main, footer (e aside em pelo menos uma).
[ ] Um único <h1> por página e hierarquia de títulos sem saltos de nível.
[ ] Menu idêntico nas três páginas, como <ul> dentro de <nav aria-label="Principal">, com aria-current="page" no item correto de cada uma.
[ ] Toda imagem com alt adequado (descritivo ou alt="" para decorativas) e width/height declarados.
[ ] Pelo menos uma <figure> com <figcaption> e uma <dl> com três pares.
[ ] Uma tabela de dados com caption, thead, tbody e th scope, dentro de um contêiner com overflow-x: auto.
[ ] Pelo menos cinco formas diferentes de link em uso: página interna, âncora, âncora em outra página, URL externa com rel="noopener", mailto: e/ou tel:.
[ ] Formulário de contato com no mínimo oito campos de tipos diferentes, cada um com <label>, agrupados em <fieldset>/<legend>, com required, ao menos dois pattern com title e um checkbox de consentimento obrigatório.
[ ] Caminhos de arquivo relativos, em minúsculas, sem espaços e sem acentos.
[ ] Zero erros no validador do W3C nas três páginas.
[ ] commit e push feitos; site atualizado no GitHub Pages.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — camada de estrutura (HTML) e camada de apresentação (CSS).
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — capítulos de estruturação de páginas e formulários.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — elementos de interface e formulários em sistemas.
Na próxima aula o Café Cerrado ganha aparência profissional sem você escrever centenas de linhas de CSS: vamos comparar as duas filosofias dominantes dos frameworks CSS — componentes prontos e classes utilitárias —, experimentar Bootstrap, Tailwind e Material Web lado a lado, e adotar um deles no projeto, com a escolha justificada no README.md. A estrutura semântica que você escreveu hoje permanece exatamente como está: o framework entra nas classes, não no lugar das tags.
Explicar quais problemas concretos um framework CSS resolve — reset, grid, breakpoints, componentes e escala visual — e quais problemas ele cria.
Distinguir as duas filosofias dominantes do mercado (componentes prontos × classes utilitárias) e reconhecer qual delas um projeto qualquer está usando só olhando o HTML.
Carregar um framework por CDN com versão fixa, integrity e crossorigin, e explicar o que a hash SRI protege e o que ela não protege.
Montar layouts responsivos com o grid de 12 colunas do Bootstrap 5.3 (container → row → col-*) e prever, sem abrir o navegador, o que acontece em cada breakpoint.
Personalizar o Bootstrap pelo caminho certo — sobrescrevendo as variáveis CSS --bs-* — em vez de brigar com o framework a golpes de !important.
Escrever o mesmo componente nos três frameworks (Bootstrap, Tailwind 4 e Material Web) e comparar peso, verbosidade e liberdade visual com números medidos na aba Network.
Justificar por escrito, no README.md, a escolha de framework do seu projeto — requisito do Marco 1.
[ ] Repositório cafe-cerrado com index.html, cardapio.html e contato.html publicados no GitHub Pages (Aula 03).
[ ] As três páginas com landmarks completos (header, nav, main, footer), menu idêntico com aria-current="page" e zero erros no validador do W3C.
[ ] css/estilo.css ligado às três páginas e com o bloco :root de variáveis criado na Aula 02.
[ ] Formulário de contato.html com fieldset, label em todos os campos e validação nativa funcionando.
[ ] VS Code com Live Server; Chrome ou Firefox com DevTools — hoje você vai viver nas abas Elements, Network e no modo dispositivo.
[ ] Git configurado e o hábito de commit + push a cada passo concluído.
Na aula passada o Café Cerrado virou um site de verdade: três páginas ligadas entre si, cardápio com listas e tabela, formulário de contato que o navegador valida sozinho e um CSS mínimo, escrito à mão, só para enxergar a estrutura. O problema é que esse CSS mínimo continua mínimo: em 380 px de largura o menu se amontoa, os cartões não viram uma coluna e o formulário tem cara de 1998. Hoje você não vai escrever quinhentas linhas de CSS para resolver isso — vai comparar os três frameworks que dominam o mercado, entender a filosofia de cada um e adotar um deles no projeto. A estrutura semântica de ontem não muda uma tag: o framework entra nas classes.
1.1 A lista de coisas que ninguém quer reescrever¶
Abra o css/estilo.css que você escreveu nas Aulas 02 e 03 e conte: são cerca de 250 linhas para três páginas simples. Agora imagine o site inteiro de uma prefeitura, com quarenta páginas. Toda equipe que começa um projeto do zero precisa resolver, na mão, exatamente os mesmos seis problemas:
Normalizar o navegador. Chrome, Firefox e Safari têm margens, tamanhos de fonte e estilos de botão diferentes por padrão. Sem um reset, a mesma página fica com espaçamentos distintos em cada um.
Um sistema de grid. Colocar três blocos lado a lado no desktop e um embaixo do outro no celular é o problema de layout mais repetido da Web.
Breakpoints coerentes. Em que largura o layout muda? 600 px? 768 px? Se cada componente escolher o seu, o site quebra em pedaços diferentes conforme a tela.
Componentes repetitivos. Menu que colapsa em hambúrguer, card, modal, alerta, badge, paginação. Todo site tem os mesmos.
Uma escala visual. Espaçamentos, tamanhos de fonte, raios de borda e sombras precisam vir de um conjunto pequeno de valores. Sem escala, um botão tem padding: 11px, o outro 12px, e a página parece torta sem que ninguém saiba dizer por quê.
Estados dos formulários. Campo focado, campo inválido, campo desabilitado, texto de ajuda.
Um framework CSS é uma resposta pronta e testada para os seis. Você troca "escrever" por "aprender o vocabulário": em vez de padding: 1rem, escreve class="p-3"; em vez de uma media query, escreve class="col-12 col-md-6".
Não existe almoço grátis. O preço vem em quatro parcelas:
Peso. O CSS completo do Bootstrap 5.3 tem cerca de 230 KB (uns 32 KB comprimidos na rede). Seu site vai usar talvez 15 % disso. Em produção séria, o framework é instalado via npm e passa por uma etapa de build que remove o não utilizado.
Cara de framework. Sites que usam os componentes sem customizar nada ficam idênticos entre si. É o famoso "isso aí é Bootstrap" que qualquer desenvolvedor identifica em dois segundos.
Vocabulário para decorar.ms-auto, d-flex, col-md-6, text-bg-primary. Nada disso é CSS: é o dialeto de um framework específico, que você esquece se ficar seis meses sem usar.
Dependência. Quando o Bootstrap 4 virou 5, ml-3 virou ms-3 e o jQuery sumiu. Projetos inteiros pararam para migrar.
⚠️ Atenção
Framework é aceleração, não substituto do fundamento. É por isso que este curso ensinou HTML semântico e CSS na mão antes: quando o col-md-6 não fizer o que você espera, quem resolve é quem sabe que aquilo é flex: 0 0 auto; width: 50% por baixo. Quem só decorou classes trava.
Três palavras que a internet mistura e que caem em prova:
Termo
O que é
Exemplo
Framework CSS
Conjunto pronto de classes, grid e componentes que você usa como base do layout
Bootstrap, Bulma, Tailwind
Biblioteca de componentes
Componentes prontos para um framework de JavaScript específico
Vuetify (Vue), MUI (React)
Design system
Especificação de princípios visuais, independente de tecnologia
Material Design, GOV.BR DS
Um design system pode ter várias implementações. O Material Design do Google tem implementação oficial em Web Components (Material Web), em Flutter, em Android e em React (via MUI, que é de terceiros). Guarde essa distinção: no Nível 3 você usa o Vuetify, que é uma biblioteca de componentes Vue que implementa o Material Design. É o mesmo design system desta aula, embrulhado de outro jeito.
🧠 Você sabia?
O Bootstrap nasceu como um problema interno do Twitter. Em 2010, cada equipe da empresa fazia sua própria interface, e o painel administrativo era uma colcha de retalhos. Dois funcionários, Mark Otto e Jacob Thornton, criaram uma biblioteca interna chamada Twitter Blueprint para padronizar aquilo. Em agosto de 2011 ela foi liberada como software livre com o nome Bootstrap e, por cerca de uma década, foi o repositório com mais estrelas do GitHub — antes de ser ultrapassado por listas de estudo e por projetos de IA. A escolha de abrir o código foi feita por dois desenvolvedores, sem plano de negócio: hoje é a base visual de uma fatia enorme dos sites administrativos do mundo, inclusive de muitos sistemas de universidades brasileiras.
Você lê btn btn-primary e sabe o que é: um botão de ação principal. Não sabe (nem precisa saber, no primeiro momento) qual é o padding, o border-radius ou a cor. Seguem essa filosofia: Bootstrap, Bulma, Materialize, Foundation.
A favor: protótipo rápido, HTML curto e legível, decisão de design já tomada por alguém.
Contra: para fugir do visual padrão você precisa lutar com a especificidade do framework — e é aí que aparece o !important desesperado.
Nenhuma dessas classes sabe o que é um botão. px-4 é padding-left e padding-right; bg-sky-700 é uma cor da paleta; hover:bg-sky-800 é a mesma coisa dentro de :hover. O framework dominante aqui é o Tailwind CSS.
A favor: liberdade visual total, nenhum "visual padrão", CSS final minúsculo (só as classes usadas entram no arquivo gerado), e você nunca mais inventa nome de classe.
Contra: HTML verboso, curva de memorização, e a repetição só desaparece quando você extrai componentes em algum outro lugar (um include, um componente Vue, um @apply).
Não caia na guerra santa. O Bootstrap 5 tem centenas de classes utilitárias (d-flex, mt-3, text-center, gap-2) — a filosofia utility entrou nele. E o Tailwind tem @apply e plugins que empacotam conjuntos de utilitários em uma classe só — a filosofia de componente entrou nele. A diferença real, hoje, é de onde você parte: de um componente que você customiza, ou de peças soltas que você monta.
📌 Vale gravar
Saber diferenciar as duas filosofias olhando um trecho de HTML é pergunta clássica. Regra prática: se as classes descrevem o que a coisa é (card, navbar, btn), é componente pronto; se descrevem como a coisa parece (flex, p-4, text-xl), é utility-first.
Há dois caminhos para colocar o CSS de um framework no seu projeto:
Caminho
Como é
Quando usar
CDN
Uma tag <link> apontando para um servidor público
Aprendizado, protótipos, sites estáticos pequenos
npm + build
npm install, import no código, empacotador gera o CSS
Produção; permite remover o CSS não utilizado
Nesta unidade o site é estático, sem Node e sem build — CDN é a resposta certa. A partir do Nível 3, com Vite, você instala tudo por npm.
Uma CDN (Content Delivery Network) é uma rede de servidores espalhados pelo mundo que devolve o arquivo a partir do ponto mais próximo de quem pediu. As duas mais usadas para pacotes JavaScript e CSS são a jsDelivr (cdn.jsdelivr.net) e a cdnjs (cdnjs.cloudflare.com); ambas servem qualquer pacote publicado no npm.
A primeira significa "me dê a versão mais recente". Parece prático e é uma armadilha: no dia em que o Bootstrap 6 sair, o seu site — que você não tocou há um ano — muda de layout sozinho, sem aviso e sem você ter feito nenhum commit. A segunda trava a versão. O seu site continua exatamente igual até você decidir mudar.
Ao usar CDN você está executando, no navegador dos seus usuários, um arquivo hospedado por outra pessoa. Se a CDN for invadida e o arquivo trocado, o site inteiro é comprometido. A defesa se chama SRI (Subresource Integrity):
O que acontece: o navegador baixa o arquivo, calcula o resumo criptográfico SHA-384 do conteúdo recebido e compara com o valor de integrity. Se um único byte for diferente, o recurso é bloqueado — não é aplicado, e um erro aparece no console. O crossorigin="anonymous" é obrigatório junto: sem ele, o navegador não consegue ler o corpo da resposta de outra origem para conferir a hash.
O que a hash não protege: ela não impede a CDN de saber quem visitou o seu site (cada usuário faz uma requisição para lá), nem funciona para conteúdo que muda a cada requisição. Ela garante uma coisa só, e garante bem: o arquivo que chegou é byte a byte o que eu esperava.
🔬 Investigue
Gere você mesmo a hash de um arquivo e prove que o número não é mágica. No terminal:
A saída tem que ser exatamente QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH. Agora abra o arquivo baixado, acrescente um espaço no final, salve e rode o comando de novo: a hash inteira muda. Depois, no index.html, troque um caractere do integrity e recarregue: a página fica sem estilo nenhum e o console mostra "Failed to find a valid digest in the 'integrity' attribute for resource". É o navegador se recusando a executar algo que não é o que você pediu.
O seu estilo.css vem depois. Motivo: quando dois seletores têm a mesma especificidade, a cascata do CSS dá a vitória ao que aparece por último. Se você inverter a ordem, o Bootstrap sobrescreve o seu ajuste e você vai passar meia hora achando que "o CSS não está pegando".
Isso vale só para o empate de especificidade. Se o Bootstrap usa .navbar .nav-link (dois seletores de classe) e você escreve .nav-link (um), a ordem não salva: o dele é mais específico e ganha de qualquer jeito. A saída correta nesse caso não é !important — é subir na especificidade do jeito certo ou, melhor ainda, mexer nas variáveis do framework (§4.6).
💡 Dica
No DevTools, aba Elements → painel Styles, as regras aparecem em ordem de vitória, de cima para baixo, e as declarações perdedoras aparecem riscadas. Quando um estilo seu não funcionar, a resposta está sempre nesse painel — não no seu editor.
bootstrap.min.css — o framework inteiro: reset, grid, componentes, utilitários.
bootstrap.bundle.min.js — apenas o comportamento: menu que abre e fecha, modal, dropdown, carrossel, tooltip. O "bundle" inclui o Popper, biblioteca de posicionamento usada pelos dropdowns.
Desde a versão 5 não há dependência de jQuery — o JavaScript é nativo. Se o seu site não usa nenhum componente interativo, você pode carregar só o CSS. O Café Cerrado precisa do JS por um motivo só: a navbar que colapsa em hambúrguer.
container limita a largura e centraliza (existe também container-fluid, que ocupa 100 %).
row é a linha. Ela é um display: flex; flex-wrap: wrap.
col-* são as colunas. Doze é o total; col-md-6 significa "metade da linha a partir de 768 px".
Leia o exemplo acima como três frases: no celular, cada bloco ocupa a linha inteira (12/12); no tablet, os dois primeiros dividem a linha e o terceiro ocupa uma linha só; no desktop, os três ficam lado a lado (4 + 4 + 4 = 12).
🔎 Por baixo do capô
Nada disso é mágico. Abra o bootstrap.min.css, formate o arquivo no VS Code e procure as três classes principais: é CSS comum, do tipo que você escreve desde a Aula 02.
Três coisas ficam claras: o "12 colunas" é só uma tabela de porcentagens; a calha (gutter) é padding nas colunas compensado por margin negativa na linha — por isso uma col-* fora de uma row fica com um espaço estranho na borda; e a largura padrão de qualquer filho direto de .row é 100 %, o que explica por que uma coluna sem col-md-* ocupa a linha toda no celular sem você escrever nada.
O Bootstrap é mobile-first: col-md-6 quer dizer "6 colunas a partir de 768 px", e não "apenas em 768 px". Abaixo disso vale o que você declarou para telas menores, ou o padrão (largura total). É por isso que se escreve col-12 col-md-6 e nunca o contrário.
row-cols-lg-3 diz "três colunas por linha a partir de 992 px", e cada filho é só col. Menos repetição, e para acrescentar um quarto card você não precisa recalcular nada. O g-4 é o espaçamento entre as células (a calha).
O sistema de espaçamento é o mais importante e segue um padrão: {propriedade}{lado}-{tamanho}.
Peça
Valores
Exemplo
Propriedade
m (margin), p (padding)
m, p
Lado
tbsexy, ou nada
mt, px, me
Tamanho
0=0, 1=.25rem, 2=.5rem, 3=1rem, 4=1.5rem, 5=3rem
mt-3, py-5
O s é start e o e é end — não left e right. Isso existe para que o mesmo CSS funcione em idiomas escritos da direita para a esquerda, como o árabe. ms-auto significa "margem automática do lado inicial", o truque clássico para empurrar um bloco para a direita dentro de um flex.
Outros que aparecem no projeto de hoje:
HTML
<divclass="d-flex align-items-center justify-content-between gap-3">Flexbox pronto</div><pclass="text-center text-body-secondary">Texto centralizado e apagado</p><imgsrc="img/fachada.jpg"alt="Fachada da cafeteria"class="img-fluid rounded-3"><spanclass="badge text-bg-success">Sem glúten</span><spanclass="visually-hidden">Texto só para leitor de tela</span>
img-fluid é max-width: 100%; height: auto — a linha que impede toda imagem de estourar a tela do celular.
Navbar — o único componente com JavaScript no projeto:
HTML
<navclass="navbar navbar-expand-lg"aria-label="Navegação principal"><divclass="container"><aclass="navbar-brand"href="index.html">Café Cerrado</a><buttonclass="navbar-toggler"type="button"data-bs-toggle="collapse"data-bs-target="#menu-principal"aria-controls="menu-principal"aria-expanded="false"aria-label="Abrir e fechar o menu de navegação"><spanclass="navbar-toggler-icon"aria-hidden="true"></span></button><divclass="collapse navbar-collapse"id="menu-principal"><ulclass="navbar-nav ms-auto"><liclass="nav-item"><aclass="nav-link"href="index.html"aria-current="page">Início</a></li><liclass="nav-item"><aclass="nav-link"href="cardapio.html">Cardápio</a></li></ul></div></div></nav>
navbar-expand-lg diz: menu horizontal a partir de 992 px, hambúrguer abaixo disso. O par data-bs-toggle/data-bs-target liga o botão ao bloco #menu-principal — é o JavaScript do Bootstrap lendo esses atributos. Os atributos ARIA (aria-controls, aria-expanded, aria-label) você já escreve hoje; por que cada um é obrigatório, você disseca na Aula 06.
Card — a caixa que vai substituir os .produto da Aula 03:
HTML
<articleclass="card h-100"><imgsrc="img/espresso.jpg"class="card-img-top"alt="Xícara de espresso sobre a mesa de madeira"><divclass="card-body"><h3class="h5 card-title">Espresso do Cerrado</h3><pclass="card-text">Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.</p></div><divclass="card-footer">R$ 6,00</div></article>
Repare no h5 junto do h3: a tag define a hierarquia do documento (e é h3 porque está dentro de uma seção com h2); a classe define só o tamanho visual. Nunca escolha a tag pelo tamanho da fonte. E h-100 faz o card ocupar toda a altura da célula do grid, para que uma fileira de cards fique alinhada mesmo com textos de tamanhos diferentes.
Formulário — três classes resolvem quase tudo:
HTML
<divclass="mb-3"><labelfor="nome"class="form-label">Nome completo *</label><inputtype="text"id="nome"name="nome"class="form-control"requiredminlength="3"><divclass="form-text">Como você quer ser chamado no atendimento.</div></div><divclass="form-check"><inputtype="checkbox"id="novidades"name="novidades"value="sim"class="form-check-input"><labelfor="novidades"class="form-check-label">Quero receber avisos de novos lotes</label></div>
form-control para input, textarea e afins; form-select para select; form-check + form-check-input + form-check-label para caixas e rádios.
⚠️ Atenção
O Bootstrap tem um sistema próprio de validação visual (needs-validation, was-validated, is-invalid) que depende de JavaScript autoral para funcionar. Nesta unidade o único JavaScript permitido é o do framework, então continuamos com a validação nativa da Aula 03 e marcamos o campo errado com :user-invalid, que é CSS puro. Na Unidade 2, quando o js/app.js existir, você troca por is-invalid e mensagens próprias.
Repare que a cor do link é declarada como três números, não como #6f4e37. O Bootstrap guarda as cores nesse formato para poder montar rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)) e oferecer utilitários de opacidade como link-opacity-50. Se você escrever --bs-link-color-rgb: #6f4e37, o rgba() recebe lixo e o link fica preto.
Nível de componente, na classe — cada componente lê o próprio conjunto de variáveis:
Isso cria uma variante de botão nova, do mesmo jeito que o btn-primary é criado internamente — sem sobrescrever nada, sem !important e sem risco de quebrar outro botão da página. É o caminho oficial e é o que o Café Cerrado vai usar.
Um erro comum: achar que --bs-primary: #6f4e37 no :root recolore os botões. Não recolore. --bs-primary alimenta utilitários como .text-primary e .bg-primary; os botões leem --bs-btn-*. Cada componente tem o seu prefixo (--bs-card-*, --bs-navbar-*, --bs-nav-*), e a lista completa está na documentação de cada um.
A versão 5.3 também trouxe o atributo data-bs-theme, que troca o conjunto inteiro de cores de qualquer subárvore:
HTML
<bodydata-bs-theme="dark"><p>A página inteira em modo escuro, sem uma linha de CSS.</p></body>
Funciona em qualquer elemento, não só no <body> — dá para ter um card claro dentro de uma página escura.
5. Tailwind CSS 4 — utility-first em vinte minutos¶
O Tailwind de verdade é uma ferramenta de build: ela varre o seu HTML, descobre quais classes você usou e gera um CSS só com elas. Para estudar, existe uma versão que faz isso no próprio navegador:
experimentos/tailwind.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Experimento com Tailwind 4</title><scriptsrc="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script></head><bodyclass="bg-stone-100 p-8"><divclass="max-w-sm overflow-hidden rounded-xl bg-white shadow-lg"><imgclass="h-48 w-full object-cover"src="../img/espresso.jpg"alt="Xícara de espresso sobre a mesa de madeira"><divclass="p-6"><h2class="mb-2 text-xl font-bold text-stone-800">Espresso do Cerrado</h2><pclass="mb-4 text-stone-600">Grãos de Alto Paraíso, torra média, final achocolatado.</p><ahref="#"class="inline-block rounded-lg bg-amber-800 px-4 py-2 font-semibold text-white hover:bg-amber-900">
Ver detalhes
</a></div></div></body></html>
Compare com o card do Bootstrap da §4.5: mesmo resultado visual, filosofias opostas. O card Bootstrap tem oito classes, todas com nome de coisa (card, card-body, card-title), e carrega um CSS de 230 KB; o card Tailwind tem mais de vinte classes, todas com nome de propriedade, e depois do build gera um CSS de poucos KB.
⚠️ Atenção
Este script é o Play CDN: ele carrega o compilador do Tailwind no navegador e monta o CSS na hora. Serve para aprender e prototipar, e é o que usamos aqui. Em produção ele é lento e pesado — o Tailwind de verdade se instala com npm install tailwindcss e roda no build. O endereço cdn.tailwindcss.com, que você vai achar em tutoriais antigos, é do Tailwind 3: para a versão 4 o pacote é @tailwindcss/browser@4, servido pela jsDelivr.
md:grid-cols-2 é "duas colunas a partir de 768 px" — mesma lógica mobile-first do Bootstrap, sintaxe diferente. O mesmo mecanismo vale para estados e para preferências do usuário:
HTML
<buttonclass="bg-amber-800 hover:bg-amber-900 focus-visible:outline-2 dark:bg-amber-600">
Composição de estados
</button>
hover:, focus:, focus-visible:, disabled:, dark:, motion-reduce: — e eles se combinam (md:hover:bg-amber-900). É essa composição que torna o Tailwind poderoso e o HTML comprido ao mesmo tempo.
A versão 4 (2025) reescreveu o motor e mudou a configuração de lugar: o antigo arquivo tailwind.config.js deu lugar a configuração dentro do próprio CSS.
Declarar --color-cafe no bloco @theme cria automaticamente as classes bg-cafe, text-cafe, border-cafe e todas as variantes. No Play CDN você faz o mesmo dentro de uma tag de estilo especial:
E aí class="bg-cafe" funciona. É a mesma ideia das variáveis --bs-* do Bootstrap, vista pelo outro lado: em vez de sobrescrever os tokens de um design pronto, você declara os tokens do seu design e o framework gera as classes.
O Material Design é a linguagem visual que o Google publicou em 2014 e usa no Android, no Gmail e no Drive. Ele não é um arquivo CSS: é uma especificação — superfícies em camadas com elevação, cores derivadas de uma cor-semente, movimento com significado, um botão flutuante de ação. A versão atual é a Material 3, documentada em m3.material.io.
Para usar Material Design em HTML puro existem dois caminhos, e é importante não confundir:
Caminho
O que é
Situação
Materialize
Framework de componentes de terceiros, estilo Bootstrap
Última versão estável de 2018, implementa o Material 1
Material Web
Implementação oficial do Google em Web Components
Material 3; em modo de manutenção desde 2024
Muito material de aula ainda ensina o Materialize porque ele se parece com o Bootstrap. Nesta trilha usamos o Material Web, porque ele mostra uma terceira ideia que os outros dois frameworks não mostram: componentes como elementos HTML próprios.
Um Web Component é um elemento customizado, registrado por JavaScript, que o navegador passa a tratar como uma tag nativa — com estilo encapsulado dentro de um shadow DOM, isolado do CSS da página. Você não escreve <button class="md-button">: você escreve <md-filled-button>.
experimentos/material.html
HTML
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>Experimento com Material Web</title><linkrel="stylesheet"href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap"><scripttype="importmap">{"imports":{"@material/web/":"https://esm.run/@material/web@2.5.0/"}}</script><scripttype="module">import'@material/web/all.js';import{stylesastypescaleStyles}from'@material/web/typography/md-typescale-styles.js';document.adoptedStyleSheets.push(typescaleStyles.styleSheet);</script><style>:root{--md-sys-color-primary:#6f4e37;--md-sys-color-on-primary:#ffffff;}body{font-family:Roboto,system-ui,sans-serif;margin:0;padding:2rem;display:flex;flex-direction:column;gap:1.5rem;max-width:24rem;}</style></head><body><h1class="md-typescale-headline-medium">Reserve sua mesa</h1><md-outlined-text-fieldlabel="Nome completo"required></md-outlined-text-field><md-outlined-text-fieldlabel="E-mail"type="email"required></md-outlined-text-field><label><md-checkboxtouch-target="wrapper"></md-checkbox>
Quero receber avisos de novos lotes
</label><div><md-filled-button>Enviar</md-filled-button><md-text-button>Cancelar</md-text-button></div></body></html>
Três mecanismos novos aparecem aqui, e vale entender cada um:
<script type="importmap"> é um recurso nativo do navegador que traduz nomes de pacote em URLs. Sem ele, o import '@material/web/all.js' não teria como saber onde procurar o arquivo. O esm.run é o serviço da jsDelivr que entrega pacotes npm já no formato de módulo ES.
type="module" é obrigatório: import só existe em módulos ES. É a mesma sintaxe que você vai usar na Unidade 2 e no Nível 3.
--md-sys-color-primary é um design token do Material 3. Todos os componentes leem a mesma paleta de tokens, então trocar duas variáveis recolore a interface inteira — exatamente a ideia das variáveis --bs-*, com outro nome.
Material Web é elegante e é o futuro anunciado dos componentes web, mas tem três características que pesam contra ele na Unidade 1:
Depende de JavaScript para tudo. Se o script não carregar, <md-filled-button> fica sendo uma tag desconhecida e some da tela. Um site Bootstrap sem JavaScript perde o menu hambúrguer; um site Material Web sem JavaScript perde tudo.
Não tem sistema de grid. Ele entrega componentes, não layout. Você teria que escrever o grid à mão com CSS Grid — que é ótimo, mas não é o que o Marco 1 pede ("sistema de grid do framework").
Identidade visual muito forte. Um site com Material Web parece um app do Google. Para uma cafeteria de Sinop, isso é uma escolha, não um padrão.
🧠 Você sabia?
Em 2024 o Google anunciou que a equipe do Material Web foi realocada e que o projeto entrou em modo de manutenção: correções de segurança continuam, mas novos componentes não vêm. O card, por exemplo, nunca saiu da pasta labs. Isso é um lembrete útil para toda escolha de dependência: você não está escolhendo só uma sintaxe, está escolhendo quem vai manter aquele código nos próximos cinco anos. A pergunta "quando foi o último commit?" e "quantas pessoas mantêm isso?" vale tanto quanto "que bonito ficou o botão".
A última linha merece atenção: o Bootstrap publica o integrity de cada arquivo na própria documentação. O Play CDN do Tailwind e o esm.run do Material Web não publicam hash — o primeiro porque o conteúdo é gerado, o segundo porque um módulo ES importa outros arquivos que a hash da primeira requisição não cobre. Isso reforça o que a §5.1 e a §6.3 já diziam: os dois entram no projeto como experimento, não como dependência de produção.
"O Bootstrap é pesado" é uma frase que se repete sem número. Números você tira da aba Network do DevTools em trinta segundos: abra a página, recarregue com Ctrl+Shift+R, ordene por Size e leia a coluna Transferred (o que veio pela rede, comprimido) ao lado da coluna de tamanho real. O CSS do Bootstrap 5.3 completo transfere cerca de 32 KB comprimidos; o bundle de JavaScript, cerca de 25 KB. Uma única foto de café mal exportada pesa mais que os dois juntos — e é aí que quase sempre está o problema real de desempenho do site de um aluno.
⚠️ AtençãoUm framework por projeto. Misturar Bootstrap e Materialize, ou Bootstrap e Tailwind, gera três problemas de uma vez: classes com o mesmo nome e comportamentos diferentes (.card, .btn, .container existem em quase todos), CSS duplicado baixado à toa, e um visual sem identidade. Se você quiser trocar de framework no meio do projeto, troque — mas remova o anterior por inteiro.
O Café Cerrado adota o Bootstrap 5.3. A justificativa, que vai literalmente para o README.md, tem quatro pontos:
O projeto é um site estático, sem etapa de build — e o Bootstrap é o único dos três que entrega grid e componentes por CDN, sem npm.
O critério "framework CSS" do Marco 1 pede um sistema de grid responsivo; o de 12 colunas é o mais direto e o mais documentado em português.
Uma cafeteria de bairro precisa de menu, cards e formulário bem-resolvidos, não de um design autoral disputando prêmio — a curva baixa vale mais que a liberdade total.
As variáveis --bs-* da versão 5.3 permitem aplicar a paleta da marca sem lutar contra o framework, o que responde à principal crítica ao Bootstrap ("todo site fica igual").
Seu projeto autoral pode escolher outro. O que o marco exige não é a escolha, é a justificativa e a aplicação consistente.
💻 Mão na massa — O Café Cerrado adota o Bootstrap 5.3¶
Nove passos. Ao final, as três páginas da Aula 03 continuam com exatamente a mesma estrutura semântica, mas responsivas, com menu que colapsa, cardápio em grid de cards e formulário consistente — e com um css/estilo.css três vezes menor do que ontem.
O script vai no fim do <body> por dois motivos: um <script> no <head> sem defer bloqueia a renderização enquanto baixa e executa, e os componentes que leem o DOM ao inicializar já encontram a página inteira montada. Salve e recarregue: a página muda de fonte e de espaçamento na hora. Ela ainda está feia — é o reset do Bootstrap desmanchando o seu CSS antigo. Os próximos passos arrumam isso.
Substitua o <header> inteiro das três páginas por este bloco. Ele é idêntico nas três; só muda em qual link fica o aria-current="page".
cafe-cerrado/index.html (o <header> completo)
HTML
<header><navclass="navbar navbar-expand-lg"aria-label="Navegação principal"><divclass="container"><aclass="navbar-brand"href="index.html">
Café Cerrado
<spanclass="d-block small fw-normal opacity-75">Torrefação artesanal · Sinop, MT</span></a><buttonclass="navbar-toggler"type="button"data-bs-toggle="collapse"data-bs-target="#menu-principal"aria-controls="menu-principal"aria-expanded="false"aria-label="Abrir e fechar o menu de navegação"><spanclass="navbar-toggler-icon"aria-hidden="true"></span></button><divclass="collapse navbar-collapse"id="menu-principal"><ulclass="navbar-nav ms-auto"><liclass="nav-item"><aclass="nav-link"href="index.html"aria-current="page">Início</a></li><liclass="nav-item"><aclass="nav-link"href="cardapio.html">Cardápio</a></li><liclass="nav-item"><aclass="nav-link"href="contato.html">Contato</a></li></ul></div></div></nav></header>
Em cardapio.html, o aria-current="page" vai para o link do Cardápio; em contato.html, para o de Contato. O <nav> continua dentro do <header>: a semântica da Aula 03 não muda.
Passo 3 — index.html: hero, história, destaques e horários¶
Substitua o <main> da página inicial:
cafe-cerrado/index.html (conteúdo do <main>)
HTML
<main><sectionclass="hero text-center py-5"><divclass="container"><h1class="hero__titulo display-4">Café do Cerrado, torrado em Sinop</h1><pclass="hero__texto lead">
Grãos de produtores de Mato Grosso, torra artesanal e um lugar para ficar.
</p><divclass="hero__acoes"><aclass="btn btn-cafe btn-lg"href="cardapio.html">Ver o cardápio</a></div></div></section><sectionid="sobre"class="container py-5"><divclass="row align-items-center g-4"><divclass="col-12 col-md-6"><h2>Nossa história</h2><p>
O Café Cerrado nasceu em uma garagem no Setor Comercial de Sinop, com um
torrador de dois quilos e a teimosia de provar que o café produzido no
Mato Grosso pode brigar com os grãos mais famosos do país.
</p><p>
Hoje trabalhamos com quatro sítios parceiros e torramos, em média,
sessenta quilos por semana — tudo consumido aqui mesmo, no balcão ou nas
mesas da calçada.
</p><ulclass="list-unstyled"><li>Wi-Fi liberado e tomada em todas as mesas</li><li>Moagem na hora, com escolha da torra</li><li>Opções sem lactose e sem glúten identificadas no cardápio</li></ul></div><divclass="col-12 col-md-6"><imgsrc="img/fachada.jpg"class="img-fluid rounded-3 shadow-sm"alt="Fachada do Café Cerrado com toldo verde e mesas na calçada"width="1200"height="800"></div></div></section><sectionid="destaques"class="container py-5"><h2class="mb-4">Destaques da semana</h2><divclass="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"><divclass="col"><articleclass="card card-produto h-100"><divclass="card-body"><h3class="h5 card-title">Cold Brew da Chapada</h3><pclass="card-text">Extração a frio por dezoito horas, servido com gelo e rodela de laranja.</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 15,00</span><aclass="btn btn-sm btn-cafe-vazado"href="cardapio.html#geladas">Ver</a></div></article></div><divclass="col"><articleclass="card card-produto h-100"><divclass="card-body"><h3class="h5 card-title">Pão de Queijo Mineiro</h3><pclass="card-text">Massa de polvilho azedo com queijo canastra, assado de hora em hora.</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 7,00</span><aclass="btn btn-sm btn-cafe-vazado"href="cardapio.html#salgados">Ver</a></div></article></div><divclass="col"><articleclass="card card-produto h-100"><divclass="card-body"><h3class="h5 card-title">Bolo de Milho Verde</h3><pclass="card-text">Fatia de bolo cremoso feito com milho da feira do produtor.</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 9,50</span><aclass="btn btn-sm btn-cafe-vazado"href="cardapio.html#doces">Ver</a></div></article></div></div></section><sectionid="horarios"class="container py-5"><h2class="mb-4">Horário de atendimento</h2><divclass="table-responsive"><tableclass="table table-striped align-middle caption-top"><caption>Horário de atendimento do Café Cerrado</caption><thead><tr><thscope="col">Dia</th><thscope="col">Abertura</th><thscope="col">Fechamento</th></tr></thead><tbody><tr><thscope="row">Segunda a sexta</th><td>07h00</td><td>20h00</td></tr><tr><thscope="row">Sábado</th><td>08h00</td><td>18h00</td></tr><tr><thscope="row">Domingo</th><tdcolspan="2">Fechado</td></tr></tbody></table></div><p>
Precisa de um horário fora do expediente para um evento?
<ahref="contato.html">Fale com a gente pelo formulário de contato</a>.
</p></section></main>
Três decisões para reparar:
O container saiu do <main> e entrou em cada seção. Isso permite que uma seção futura ocupe a largura toda da tela (uma faixa colorida, por exemplo) sem quebrar o alinhamento das outras.
row-cols-1 row-cols-md-2 row-cols-lg-3 faz o trabalho de três media queries. Acrescente um quarto destaque e nada precisa ser recalculado.
table-responsive substitui o .tabela-rolavel que você escreveu à mão na Aula 03: é a mesma ideia (overflow-x: auto), já pronta. Pode apagar a sua.
Cada categoria vira uma <section> com um grid próprio, e as quatro âncoras da Aula 03 (#cafes, #geladas, #salgados, #doces) continuam sendo os id das seções — é para elas que apontam os botões "Ver" dos destaques e a <nav> de atalhos. São os mesmos dez produtos da Aula 03, com os mesmos preços.
cafe-cerrado/cardapio.html (o <main> inteiro)
HTML
<main><sectionclass="container pt-5"><h1>Cardápio</h1><pclass="lead">
Preços válidos para consumo no local. Todos os cafés podem ser preparados
com leite vegetal por R$ 2,00 adicionais.
</p><navaria-label="Seções do cardápio"><ulclass="nav gap-2"><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"href="#cafes">Cafés</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"href="#geladas">Bebidas geladas</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"href="#salgados">Salgados</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"href="#doces">Doces</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"href="#torras">Guia de torras</a></li></ul></nav></section><sectionid="cafes"class="container py-5"><h2class="mb-4">Cafés</h2><divclass="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/espresso.jpg"class="card-img-top"alt="Xícara de espresso sobre a mesa de madeira"><divclass="card-body"><h3class="h5 card-title">Espresso do Cerrado</h3><pclass="card-text">
Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 6,00</span><spanclass="badge text-bg-secondary">Torra média</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/coado.jpg"class="card-img-top"alt="Café sendo coado em filtro de papel sobre uma jarra de vidro"><divclass="card-body"><h3class="h5 card-title">Coado da Casa</h3><pclass="card-text">
Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 8,50</span><spanclass="badge text-bg-secondary">Torra clara</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/cappuccino.jpg"class="card-img-top"alt="Cappuccino com canela polvilhada sobre a espuma"><divclass="card-body"><h3class="h5 card-title">Cappuccino Sinop</h3><pclass="card-text">
Espresso duplo, leite vaporizado e canela do Cerrado por cima.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 12,00</span><spanclass="badge text-bg-secondary">Com leite</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/latte.jpg"class="card-img-top"alt="Copo de latte com camadas de leite e café visíveis"><divclass="card-body"><h3class="h5 card-title">Latte de Baunilha</h3><pclass="card-text">
Espresso, leite vaporizado e calda de baunilha feita na casa.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 14,00</span><spanclass="badge text-bg-secondary">Com leite</span></div></article></div></div></section><sectionid="geladas"class="container py-5"><h2class="mb-4">Bebidas geladas</h2><divclass="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/cold-brew.jpg"class="card-img-top"alt="Copo alto de cold brew com gelo e rodela de laranja"><divclass="card-body"><h3class="h5 card-title">Cold Brew da Chapada</h3><pclass="card-text">
Extração a frio por dezoito horas, servida com gelo e rodela de laranja.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 15,00</span><spanclass="badge text-bg-secondary">Gelado</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/frappe.jpg"class="card-img-top"alt="Frappê de café coberto com chantili em copo de vidro"><divclass="card-body"><h3class="h5 card-title">Frappê de Café</h3><pclass="card-text">
Espresso batido com gelo, leite e chantili. Também sai sem lactose.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 16,00</span><spanclass="badge text-bg-secondary">Gelado</span></div></article></div></div></section><sectionid="salgados"class="container py-5"><h2class="mb-4">Salgados</h2><divclass="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/pao-de-queijo.jpg"class="card-img-top"alt="Porção de pães de queijo dourados em um prato branco"><divclass="card-body"><h3class="h5 card-title">Pão de Queijo Mineiro</h3><pclass="card-text">
Porção com quatro unidades de polvilho azedo com queijo canastra.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 7,00</span><spanclass="badge text-bg-secondary">Sem glúten</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/torta-de-frango.jpg"class="card-img-top"alt="Fatia de torta de frango em um prato de cerâmica"><divclass="card-body"><h3class="h5 card-title">Torta de Frango</h3><pclass="card-text">
Fatia generosa com massa amanteigada e recheio de frango desfiado.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 13,00</span><spanclass="badge text-bg-secondary">Assado do dia</span></div></article></div></div></section><sectionid="doces"class="container py-5"><h2class="mb-4">Doces</h2><divclass="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4"><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/bolo-de-milho.jpg"class="card-img-top"alt="Fatia de bolo de milho verde sobre papel manteiga"><divclass="card-body"><h3class="h5 card-title">Bolo de Milho Verde</h3><pclass="card-text">
Fatia de bolo cremoso feito com milho da feira do produtor.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 9,50</span><spanclass="badge text-bg-secondary">Feito na casa</span></div></article></div><divclass="col"><articleclass="card card-produto h-100"><imgsrc="img/brownie.jpg"class="card-img-top"alt="Brownie de chocolate com castanhas por cima"><divclass="card-body"><h3class="h5 card-title">Brownie de Castanha</h3><pclass="card-text">
Chocolate meio amargo com castanha-do-pará. Sem glúten.
</p></div><divclass="card-footer d-flex justify-content-between align-items-center"><spanclass="fw-bold">R$ 11,00</span><spanclass="badge text-bg-secondary">Sem glúten</span></div></article></div></div></section></main>
Guarde esta lista: são os dez produtos do Café Cerrado, com estes nomes, estes preços, estas quatro categorias e estes arquivos de imagem. Eles voltam na Aula 07 como um array de objetos, na Aula 09 como js/dados.js, na Aula 10 como data/produtos.json e na Unidade 3 como as linhas da sua API. Mudar um preço aqui obriga a mudar em todos os outros lugares — por isso não mude.
Não tem foto de cada produto? Duas saídas honestas: fotografe com o celular (uma xícara, uma mesa, luz de janela — leva dez minutos) ou remova a tag <img> do card, como nos destaques da página inicial. Não use uma imagem qualquer da internet: além do problema de direito autoral, você vai carregar 3 MB por card.
Mantenha, logo abaixo dos grids, a <figure> do guia de torras e a tabela de grãos da Aula 03 dentro de uma <section id="a04-torras" class="container py-5"> — só acrescente class="table table-striped caption-top" na tabela e envolva-a em <div class="table-responsive">, como você fez na página inicial.
Passo 5 — contato.html: o formulário com classes do Bootstrap¶
A estrutura do formulário não muda: os mesmos treze campos da Aula 03, com os mesmos name, os mesmos <optgroup> e a mesma validação nativa. O que muda são as classes e a troca dos <p class="campo"> por <div class="mb-3">. Nenhum campo pode desaparecer — o requisito 5 do Marco 1 cobra o formulário completo.
cafe-cerrado/contato.html (trecho do <main>)
HTML
<mainclass="container py-5"><h1>Fale com a gente</h1><pclass="lead">
Reservas para grupos, encomendas, eventos e parcerias. Respondemos em até um
dia útil. Campos marcados com asterisco são obrigatórios.
</p><formclass="row g-4"action="contato.html"method="post"><fieldsetclass="col-12 col-lg-6"><legendclass="h5">Seus dados</legend><divclass="mb-3"><labelfor="nome"class="form-label">Nome completo *</label><inputtype="text"id="nome"name="nome"class="form-control"requiredminlength="3"maxlength="80"autocomplete="name"placeholder="Ana Beatriz Souza"></div><divclass="mb-3"><labelfor="email"class="form-label">E-mail *</label><inputtype="email"id="email"name="email"class="form-control"requiredautocomplete="email"placeholder="voce@exemplo.com"></div><divclass="mb-3"><labelfor="telefone"class="form-label">Telefone com DDD</label><inputtype="tel"id="telefone"name="telefone"class="form-control"pattern="\(?[0-9]{2}\)?\s?9?[0-9]{4}-?[0-9]{4}"title="Digite o telefone com DDD, no formato (66) 99999-0000"inputmode="tel"autocomplete="tel"placeholder="(66) 99999-0000"><divclass="form-text">Só usamos para confirmar reservas.</div></div><divclass="mb-3"><labelfor="cep"class="form-label">CEP</label><inputtype="text"id="cep"name="cep"class="form-control"pattern="[0-9]{5}-?[0-9]{3}"title="Digite um CEP no formato 78550-000"inputmode="numeric"autocomplete="postal-code"placeholder="78550-000"></div></fieldset><fieldsetclass="col-12 col-lg-6"><legendclass="h5">Sobre o seu pedido</legend><divclass="mb-3"><labelfor="assunto"class="form-label">Assunto *</label><selectid="assunto"name="assunto"class="form-select"required><optionvalue="">Selecione um assunto</option><optgrouplabel="Atendimento"><optionvalue="reserva">Reserva de mesa</option><optionvalue="encomenda">Encomenda de bolos e tortas</option><optionvalue="graos">Compra de grãos em pacote</option></optgroup><optgrouplabel="Institucional"><optionvalue="evento">Evento ou parceria</option><optionvalue="trabalhe">Trabalhe conosco</option></optgroup></select></div><divclass="row g-3 mb-3"><divclass="col-12 col-sm-4"><labelfor="pessoas"class="form-label">Pessoas</label><inputtype="number"id="pessoas"name="pessoas"class="form-control"min="1"max="40"step="1"value="2"></div><divclass="col-12 col-sm-4"><labelfor="data"class="form-label">Data desejada</label><inputtype="date"id="data"name="data"class="form-control"></div><divclass="col-12 col-sm-4"><labelfor="horario"class="form-label">Horário</label><inputtype="time"id="horario"name="horario"class="form-control"min="07:00"max="20:00"step="900"></div></div><divclass="mb-3"><labelfor="mensagem"class="form-label">Mensagem *</label><textareaid="mensagem"name="mensagem"class="form-control"rows="5"maxlength="500"requiredminlength="10"placeholder="Conte o que você precisa"></textarea></div></fieldset><fieldsetclass="col-12"><legendclass="h5">Como prefere ser respondido?</legend><divclass="form-check form-check-inline"><inputtype="radio"id="canal-email"name="canal"value="email"class="form-check-input"checked><labelfor="canal-email"class="form-check-label">E-mail</label></div><divclass="form-check form-check-inline"><inputtype="radio"id="canal-telefone"name="canal"value="telefone"class="form-check-input"><labelfor="canal-telefone"class="form-check-label">Telefone</label></div><divclass="form-check form-check-inline"><inputtype="radio"id="canal-whatsapp"name="canal"value="whatsapp"class="form-check-input"><labelfor="canal-whatsapp"class="form-check-label">WhatsApp</label></div></fieldset><divclass="col-12"><divclass="form-check"><inputtype="checkbox"id="novidades"name="novidades"value="sim"class="form-check-input"><labelfor="novidades"class="form-check-label">
Quero receber avisos de novos lotes de café por e-mail
</label></div><divclass="form-check"><inputtype="checkbox"id="consentimento"name="consentimento"value="sim"class="form-check-input"required><labelfor="consentimento"class="form-check-label">
Autorizo o Café Cerrado a usar meus dados para responder a este contato *
</label></div></div><inputtype="hidden"name="origem"value="site-contato"><divclass="col-12 d-flex gap-2"><buttontype="submit"class="btn btn-cafe btn-enviar">Enviar mensagem</button><buttontype="reset"class="btn btn-cafe-vazado">Limpar formulário</button></div></form></main>
Repare que os <fieldset> viraram colunas do grid: col-12 col-lg-6 põe os dois primeiros blocos lado a lado em telas grandes e um sobre o outro no celular. Um fieldset é um elemento como outro qualquer — pode receber classes de coluna sem perder o significado de agrupamento para o leitor de tela.
Os rádios ganharam id e label for. Na Aula 03 eles estavam envolvidos pelo próprio <label>, o que também é válido; o Bootstrap, porém, estiliza form-check-input e form-check-label como irmãos, e o par for/id é a forma que funciona com esse layout.
cafe-cerrado/index.html (o <footer> completo, igual nas três páginas)
HTML
<footerclass="rodape mt-5 py-5"><divclass="container"><divclass="row g-4"><sectionclass="col-12 col-md-4"><h2class="h5">Café Cerrado</h2><pclass="mb-0">
Torrefação artesanal de grãos do Cerrado mato-grossense. Projeto fictício
usado como estudo de caso do Nível 2 do WebLab (Desenvolvimento Web).
</p></section><sectionclass="col-12 col-md-4"><h2class="h5">Onde estamos</h2><addressclass="mb-0">
Avenida dos Jacarandás, 1200 — Setor Comercial<br>
Sinop — MT<br><ahref="tel:+556699999000">(66) 9 9999-9000</a><br><ahref="mailto:contato@cafecerrado.exemplo.br">contato@cafecerrado.exemplo.br</a></address></section><navclass="col-12 col-md-4"aria-label="Rodapé"><h2class="h5">Navegação</h2><ulclass="list-unstyled mb-0"><li><ahref="index.html">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li><li><ahref="contato.html">Contato</a></li><li><ahref="index.html#horarios">Horários</a></li></ul></nav></div><pclass="text-center small mt-4 mb-0">Café Cerrado · Sinop/MT · Projeto acadêmico</p></div></footer>
Agora a melhor parte: apagar código. Substitua todo o conteúdo de css/estilo.css pelo arquivo abaixo. Ele tem menos de um terço do tamanho do anterior, porque tudo o que era layout, tabela, formulário e espaçamento agora vem do framework. O que sobra é só o que o Bootstrap não sabe: a identidade da sua marca.
cafe-cerrado/css/estilo.css (arquivo completo)
CSS
/* Café Cerrado — tempero sobre o Bootstrap 5.3. Só o que o framework não entrega: paleta da marca, variantes de botão, card de produto, navbar colorida e o realce de campo inválido. *//* ---------- 1. Variáveis do projeto (Aula 02) ---------- */:root{--cor-marca:#6f4e37;--cor-marca-escura:#4a3325;--cor-destaque:#c2703d;--cor-fundo:#fdfaf6;--cor-superficie:#ffffff;--cor-texto:#2b2118;--cor-texto-suave:#5c4b3c;--cor-menu-inativo:rgba(255,255,255,0.82);--cor-menu-borda:rgba(255,255,255,0.35);--borda-suave:rgba(111,78,55,0.15);--realce-invalido:rgba(220,53,69,0.2);--fonte-base:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;--raio:0.75rem;}/* ---------- 2. Tokens globais do Bootstrap ---------- *//* Cores em três números são exigência do Bootstrap: ele monta rgba() com elas para oferecer os utilitários de opacidade. 111,78,55 = #6f4e37. */:root{--bs-body-bg:var(--cor-fundo);--bs-body-color:var(--cor-texto);--bs-body-font-family:var(--fonte-base);--bs-link-color-rgb:111,78,55;--bs-link-hover-color-rgb:74,51,37;--bs-border-radius:var(--raio);--bs-secondary-color:var(--cor-texto-suave);}/* ---------- 3. Navbar com a cor da marca ---------- *//* Só variáveis do componente: o HTML fica limpo, sem classe de cor. A única cor literal fora do :root está dentro da data URI do ícone — ali ela é texto de uma imagem SVG, não uma declaração CSS. */.navbar{background-color:var(--cor-marca);--bs-navbar-color:var(--cor-menu-inativo);--bs-navbar-hover-color:var(--cor-superficie);--bs-navbar-active-color:var(--cor-superficie);--bs-navbar-brand-color:var(--cor-superficie);--bs-navbar-brand-hover-color:var(--cor-superficie);--bs-navbar-toggler-border-color:var(--cor-menu-borda);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.85%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");}.navbar.nav-link[aria-current="page"]{font-weight:600;}/* ---------- 4. Variantes de botão da marca ---------- */.btn-cafe{--bs-btn-bg:var(--cor-marca);--bs-btn-border-color:var(--cor-marca);--bs-btn-color:var(--cor-superficie);--bs-btn-hover-bg:var(--cor-marca-escura);--bs-btn-hover-border-color:var(--cor-marca-escura);--bs-btn-hover-color:var(--cor-superficie);--bs-btn-active-bg:var(--cor-marca-escura);--bs-btn-active-border-color:var(--cor-marca-escura);--bs-btn-active-color:var(--cor-superficie);--bs-btn-disabled-bg:var(--cor-marca);--bs-btn-disabled-border-color:var(--cor-marca);--bs-btn-disabled-color:var(--cor-superficie);--bs-btn-focus-shadow-rgb:111,78,55;}.btn-cafe-vazado{--bs-btn-bg:transparent;--bs-btn-border-color:var(--cor-marca);--bs-btn-color:var(--cor-marca);--bs-btn-hover-bg:var(--cor-marca);--bs-btn-hover-border-color:var(--cor-marca);--bs-btn-hover-color:var(--cor-superficie);--bs-btn-active-bg:var(--cor-marca-escura);--bs-btn-active-border-color:var(--cor-marca-escura);--bs-btn-active-color:var(--cor-superficie);--bs-btn-focus-shadow-rgb:111,78,55;}/* ---------- 5. Hero e cards ---------- */.hero{background-color:var(--cor-superficie);border-bottom:1pxsolidvar(--borda-suave);}.hero__titulo{color:var(--cor-marca-escura);}.hero__texto{color:var(--cor-texto-suave);max-width:40rem;margin-inline:auto;}.card-produto{--bs-card-bg:var(--cor-superficie);--bs-card-border-color:var(--borda-suave);--bs-card-border-radius:var(--raio);--bs-card-cap-bg:transparent;}.card-produto.card-img-top{aspect-ratio:4/3;object-fit:cover;}/* ---------- 6. Formulário: o que o Bootstrap não faz sem JavaScript ---------- *//* :user-invalid só marca o campo DEPOIS da interação (Aula 03, §4.5). */.form-control:user-invalid,.form-select:user-invalid{border-color:var(--bs-form-invalid-border-color);box-shadow:0000.25remvar(--realce-invalido);}/* ---------- 7. Rodapé ---------- */.rodape{background-color:var(--cor-marca-escura);color:var(--cor-menu-inativo);}.rodapeh2,.rodapea{color:var(--cor-superficie);}
Guarde o arquivo antigo? Não precisa: o Git guarda. Se der saudade, git show HEAD~1:css/estilo.css mostra a versão anterior sem restaurar nada.
Este passo vale nota. Acrescente a seção abaixo ao README.md do repositório:
cafe-cerrado/README.md (acrescente ao final)
Markdown
## Framework CSS
Este projeto usa **Bootstrap 5.3.3**, carregado por CDN (jsDelivr) com versão
fixa, `integrity` e `crossorigin`.
Por que Bootstrap e não Tailwind ou Material Web:
-O site é estático e não tem etapa de build. O Bootstrap é o único dos três que
entrega grid **e** componentes prontos por CDN; o Tailwind por CDN é só para
estudo e o Material Web não tem sistema de grid.
-O projeto precisa de menu responsivo, cards e formulário bem resolvidos, não de
um design autoral. A curva de aprendizado baixa vale mais aqui do que a
liberdade visual total do Tailwind.
-A identidade da marca é aplicada pelas variáveis CSS `--bs-*` da versão 5.3
(`--bs-btn-*`, `--bs-navbar-*`), sem `!important` e sem lutar com a
especificidade do framework.
O que é nosso e o que é do framework: todo o layout, os componentes e os
utilitários vêm do Bootstrap; o arquivo `css/estilo.css` tem apenas a paleta da
marca, duas variantes de botão (`.btn-cafe`, `.btn-cafe-vazado`), o card de
produto e o realce de campo inválido com `:user-invalid`.
Abra index.html com o Live Server e reduza a janela até abaixo de 992 px. O menu vira um botão hambúrguer; clique nele: o menu abre e fecha. É o único JavaScript do projeto funcionando.
Ainda no modo estreito, confira que não existe rolagem horizontal. Se existir, algum bloco está fora de um container ou uma imagem está sem img-fluid.
Abra o modo dispositivo (F12 → ícone de celular) e teste em 360 px, 768 px e 1200 px. Os cards de destaque devem ficar em 1, 2 e 3 colunas, nessa ordem.
No cardapio.html, confira que todos os cards de uma mesma fileira têm a mesma altura, mesmo com descrições de tamanhos diferentes. Se não tiverem, faltou h-100.
Passe o mouse nos botões: btn-cafe escurece; btn-cafe-vazado preenche. Navegue por Tab: o anel de foco aparece na cor da marca (é o --bs-btn-focus-shadow-rgb).
Em contato.html, com a janela larga, os dois primeiros fieldset ficam lado a lado; estreite a janela e eles empilham.
Envie o formulário vazio: o navegador continua bloqueando com "Preencha este campo". Digite duas letras no nome, saia do campo: a borda fica vermelha (é o :user-invalid).
Na aba Network, recarregue com cache desativado e anote o valor transferido de bootstrap.min.css e de bootstrap.bundle.min.js. Some: o total deve ficar perto de 57 KB.
Quebre um caractere do integrity do CSS e recarregue: a página perde todo o estilo e o console acusa o bloqueio. Desfaça.
Valide as três páginas no W3C outra vez. Zero erros — as classes não mudam a validade do HTML, mas um <div> esquecido aberto muda.
Resultado esperado: as três páginas do Café Cerrado responsivas, com menu que colapsa, cardápio em grid de cards de altura igual, formulário em duas colunas no desktop e uma no celular, tudo na paleta marrom da marca — e um css/estilo.css que cabe em uma tela e meia.
A1. Traduza para português o que este HTML faz em cada uma das três faixas de tela: <div class="col-12 col-sm-6 col-xl-3">. Quantas colunas de 12 ele ocupa em 400 px, em 800 px e em 1300 px?
A2. Qual a diferença entre mt-3, my-3, me-3 e ms-3? Escreva o CSS equivalente de cada um, com o valor em rem.
A3. O trecho abaixo não coloca os dois blocos lado a lado em nenhuma largura. Aponte o erro e corrija:
A4. Um colega escreveu :root { --bs-primary: #6f4e37; } e reclama que os botões btn-primary continuam azuis. Explique por que, e diga qual variável ele deveria ter usado.
A5. Qual é a diferença entre <h3 class="h5"> e <h5>? Em que situação a primeira forma é a correta?
A6. Classifique cada trecho como "componentes prontos" ou "utility-first" e justifique em uma linha: (a) class="alert alert-danger"; (b) class="flex items-center gap-2"; (c) class="card h-100"; (d) class="mt-3 d-flex".
A7. Por que o <link> do css/estilo.css precisa vir depois do <link> do Bootstrap? Em que caso essa ordem, sozinha, não resolve?
B1. Monte, em um arquivo novo experimentos/grid.html com o Bootstrap por CDN, uma página com uma linha de seis blocos coloridos numerados. Eles devem ficar em 1 coluna abaixo de 576 px, 2 colunas a partir de 576 px, 3 a partir de 768 px e 6 a partir de 1200 px — usando row-cols-*, sem escrever nenhuma media query.
Resultado esperado: ao arrastar a borda da janela, o layout assume exatamente quatro arranjos, mudando nas três larguras previstas.
Dica
row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-6 na row, e class="col" em cada filho. Para enxergar os blocos, use os utilitários p-3, border e text-bg-secondary. Confirme os pontos de quebra com a régua do modo dispositivo do DevTools, não no olho.
B2. Crie uma variante de botão nova, .btn-cerrado-verde, usando apenas variáveis --bs-btn-* — nenhuma propriedade CSS comum, nenhum !important. Ela precisa ter cor de fundo, cor de borda, cor de texto, estados :hover, :active e :disabled coerentes, e um anel de foco na mesma família de cor.
Resultado esperado: o botão se comporta como qualquer btn-* nativo do Bootstrap, inclusive combinado com btn-lg e btn-sm.
Dica
A lista completa está na documentação do componente Button, seção "CSS variables". Você precisa de pelo menos --bs-btn-bg, --bs-btn-border-color, --bs-btn-color, os três --bs-btn-hover-*, os três --bs-btn-active-*, os três --bs-btn-disabled-* e --bs-btn-focus-shadow-rgb. Confira no DevTools: selecione o botão e procure as variáveis no painel Computed.
B3. Reescreva o card de produto do Café Cerrado em Tailwind 4, em experimentos/tailwind-card.html, chegando o mais perto possível do resultado do Bootstrap: imagem no topo com proporção fixa, corpo com título e texto, rodapé com preço à esquerda e etiqueta à direita, e altura total igual à do vizinho quando em grid.
Resultado esperado: dois cards lado a lado em telas médias, com a mesma altura, e nenhuma linha de CSS escrita fora das classes utilitárias.
Dica
Altura igual em grid sai de h-full no card e items-stretch (ou o padrão) no contêiner do grid. Para empurrar o rodapé para baixo, o card precisa ser flex flex-col e o corpo grow. A proporção da imagem sai de aspect-[4/3] object-cover.
B4. Faça a auditoria de peso da sua página inicial: na aba Network, com o cache desativado, monte uma tabela em docs/peso.md com três colunas — recurso, tamanho transferido e percentual do total. Inclua CSS, JavaScript, fontes e todas as imagens.
Resultado esperado: a tabela mostra, com números, quem realmente pesa na sua página; um parágrafo final aponta o maior vilão e o que fazer com ele.
Dica
A coluna Transferred é o que veio pela rede (comprimido); a coluna Size é o tamanho descompactado. Use a primeira. Clique em "Disable cache" antes de recarregar, senão você mede zero. O rodapé do painel mostra o total agregado.
C1. Aplique o framework escolhido ao seu projeto autoral inteiro, nas três páginas, com os mesmos requisitos do Café Cerrado: menu responsivo que colapsa, grid com no mínimo três cards que viram uma coluna no celular, formulário estilizado, paleta própria aplicada por variáveis do framework (não por !important), estrutura semântica da Aula 03 intacta e a justificativa no README.md. Publique no GitHub Pages e abra o endereço no celular de outra pessoa (ou em outro aparelho seu).
Dica
Trabalhe em uma página por vez e faça commit a cada uma. Antes de mexer no CSS, apague todo o layout escrito à mão da Aula 03: manter os dois brigando é a causa número um de "o Bootstrap não está funcionando". Se aparecer rolagem horizontal, o culpado quase sempre é uma row sem container em volta, ou um col-* que é filho de algo que não é row.
C2. Pegue a página inicial pronta e crie uma segunda versão dela em experimentos/tailwind-home.html, com o mesmo conteúdo, feita inteiramente em Tailwind 4 pelo Play CDN. Depois escreva, em docs/comparacao.md, o que ficou melhor e o que ficou pior — com números de linhas e um trecho de cada versão lado a lado.
Dica
Não tente reproduzir o visual do Bootstrap pixel a pixel: o objetivo é comparar o processo, não o resultado. Cronometre quanto tempo você levou em cada versão e registre também isso; é o dado mais honesto da comparação.
Você usou col-md-6 a tarde inteira sem nunca ter visto o CSS que faz aquilo acontecer. Isso é confortável e é perigoso: no dia em que a coluna não obedecer, você não vai ter para onde olhar. Abra a caixa preta. O bootstrap.min.css é um arquivo de texto comum — e tudo que ele faz é CSS que você já sabe ler.
Critérios de pronto
Um arquivo docs/bootstrap-por-dentro.md com o CSS real (copiado do arquivo, não de tutorial) de cinco classes: .container, .row, .col-md-6, .d-flex e .h-100.
Para cada uma, duas linhas explicando o que aquele CSS faz — em português, sem repetir o nome da propriedade.
A resposta, com evidência, para: por que uma col-* colocada fora de uma row fica com um espaço estranho nas laterais?
A resposta, com evidência, para: quantas media queries diferentes o .container usa e quais são as larguras máximas em cada uma?
Pistas
Baixe o arquivo com curl -sO e formate-o antes de ler: o VS Code faz isso com Shift+Alt+F em um arquivo .css.
Existe uma versão não minificada oficial, bootstrap.css, no mesmo caminho da CDN, com comentários — muito mais fácil de ler.
Para a pergunta do espaço lateral, procure margin-left na regra .row e padding-left na regra .row > *. Um cancela o outro; sozinho, o segundo aparece.
No DevTools, o painel Computed mostra o valor final de cada propriedade e, clicando na setinha, de qual regra ele veio.
Discussões sobre framework na internet duram anos e quase nunca têm números. Você vai encerrar a sua em uma tarde: construa o mesmo componente — um card de produto com imagem, título, texto, preço e botão — nos três frameworks desta aula, meça tudo, e só então opine.
Critérios de pronto
Três arquivos em experimentos/, um por framework, cada um com um grid de seis cards responsivo (1 / 2 / 3 colunas).
Uma tabela em docs/tres-frameworks.md com quatro colunas: framework, KB transferidos, linhas de HTML do card, tempo que você levou.
Um parágrafo sobre manutenção: em qual das três versões é mais fácil mudar a cor da marca em todos os cards de uma vez? Prove fazendo a mudança e contando quantas linhas foram tocadas.
Uma conclusão de cinco linhas que não use as palavras "melhor" nem "pior" sem número ao lado.
Pistas
Meça sempre com "Disable cache" ligado e leia a coluna Transferred, não a Size.
Para o Material Web, lembre que ele não tem grid: use CSS Grid nativo e registre isso como um custo na sua tabela.
Para a pergunta da manutenção, a resposta não é óbvia: no Tailwind sem build, trocar a cor significa editar todas as ocorrências da classe; com @theme, significa editar uma linha. Compare os dois cenários.
Um cronômetro honesto vale mais que uma opinião elegante. Anote o tempo antes de começar cada versão.
Existe um jogo cruel entre desenvolvedores: abrir um site e adivinhar o framework em dois segundos. Sites feitos com Bootstrap sem customização são os mais fáceis — azul #0d6efd, cantos de 6 px, a mesma sombra, a mesma fonte. Seu desafio é fazer o Café Cerrado passar despercebido nesse jogo, sem abandonar o framework e sem escrever um único !important.
Critérios de pronto
Nenhuma cor padrão do Bootstrap visível na página: primária, secundária, links, foco, badges e alertas todos na paleta da marca.
Tipografia própria: uma fonte de título diferente da fonte de corpo, carregada de forma que não bloqueie a renderização.
Raios de borda, sombras e escala de espaçamento ajustados por variáveis, não por regras novas.
Um arquivo docs/tema.md listando cada variável --bs-* que você sobrescreveu e o que ela controla.
Prova final: mostre a página para três pessoas quaisquer — colegas de estudo, amigos, familiares — e registre quantos acertaram o framework. Se alguém acertar, pergunte pelo quê — e conserte.
Pistas
Comece pelo :root: --bs-primary-rgb, --bs-body-bg, --bs-body-color, --bs-border-radius, --bs-box-shadow, --bs-font-sans-serif.
As cores do Bootstrap 5.3 vivem em duas formas, --bs-primary e --bs-primary-rgb. Utilitários de fundo e texto usam a segunda; se você trocar só a primeira, metade da página não muda.
Fonte sem bloquear renderização significa <link rel="preconnect"> mais display=swap na URL do Google Fonts — ou, melhor ainda, a fonte hospedada no seu próprio repositório.
O detalhe que mais entrega o Bootstrap não é a cor: é o border-radius de 6 px e o espaçamento vertical dos componentes. Mexa neles.
A melhor forma de entender um framework é escrever um. Não um clone do Bootstrap — um micro-framework honesto, de no máximo 200 linhas de CSS, com o mínimo necessário para montar o site do Café Cerrado sem nenhuma dependência externa. Quando terminar, você vai saber exatamente o que está comprando quando importa 230 KB de CSS.
Critérios de pronto
Um arquivo experimentos/mini.css com, no máximo, 200 linhas: reset, escala de espaçamento por variáveis, grid responsivo de 12 colunas com três breakpoints, e quatro componentes (botão, card, campo de formulário, barra de navegação).
O grid precisa funcionar com a mesma sintaxe de nome que você escolher, documentada em experimentos/mini.md, e ser implementado com CSS Grid ou Flexbox — a escolha justificada por escrito.
Uma cópia da página inicial do Café Cerrado usando apenas o seu mini.css, visualmente coerente (não precisa ser idêntica) e sem rolagem horizontal em 360 px.
Comparativo de tamanho: quantos KB o seu arquivo tem contra os do Bootstrap, e a lista honesta do que o seu não faz.
Uma seção "o que eu subestimei" com pelo menos três itens que pareciam simples e não eram.
Pistas
Comece pelo grid; é a parte que dá mais retorno. Com CSS Grid, grid-template-columns: repeat(12, 1fr) e classes de grid-column: span N resolvem em menos de 20 linhas.
Use uma única escala de espaçamento em variáveis (--e-1 a --e-5) e faça todos os componentes lerem dela. É isso que dá coerência visual, mais do que qualquer cor.
Não tente cobrir todos os casos. Um framework de 200 linhas é uma decisão sobre o que não fazer — e essa lista de recusas é a parte mais valiosa da entrega.
Compare o seu resultado com projetos reais de CSS mínimo, como o new.css ou o Pico.css, depois de terminar o seu — nunca antes.
Parte 1 — Leitura (20 min). QUEIRÓS e PORTELA, Introdução ao Desenvolvimento Moderno para a Web, seções sobre a camada de apresentação e frameworks de CSS (se você tem acesso a uma biblioteca virtual pela sua instituição). Em seguida, leia a página Layout → Breakpoints da documentação oficial do Bootstrap 5.3 e a página Styling with utility classes do Tailwind. Anote duas ideias que os dois textos defendem de formas opostas.
Parte 2 — Entrega (30 min). No repositório do seu projeto autoral:
O framework escolhido aplicado às três páginas, com menu responsivo, grid de no mínimo três cards e formulário estilizado (exercício C1).
README.md com a seção "Framework CSS": qual, por quê (dois a quatro argumentos), como foi carregado e o que é seu contra o que é do framework.
docs/peso.md com a auditoria do exercício B4.
Nenhum !important no seu CSS — fora do bloco prefers-reduced-motion da Aula 05, onde ele é a forma correta. Se houver algum, troque por variável do framework ou por um seletor honesto, e registre a troca no README.md.
Parte 3 — Argumento contrário (10 min). Em docs/framework.md, anote o framework escolhido e um argumento contra a sua própria escolha (todo framework tem um). Se puder, compare com outra pessoa que tenha feito uma escolha diferente e anote uma situação concreta em que a escolha dela seria melhor que a sua.
Critério de pronto: o site do projeto autoral abre pelo endereço do GitHub Pages; em 360 px de largura não há rolagem horizontal e o menu colapsa; em 1200 px os cards ficam lado a lado; a paleta é a da sua marca, não a padrão do framework; e o README.md justifica a escolha em texto próprio.
Ao fim desta aula, o repositório do seu projeto autoral deve ter:
[ ] Framework CSS carregado por CDN nas três páginas, com versão fixa na URL e, quando o provedor fornecer, integrity e crossorigin="anonymous".
[ ] O seu arquivo de estilos carregado depois do framework, em todas as páginas.
[ ] Menu responsivo que colapsa em telas estreitas e volta ao normal nas largas, com aria-current="page" preservado da Aula 03.
[ ] Grid do framework em uso com pelo menos três cards que passam de 3 para 1 coluna conforme a largura.
[ ] Formulário de contato inteiro estilizado pelas classes do framework, com a validação nativa ainda funcionando.
[ ] Estrutura semântica da Aula 03 intacta: header, nav, main, footer, fieldset, legend, caption, th scope, alt em todas as imagens.
[ ] Paleta da marca aplicada por variáveis do framework; zero!important no seu CSS. A única exceção aceita no curso inteiro é o bloco @media (prefers-reduced-motion: reduce) que você vai escrever na Aula 05: ali o !important é o mecanismo previsto para desligar animações que qualquer outra regra tenha ligado.
[ ] Nenhuma rolagem horizontal em 360 px de largura, em nenhuma das três páginas.
[ ] Um único framework CSS no projeto.
[ ] README.md com a seção "Framework CSS" justificando a escolha em texto próprio.
[ ] docs/peso.md com a auditoria de tamanho da página inicial.
[ ] Zero erros no validador do W3C nas três páginas; commit e push feitos e site atualizado no GitHub Pages.
Material Design 3: https://m3.material.io/ — a especificação do design system; a seção Styles → Color explica o esquema de cores derivado de uma cor-semente.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — camada de apresentação e frameworks CSS.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — organização e manutenibilidade de CSS em projetos grandes; o capítulo que explica por que a escala de espaçamento importa.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — layout e interface de sistemas web.
O site do Café Cerrado agora é responsivo e tem a cor da marca — mas ele é estático como uma foto: o botão troca de estado num estalo, o card não reage ao mouse, o logotipo ainda é texto puro e os ícones não existem. Na próxima aula você acrescenta a camada de movimento e desenho: transition e @keyframes com propósito, transform medido no painel Performance, o logotipo desenhado em SVG inline, um sprite de ícones vetoriais e o bloco prefers-reduced-motion que respeita quem prefere a tela quieta. As classes .btn-cafe, .card-produto e .hero__titulo que você criou hoje são exatamente os ganchos onde essas animações vão se pendurar.
Explicar o que uma animação de interface comunica ao usuário e decidir, com critério, quando não animar.
Escrever transições CSS completas (propriedade, duração, curva e atraso) para os estados :hover, :focus-visible, :active e :disabled, sem quebrar o retorno visual de quem navega por teclado.
Usar transform (translate, scale, rotate) e transform-origin para mover, ampliar e girar elementos sem afetar o layout dos vizinhos.
Construir animações de múltiplas etapas com @keyframes e controlá-las com animation-delay, animation-iteration-count e animation-fill-mode.
Justificar, pelo pipeline de renderização do navegador, por que transform e opacity são as propriedades baratas — e comprovar isso no painel Performance do DevTools.
Descrever a estrutura de um arquivo SVG (viewBox, formas básicas, path), escolher entre <img src="…svg"> e SVG inline, e estilizar/animar um SVG inline com fill, stroke e @keyframes.
Aplicar prefers-reduced-motion e as regras da WCAG sobre movimento, cobrindo o critério "animação/SVG" do Marco 1.
[ ] Repositório cafe-cerrado publicado no GitHub Pages, com index.html, cardapio.html e contato.html.
[ ] Bootstrap 5.3 carregado via CDN e css/estilo.cssdepois dele no <head> (Aula 04), com as variáveis de cor no :root (Aula 02).
[ ] Navbar responsiva, grid de cards no cardápio e formulário de contato estilizados pelo Bootstrap (Aula 04).
[ ] Seu projeto autoral no mesmo estágio, com a escolha do framework justificada no README.md.
[ ] VS Code com Live Server e Chrome ou Firefox com DevTools — hoje você vai usar as abas Elements, Rendering e Performance.
[ ] Um celular na mesma rede Wi-Fi do computador (para sentir a diferença de desempenho de verdade).
Na aula passada o Café Cerrado ganhou aparência profissional: o Bootstrap trouxe navbar responsiva, grid de 12 colunas, cards no cardápio e um formulário consistente, e o README.md passou a justificar essa escolha. O site está bonito e responsivo — mas tudo nele acontece de repente: o botão troca de cor num estalo, o card não reage ao mouse, o logotipo ainda é um texto e os ícones são caracteres soltos. Hoje o site ganha movimento e desenho vetorial: microinterações que comunicam, um logotipo em SVG nítido em qualquer tela e um bloco prefers-reduced-motion que respeita quem prefere menos animação.
Animação em interface não é enfeite. Ela existe para responder a três perguntas do usuário:
Pergunta do usuário
O que a animação responde
"O sistema recebeu meu clique?"
Feedback — o botão afunda e escurece no instante do toque
"De onde saiu isso?"
Continuidade — o menu desliza para fora do botão que o abriu
"Para onde devo olhar?"
Atenção — o campo com erro treme de leve e o olho vai até ele
Se uma animação não responde a nenhuma dessas perguntas, ela é ruído. Movimento gratuito cansa, atrasa a interação (o usuário espera a animação terminar para clicar) e prejudica pessoas com sensibilidade vestibular, que sentem tontura real diante de deslocamentos grandes na tela.
A regra de ouro desta aula: toda animação precisa responder "o que isso está dizendo ao usuário?". Se você não consegue responder em uma frase, apague.
⚠️ Atenção
O critério "animação/SVG" do Marco 1 é sobre coerência, não sobre quantidade. Três microinterações bem escolhidas valem mais que dez efeitos disputando atenção. Um site que pisca inteiro perde qualidade.
🧠 Você sabia?
Transições e animações em CSS não nasceram em um comitê: foram propostas pela Apple em 2007, dentro do WebKit, para que o Safari do primeiro iPhone conseguisse fazer interfaces fluidas sem JavaScript — o hardware da época não aguentava animar via script a 60 quadros por segundo. As propriedades saíram com prefixo -webkit-, os outros navegadores copiaram, e só anos depois viraram recomendação do W3C. É por isso que transition e animation parecem "feitas para telas de toque": elas foram, literalmente.
Uma transição interpola automaticamente a mudança de um valor entre dois estados. Você declara os dois estados (normal e :hover, por exemplo) e diz ao navegador: "em vez de trocar de repente, leve 200 ms para ir de um ao outro". O navegador calcula todos os valores intermediários, quadro a quadro.
Passe o mouse: a cor muda suavemente e o botão sobe 2 px. Tire o mouse: ele desce e volta à cor original, também suavemente — porque a transition está declarada no estado normal, e vale nos dois sentidos.
.painel-exemplo{transition-property:transform,opacity;/* o que anima */transition-duration:300ms;/* quanto tempo dura */transition-timing-function:ease-out;/* a curva de velocidade */transition-delay:100ms;/* espera antes de começar */}/* Atalho equivalente: propriedade duração curva atraso */.painel-atalho{transition:transform300msease-out100ms,opacity300msease-out100ms;}
O atalho aceita várias transições separadas por vírgula, cada uma com sua duração e curva. Na prática você usa quase sempre o atalho.
📌 Vale gravar
A transition deve ser declarada no estado base do seletor (.botao), nunca dentro do estado de interação (.botao:hover). Se ficar no :hover, a entrada é suave e a saída é abrupta: ao tirar o mouse a regra :hover deixa de valer e leva a transition junto.
A curva define como a velocidade varia ao longo da transição. É o que separa um movimento mecânico de um movimento natural.
Valor
Comportamento
Uso típico
linear
Velocidade constante
Rotação contínua, barra de progresso
ease
Acelera e desacelera (padrão)
Uso geral
ease-in
Começa devagar, termina rápido
Elemento saindo da tela
ease-out
Começa rápido, termina devagar
Elemento entrando na tela
cubic-bezier(0.16, 1, 0.3, 1)
Curva personalizada, chegada macia
Painéis, cards, cartazes
steps(4, end)
Saltos discretos
Sprites, efeito de digitação
Regra de bolso: o que entra usa ease-out (chega rápido e assenta devagar, como um carro estacionando); o que sai usa ease-in (parte devagar e some rápido). Uma transição de cor pode usar ease e ninguém percebe diferença.
🔬 Investigue
Abra o site do Café Cerrado, inspecione um botão (F12 → Elements) e acrescente transition: transform 1s ease no painel Styles. Repare no pequeno ícone de curva que aparece ao lado do valor ease: clique nele. O DevTools abre um editor de cubic-bezier com a curva desenhada e uma bolinha que percorre a animação. Arraste os pontos de controle até criar um ricochete (o segundo ponto acima de 1) e observe o botão se mover ao vivo. Anote os quatro números da curva que você gostou — você vai usá-los no Passo 1 da Mão na massa.
Painéis, modais, elementos grandes que percorrem distância
acima de 500 ms
Percebido como lento e irritante
Elementos pequenos que se movem pouco precisam de menos tempo; elementos grandes que atravessam a tela precisam de mais. Na dúvida, comece em 250 ms e ajuste olhando.
⚠️ Atençãotransition: all é uma armadilha. Ele anima todas as propriedades que mudarem, inclusive as caras (width, height, margin) e as que você nem sabia que mudaram. Pior: quando você acrescentar CSS meses depois, coisas passarão a animar sem explicação. Liste as propriedades sempre.
Não animam:display, position, font-family, flex-direction, visibility (tem regra especial).
Não existe meio caminho entre display: none e display: block. Por isso o bloco abaixo simplesmente não faz nada:
CSS
/* Errado: display não interpola — o painel aparece de repente */.painel-errado{display:none;transition:display300ms;}
A solução clássica combina opacity (que interpola) com visibility (que retira o elemento da navegação por teclado e do leitor de tela):
CSS
/* Certo: opacity anima; visibility esconde do teclado e do leitor de tela */.painel-certo{opacity:0;visibility:hidden;transition:opacity300msease,visibility0s300ms;}.painel-certo.aberto{opacity:1;visibility:visible;transition:opacity300msease,visibility0s;}
Repare no truque do visibility. Ao esconder, visibility 0s 300ms espera o fade terminar e só então vira hidden; sem o atraso, o painel sumiria antes de desvanecer. Ao mostrar, visibility 0s (sem atraso) torna o elemento visível na hora e o opacity faz a entrada.
🔎 Por baixo do capô
Por que opacity: 0 sozinho não basta? Porque um elemento transparente continua no fluxo e continua focável: quem navega por Tab cai em links invisíveis e o leitor de tela lê o conteúdo de um painel que "não está lá". visibility: hidden resolve os dois problemas de uma vez. Guarde essa dupla: na Aula 06 ela vira critério de acessibilidade, e na Unidade 2 o JavaScript vai apenas alternar a classe .aberto.
transform altera a renderização do elemento sem afetar o layout dos vizinhos. Um elemento com transform: translateX(50px) é desenhado 50 px à direita, mas para o resto da página ele continua exatamente onde estava — ninguém é empurrado, nada é recalculado. É por isso que transform é barato (a §5 mede isso).
CSS
/* Translação: move sem tirar do fluxo */.t1{transform:translateX(20px);}.t2{transform:translateY(-10px);}.t3{transform:translate(20px,-10px);}/* Escala: 1 é o tamanho original */.t4{transform:scale(1.05);}/* 5% maior nos dois eixos */.t5{transform:scaleX(2)scaleY(0.5);}/* estica na horizontal, achata na vertical *//* Rotação: graus ou voltas */.t6{transform:rotate(45deg);}.t7{transform:rotate(-0.25turn);}/* um quarto de volta, anti-horário *//* Inclinação */.t8{transform:skewX(12deg);}/* Combinadas: a ORDEM importa */.t9{transform:translateX(50px)rotate(45deg);}/* move, depois gira no lugar */.t10{transform:rotate(45deg)translateX(50px);}/* gira o eixo, depois anda pelo eixo girado */
As funções são aplicadas da esquerda para a direita, sobre o sistema de coordenadas do elemento. Em .t9 o elemento desliza 50 px para a direita e então gira em torno do próprio centro. Em .t10 ele gira primeiro — e com ele giram os eixos X e Y — e só então anda 50 px "para a direita", que agora aponta para a diagonal. Os dois terminam em lugares diferentes.
Toda transformação acontece em torno de um ponto. O padrão é o centro do elemento; transform-origin muda isso.
CSS
.origem-padrao{transform-origin:center;}/* gira/escala em torno do centro */.origem-canto{transform-origin:topleft;}/* gira em torno do canto superior esquerdo */.origem-base{transform-origin:50%100%;}/* meio da borda inferior: um pêndulo */.origem-esq{transform-origin:left;}/* cresce da esquerda para a direita */
transform-origin: left combinado com scaleX é a base do sublinhado animado do menu que você vai construir na Mão na massa: o traço nasce na esquerda ao entrar e recolhe pela direita ao sair, bastando trocar a origem nos dois estados.
:focus-within é o irmão de teclado do :hover: ele casa com o card quando qualquer elemento dentro dele recebe foco. Sem essa linha, quem navega por Tab vê o link do card ganhar foco enquanto o card em volta permanece inerte — o retorno visual some justamente para quem mais precisa dele.
Transição precisa de um gatilho (um :hover, uma classe trocada). Para animar sem gatilho, ou com mais de dois estados, use @keyframes + animation.
CSS
/* 1. Definir os quadros-chave */@keyframessurgir{from{opacity:0;transform:translateY(1.5rem);}to{opacity:1;transform:translateY(0);}}@keyframespulsar{0%{transform:scale(1);}50%{transform:scale(1.06);}100%{transform:scale(1);}}/* 2. Aplicar aos elementos */.titulo-hero{animation:surgir400msease-out;}.botao-cta{animation:pulsar2.4sease-in-out3;}/* repete 3 vezes e para */
from/to são apelidos de 0%/100%. Percentuais permitem quantas etapas você quiser.
Estas cinco resolvem quase tudo o que a Unidade 1 precisa. Guarde-as no fim do css/estilo.css.
css/estilo.css (seção "Animações")
CSS
/* Entrada: sobe e aparece */@keyframessurgir{from{opacity:0;transform:translateY(1.5rem);}to{opacity:1;transform:translateY(0);}}/* Aparecer sem deslocamento (para elementos que já estão no lugar certo) */@keyframesaparecer{from{opacity:0;}to{opacity:1;}}/* Rotação contínua: spinners */@keyframesgirar{to{transform:rotate(1turn);}}/* Brilho passando: esqueleto de carregamento */@keyframesbrilho{from{background-position:-150%0;}to{background-position:250%0;}}/* Tremida curta: campo inválido */@keyframestremer{0%,100%{transform:translateX(0);}20%,60%{transform:translateX(-4px);}40%,80%{transform:translateX(4px);}}
Fazer seis cards aparecerem juntos é banal; fazê-los aparecer em cascata custa três linhas. A ideia é dar a cada card um índice via variável CSS e calcular o atraso a partir dele.
cardapio.html (trecho)
HTML
<divclass="col-12 col-md-6 col-lg-4"><articleclass="card card-produto"style="--i: 1"><h3class="h5">Espresso do Cerrado</h3></article></div><divclass="col-12 col-md-6 col-lg-4"><articleclass="card card-produto"style="--i: 2"><h3class="h5">Coado da Casa</h3></article></div>
O backwards é obrigatório aqui: durante os 80, 160, 240 ms de espera, o card precisa já estar no primeiro quadro (invisível). Sem ele, os seis cards aparecem prontos e depois piscam um a um.
⚠️ Atençãovar(--i, 0) usa o valor de reserva0: se algum card esquecer o style="--i: …", ele entra sem atraso em vez de quebrar o calc(). Sempre que ler uma variável que pode não existir, dê um valor de reserva.
Para desenhar um quadro, o navegador percorre um pipeline:
Etapa
O que faz
Custo
Layout (reflow)
Recalcula posição e tamanho de todos os elementos afetados
Alto
Paint
Pinta pixels: cores, sombras, textos, bordas
Médio
Composite
Junta as camadas já pintadas, aplicando deslocamento e opacidade
Baixo
Animar width, height, top, left, margin ou padding dispara Layout → Paint → Composite a cada quadro. Em 60 fps são 60 recálculos por segundo de toda a árvore afetada.
Animar background-color, box-shadow, border-radius ou fill dispara Paint → Composite.
Animar transform e opacity dispara só Composite — e o compositor roda em outra thread, muitas vezes na GPU.
Daí a regra prática: anime transform e opacity; tudo o mais, com parcimônia. Um box-shadow que muda em um botão é irrelevante; o mesmo box-shadow mudando em 40 cards ao mesmo tempo trava o celular de quem está vendo.
🔬 Investigue
Abra o DevTools, pressione Ctrl+Shift+P, digite "Show Rendering" e ative Paint flashing. Volte ao Café Cerrado e passe o mouse sobre os cards: as áreas repintadas piscam em verde. Agora troque, no painel Styles, o transform: translateY(-6px) do :hover por margin-top: -6px e repita. Compare quanta área verde cada versão produz. Depois ative Frame Rendering Stats (na mesma aba) e observe o contador de FPS enquanto rola a página com as duas versões. Anote os dois números — eles são metade da resposta do exercício B7 do Laboratório.
will-change: transform avisa o navegador para promover o elemento a uma camada própria antes de a animação começar, evitando o engasgo do primeiro quadro. É uma ferramenta de último recurso: cada camada consome memória de vídeo, e dezenas de camadas deixam a página mais lenta.
CSS
/* Use apenas no elemento que comprovadamente engasga */.painel-lateral{will-change:transform;}
Regra: meça primeiro no painel Performance, aplique depois, e remova se o ganho não aparecer no gráfico.
Movimento na tela não é neutro. Pessoas com distúrbios vestibulares, enxaqueca com aura ou transtornos de atenção podem sentir tontura, náusea ou perda de foco diante de deslocamentos amplos, paralaxe e animações infinitas. Os sistemas operacionais oferecem a opção "reduzir movimento", e o CSS a expõe:
0.01ms e não 0. Uma animação com duração zero pode nunca disparar o evento animationend, que a Unidade 2 usa para remover elementos. Um valor mínimo mantém o evento e é imperceptível.
!important. O bloco precisa vencer qualquer regra escrita depois, inclusive as do Bootstrap. É a única exceção à regra "zero !important" do checkpoint da Aula 04, e ela é deliberada: aqui o objetivo é justamente atropelar tudo o que tenha ligado movimento.
Reduzir não é remover. Nada pode sumir nem parar de funcionar com o movimento desligado: o menu abre, o card muda de sombra, o formulário envia. Só o deslocamento desaparece.
O que a WCAG 2.2 exige a respeito:
Critério
Exigência
2.2.2 Pausar, parar, ocultar
Movimento automático com mais de 5 s precisa de controle para pausar
2.3.1 Três flashes
Nada pode piscar mais de 3 vezes por segundo
2.3.3 Animação a partir de interações
Animação disparada por interação deve poder ser desligada
É por isso que o botão de destaque da Mão na massa pulsa três vezes e para, em vez de pulsar infinite: sem repetição infinita, não há o que pausar.
💡 Dica
O Bootstrap 5.3 já traz um bloco prefers-reduced-motion interno para as próprias animações (colapso da navbar, modais, carrossel). O bloco acima cuida do seu CSS. Os dois convivem sem conflito.
SVG (Scalable Vector Graphics) é um formato de imagem descrito em XML — ou seja, é código, como o HTML. Em vez de guardar pixels, guarda instruções de desenho: "um círculo de raio 40 na posição tal, preenchido com esta cor".
Aspecto
PNG / JPG (bitmap)
SVG (vetorial)
Escala
Serrilha ao ampliar
Nítido em qualquer tamanho e densidade de tela
Tamanho do arquivo
Cresce com a resolução
Minúsculo para ícones e logos
Estilizável com CSS
Não
Sim (cores, hover, animação)
Ideal para
Fotos
Ícones, logos, ilustrações, gráficos
Um logotipo em PNG precisa de três arquivos (1×, 2×, 3×) para ficar nítido em telas de alta densidade. Em SVG, é um arquivo só, de poucos KB, perfeito em qualquer tela — inclusive na de 4K do laboratório e na do celular.
🧠 Você sabia?
O SVG nasceu de um empate. Em 1998 dois formatos vetoriais concorrentes foram submetidos ao W3C: o PGML, defendido por Adobe, IBM, Netscape e Sun, e o VML, defendido por Microsoft, Macromedia e Autodesk. Em vez de escolher um lado, o W3C montou um grupo de trabalho que fundiu os dois numa especificação nova — o SVG, recomendação em 2001. Só que o Internet Explorer levou uma década para suportá-lo (chegou no IE9, em 2011), e nesse intervalo o Flash dominou a web vetorial. Quando o Flash morreu, o SVG estava lá, aberto e padronizado, esperando.
<svgviewBox="0 0 200 100"width="200"height="100"role="img"aria-label="Formas de exemplo"><rectx="10"y="10"width="80"height="80"rx="12"fill="#3e2723"/><circlecx="150"cy="50"r="40"fill="#d99e33"/><linex1="94"y1="50"x2="106"y2="50"stroke="#4e7c59"stroke-width="4"/><textx="50"y="55"font-size="14"text-anchor="middle"fill="#f5efe6">Café</text></svg>
viewBox="0 0 200 100" define o sistema de coordenadas interno: a origem em (0, 0), 200 unidades de largura e 100 de altura. Todas as coordenadas dos desenhos (x, cy, r) usam essas unidades. O navegador então estica esse retângulo até o tamanho externo (width/height, ou o tamanho que o CSS mandar), mantendo as proporções. É o segredo da nitidez infinita: as unidades internas não são pixels, são uma régua abstrata.
Na prática: defina o viewBox no HTML e o tamanho no CSS. Assim o mesmo ícone serve para 16 px e para 64 px sem editar o arquivo.
<path> desenha qualquer coisa. O atributo d é uma sequência de comandos, cada um uma letra seguida de números. Maiúscula significa coordenada absoluta; minúscula, relativa ao ponto atual.
Comando
Significado
M x y
move to — levanta a caneta e vai até o ponto
L x y / H x / V y
line to — linha reta, ou só horizontal, ou só vertical
C x1 y1 x2 y2 x y
curva de Bézier cúbica com dois pontos de controle
A rx ry giro arco sentido x y
arco de elipse
Z
fecha o caminho, ligando ao ponto inicial
Leia este d em voz alta e você entende a xícara do logotipo do Café Cerrado:
"Vá até (12, 26); ande 34 para a direita; desça 10; faça um arco de raio 17 até 34 unidades à esquerda; feche." O resultado é um copo de fundo arredondado.
💡 Dica
Ninguém escreve path complexo na mão. Desenhe no Figma, Inkscape ou Illustrator e exporte como SVG; ou copie de bibliotecas de ícones. O que você precisa saber é ler o resultado, para trocar cores, remover atributos inúteis e entender por que um ícone não aparece.
<!-- 1. Como imagem: simples, cacheável, mas o CSS da página não entra no desenho --><imgsrc="img/logo.svg"alt="Café Cerrado"width="120"height="40"><!-- 2. Inline: cada forma vira um nó do DOM, estilizável e animável com CSS --><svgclass="logo"viewBox="0 0 64 64"aria-hidden="true"focusable="false"><circlecx="32"cy="32"r="28"fill="currentColor"/></svg><!-- 3. Sprite: define uma vez com <symbol>, reutiliza com <use> --><svgclass="sprite-icones"aria-hidden="true"focusable="false"><symbolid="icone-relogio"viewBox="0 0 24 24"><pathd="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 2a8 8 0 1 1 0 16 8 8 0 0 1 0-16z"/><pathd="M11 6h2v6.4l4.2 2.5-1 1.7L11 13.6z"/></symbol></svg><svgclass="icone"aria-hidden="true"focusable="false"><usehref="#icone-relogio"/></svg>
Quando usar cada uma:
<img> para ilustrações grandes que não mudam de cor: o navegador guarda em cache e o HTML fica limpo.
Inline para logotipos e ícones que precisam responder ao tema, ao :hover ou a uma animação.
Sprite com <symbol> + <use> quando o mesmo ícone aparece muitas vezes na página: você paga o desenho uma vez e referencia com uma linha.
⚠️ Atenção
Em <use href="#a05-id"> a forma moderna é href; a antiga, xlink:href, ainda aparece em tutoriais e continua funcionando por compatibilidade, mas está obsoleta. Use href. E não esconda o sprite com display: none: alguns navegadores deixam de resolver a referência. Use position: absolute; width: 0; height: 0; overflow: hidden.
Dentro de um SVG inline valem seletores CSS normais, mais três propriedades específicas:
Propriedade
Equivalente mental
fill
a cor do "miolo" da forma
stroke
a cor do contorno
stroke-width
a espessura do contorno
E há uma palavra mágica: currentColor. Um fill="currentColor" faz a forma herdar a cor do texto do elemento pai — então mudar color no CSS muda o ícone junto, inclusive no :hover e no tema escuro.
O efeito de "desenhar o traço" usa duas propriedades do contorno. stroke-dasharray transforma a linha em tracejado; se o traço tiver exatamente o comprimento total do caminho, ele vira uma linha contínua. stroke-dashoffset desloca esse tracejado — e um deslocamento igual ao comprimento total esconde a linha inteira. Animar o offset de "tudo" para "zero" desenha o traço.
CSS
.divisor__linha{fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-dasharray:84;/* comprimento do caminho */stroke-dashoffset:84;/* começa totalmente escondido */animation:desenhar900msease-outforwards;}@keyframesdesenhar{to{stroke-dashoffset:0;}}
🔬 Investigue
Como descobrir o comprimento exato de um caminho? Abra o Console (F12 → Console) na página que tem o SVG e rode document.querySelector(".divisor__linha").getTotalLength(). O número que aparece é o valor que você deve colocar em stroke-dasharray e stroke-dashoffset. Teste com um valor 20 % menor e observe o traço ficar incompleto; com um valor maior, o desenho começa com um atraso invisível. É a única medição desta aula que precisa do Console — na Unidade 2 você entenderá cada pedaço dessa linha.
Esta é a parte que mais reprova em auditoria, e é a ponte direta para a próxima aula. A pergunta é sempre a mesma: este desenho carrega informação que não está em nenhum outro lugar?
HTML
<!-- Informativo: é a única fonte da informação --><svgrole="img"aria-labelledby="titulo-grafico"viewBox="0 0 100 100"><titleid="titulo-grafico">Gráfico: 70% das vendas são de café coado</title><circlecx="50"cy="50"r="40"fill="#4e7c59"/></svg><!-- Decorativo: o texto ao lado já diz tudo --><aclass="navbar-brand"href="index.html"><svgaria-hidden="true"focusable="false"viewBox="0 0 64 64"><circlecx="32"cy="32"r="28"fill="currentColor"/></svg>
Café Cerrado
</a>
Três regras:
SVG informativo recebe role="img" mais um nome acessível: aria-label="…" ou <title> referenciado por aria-labelledby. O role="img" é necessário porque leitores de tela antigos não tratam <svg> como imagem por padrão.
SVG decorativo recebe aria-hidden="true". Se o texto ao lado já diz "Café Cerrado", o logotipo repetir isso só faz o leitor de tela dizer tudo duas vezes.
focusable="false" sempre. Sem ele, o Internet Explorer e alguns motores antigos colocam o <svg> na ordem de tabulação, criando paradas invisíveis no Tab. Custa nada e evita um erro clássico.
Todas permitem copiar o código SVG e colar inline. Sempre confira a licença antes de usar em um trabalho publicado, e cite a origem no README.md. Ícones baixados de bancos de imagens genéricos costumam vir com termos restritivos — e um trabalho desta trilha fica público no GitHub Pages.
Antes de colar, faça uma limpeza: remova width/height fixos (o CSS cuida disso), remova <title> duplicados, troque fill="#000000" por fill="currentColor" e apague metadados de editor (<metadata>, atributos sodipodi:* ou inkscape:*). O site svgomg.net faz isso automaticamente e costuma cortar mais da metade do peso.
Ao fim destes onze passos o site terá logotipo vetorial, ícones vetoriais, microinterações em todos os elementos clicáveis, três animações com propósito e respeito à preferência de movimento reduzido. Trabalhe com o Live Server aberto e o DevTools ao lado.
Abra o css/estilo.css e acrescente ao :root que já existe (Aulas 02 e 04) o bloco de movimento. As cores não mudam: --cor-marca, --cor-marca-escura, --cor-destaque, --cor-fundo, --cor-superficie, --cor-texto e --cor-texto-suave continuam sendo as mesmas do primeiro dia. Não crie um segundo :root nem renomeie cor nenhuma — a Aula 06 vai medir essa paleta com o Lighthouse, e ela precisa ser a que está no seu arquivo.
css/estilo.css
CSS
:root{/* … as cores das Aulas 02 e 04 continuam aqui, intactas … *//* Movimento — Aula 05 */--duracao-rapida:150ms;--duracao-media:250ms;--duracao-longa:400ms;--curva-entrada:cubic-bezier(0.16,1,0.3,1);--curva-saida:cubic-bezier(0.4,0,1,1);--elevacao:00.75rem1.5remrgba(74,51,37,0.18);/* 74, 51, 37 = #4a3325 */}
A partir daqui nenhuma duração solta aparece no arquivo: toda transição usa uma dessas variáveis. Quando pedirem "deixe tudo 30 % mais rápido", você muda três linhas. E, como na Aula 02, nenhuma cor literal fora do :root: todo o CSS de hoje usa var(--cor-*).
Passo 2 — microinterações nos elementos clicáveis¶
O Bootstrap já transiciona cor de fundo e borda nos botões; o que falta é o movimento e um foco de teclado que se veja.
css/estilo.css
CSS
/* Uma transição para todos os elementos clicáveis, no estado base */.btn,.nav-link,.card-produto,.rodapea{transition:background-colorvar(--duracao-rapida)ease,colorvar(--duracao-rapida)ease,transformvar(--duracao-media)var(--curva-entrada),box-shadowvar(--duracao-media)var(--curva-entrada);}/* As cores do .btn-cafe já estão definidas na Aula 04, pelas variáveis --bs-btn-*. Aqui só acrescentamos movimento — nenhuma propriedade de cor, nenhuma regra concorrente. */.btn-cafe:hover{transform:translateY(-2px);box-shadow:var(--elevacao);}.btn-cafe:active{transform:translateY(0);box-shadow:none;}.btn-cafe:disabled,.btn-cafe.disabled{opacity:0.55;transform:none;box-shadow:none;cursor:not-allowed;}/* O mesmo retorno para quem chega pelo teclado */.btn:focus-visible,.nav-link:focus-visible,.rodapea:focus-visible{outline:3pxsolidvar(--cor-destaque);outline-offset:3px;}
Repare no :disabled: ele desliga o movimento. Um botão desabilitado que ainda sobe no hover mente ao usuário.
Repare também no que não está aqui: nenhuma cor de fundo, de borda ou de texto do .btn-cafe. Elas moram nas variáveis --bs-btn-* que você definiu na Aula 04, e é lá que continuam. Se você redeclarasse background-color aqui, teria duas regras .btn-cafe no mesmo arquivo brigando pelo mesmo botão — exatamente o "brigar com o framework" que a Aula 04 ensinou a evitar. Movimento é responsabilidade desta aula; cor é responsabilidade da anterior.
Aqui está a transform-origin da §3.1 em ação: no estado base a origem é right, então o traço recolhe pela direita ao sair; nos estados ativos ela é left, então o traço cresce da esquerda ao entrar. O seletor [aria-current="page"] reaproveita o atributo que você colocou na Aula 03 — o item da página atual fica sublinhado o tempo todo, sem classe extra.
Repare também que a transition aparece duas vezes, com curvas diferentes. Isso não contradiz a regra do callout 📌 Vale gravar: a transition continua declarada no estado base (é ela que faz a saída acontecer); a segunda apenas sobrescreve a curva enquanto o estado ativo vale, dando --curva-entrada na ida e --curva-saida na volta. É a forma correta de ter velocidades diferentes nos dois sentidos — e é exatamente por isso que apagar a declaração do estado base quebraria a saída.
O overflow: hidden no card é o que impede a imagem ampliada de vazar pela borda arredondada. E o :focus-within garante que, ao chegar de Tab no link "Ver detalhes" dentro do card, o card inteiro reaja igual ao hover.
O SVG é aria-hidden="true" porque o <span> ao lado já dá o nome acessível do link. Se você optar por remover o texto e deixar só o desenho, troque para role="img" aria-label="Café Cerrado" — sem isso o link vira um botão sem nome, erro que o Lighthouse aponta na próxima aula.
Como o fill e o stroke são currentColor, basta mudar color no .logo para o logotipo inteiro trocar de cor. Guarde isso: na Aula 06 você vai precisar ajustar essa cor para passar no contraste.
<articleclass="card card-produto h-100"style="--i: 1"><imgsrc="img/espresso.jpg"class="card-img-top"alt="Xícara de espresso sobre a mesa de madeira"><divclass="card-body"><h3class="h5 card-title">Espresso do Cerrado</h3><pclass="card-text">Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.</p><ulclass="list-unstyled small mb-0"><li><svgclass="icone"aria-hidden="true"focusable="false"><usehref="#icone-grao"/></svg>
Torra média
</li><li><svgclass="icone"aria-hidden="true"focusable="false"><usehref="#icone-relogio"/></svg>
Pronto em 2 min
</li><li><svgclass="icone"aria-hidden="true"focusable="false"><usehref="#icone-chama"/></svg>
Servido quente
</li></ul></div></article>
css/estilo.css
CSS
/* O sprite existe para ser referenciado, nunca para ser visto */.sprite-icones{position:absolute;width:0;height:0;overflow:hidden;}.icone{width:1.1rem;height:1.1rem;fill:currentColor;vertical-align:-0.15em;margin-right:0.35rem;color:var(--cor-destaque);transition:transformvar(--duracao-rapida)ease,colorvar(--duracao-rapida)ease;}.card-produto:hover.icone{color:var(--cor-marca);transform:scale(1.15);}
Os ícones são decorativos: o texto ao lado ("Torra média") já carrega a informação. Por isso aria-hidden="true" em todos.
Numere os cards no HTML com style="--i: 1", style="--i: 2" e assim por diante. Com seis produtos, o último entra 400 ms depois do primeiro — perceptível, mas não irritante. Acima de dez cards, reduza para 40 ms ou o usuário espera demais.
🔎 Por baixo do capô
A animação de entrada e o :hover do Passo 4 disputam a mesma propriedade transform. Enquanto a animação está em execução ela vence — passar o mouse sobre um card nos primeiros 400 ms não eleva nada. Depois que ela termina, o animation-fill-mode: backwards deixa de valer para o estado final e o CSS normal volta a mandar, então o hover funciona. Foi por isso que este passo usou backwards e não forwards: com forwards, o último quadro da animação ficaria "grudado" no elemento e o :hover nunca mais moveria o card. Quando você precisar de entrada e hover no mesmo elemento, ou usa backwards, ou anima um filho em vez do próprio card.
Passo 8 — hero com entrada e botão que chama atenção¶
index.html (trecho do hero)
HTML
<sectionclass="hero text-center py-5"><divclass="container"><h1class="hero__titulo display-4">Café do Cerrado, torrado em Sinop</h1><pclass="hero__texto lead">
Grãos de produtores de Mato Grosso, torra artesanal e um lugar para ficar.
</p><divclass="hero__acoes"><aclass="btn btn-cafe btn-lg btn-destaque"href="cardapio.html">Ver o cardápio</a></div></div></section>
O <div class="container"> é o mesmo da Aula 04 — ele fica: só a classe btn-destaque é novidade. Animar os filhos do container, e não o container, evita que a entrada disputa espaço com o alinhamento.
css/estilo.css
CSS
.hero__titulo,.hero__texto,.hero__acoes{animation:surgirvar(--duracao-longa)var(--curva-entrada)backwards;}.hero__titulo{animation-delay:80ms;}.hero__texto{animation-delay:200ms;}.hero__acoes{animation-delay:320ms;}/* Pulsa três vezes depois de 1,5 s e para: sem movimento infinito, sem botão de pausa */.btn-destaque{animation:pulsar2.4sease-in-out1.5s3both;}@keyframespulsar{0%,100%{transform:scale(1);box-shadow:0000rgba(217,158,51,0.55);}50%{transform:scale(1.04);box-shadow:0000.9remrgba(217,158,51,0);}}
O both combina backwards e forwards: durante 1,5 s de espera o botão fica no primeiro quadro (tamanho normal), e ao fim ele permanece no último. Sem isso, o botão dá um salto no início e outro no fim.
Para ver funcionando: inspecione o botão, troque data-estado="pronto" por data-estado="enviando" no painel Elements e observe. Na Unidade 2 esse atributo passa a ser trocado por JavaScript no envio do formulário, e o aria-live da Aula 06 anunciará o resultado.
Os dois traços têm o mesmo comprimento, mas direções opostas: o da esquerda começa em (88, 12) e anda para trás até (4, 12); o da direita começa em (152, 12) e anda para a frente. Como o desenho do traço sempre segue o sentido do caminho, os dois nascem no centro e crescem para fora — simetria de graça, sem uma linha de CSS a mais.
Os dois caminhos horizontais medem 84 unidades cada — por isso stroke-dasharray: 84. Confirme com getTotalLength() no Console, como no 🔬 Investigue da §7.5, antes de reaproveitar isso com outro desenho.
Para testar sem mexer no sistema operacional: DevTools → Ctrl+Shift+P → "Show Rendering" → em Emulate CSS media feature prefers-reduced-motion, escolha reduce. Recarregue. Tudo deve continuar visível, legível e funcional — só sem movimento.
Botões: passe o mouse e depois navegue só por Tab. Todo botão sobe no hover, afunda no clique e mostra o anel de foco na cor de destaque ao chegar por teclado. Um botão disabled não reage.
Menu: o sublinhado cresce da esquerda ao entrar e recolhe pela direita ao sair; o item da página atual fica sublinhado permanentemente, sem mouse nenhum.
Cards: recarregue cardapio.html — os cards entram em cascata, de 80 em 80 ms. No hover, o card sobe, a foto amplia sem vazar da borda e os ícones mudam de cor. Chegando por Tab ao link interno, o card reage igual.
Logotipo: passe o mouse na marca da navbar — os três fios de vapor sobem em sequência. O leitor de tela não anuncia "Café Cerrado" duas vezes (confira na aba Elements que o <svg> tem aria-hidden="true").
Ícones: no painel Elements, confirme que cada <use href="#a05-icone-…"> resolve; um ícone invisível quase sempre é id digitado errado ou sprite depois do uso.
Hero: ao carregar a página inicial, título, texto e botão entram em sequência; 1,5 s depois o botão pulsa três vezes e para de vez.
Spinner: troque data-estado para enviando no DevTools e veja o círculo girar no lugar do texto.
Divisor: recarregue e observe as duas linhas se desenhando do centro para fora, com o grão surgindo no meio ao final.
Movimento reduzido: com prefers-reduced-motion: reduce emulado, nada some, nada trava, nada se move.
Desempenho: com Paint flashing ligado, o hover dos cards deve pintar pouca área. No painel Performance com CPU 4×, a rolagem mantém a taxa de quadros estável.
Resultado esperado: o Café Cerrado continua com a mesma estrutura e o mesmo layout Bootstrap, mas agora responde ao usuário: cada elemento clicável tem retorno visual (mouse e teclado), o cardápio chega em cascata, a marca é vetorial e nítida em qualquer tela, os ícones acompanham a cor do tema e ninguém que prefira menos movimento é prejudicado. Faça o commit com a mensagem Aula 05: animacoes, microinteracoes e SVG e o push para o GitHub Pages.
A1. Qual a diferença entre transition e animation? Dê um exemplo em que só a segunda serve.
A2. Escreva, na forma abreviada, uma transição de transform e box-shadow com 250 ms e curva ease-out.
A3. Cite dois motivos concretos para nunca usar transition: all.
A4. Em qual seletor a transition deve ser declarada: .btn ou .btn:hover? Explique o que acontece se você errar.
A5. Diferencie ease-in de ease-out e diga qual usar para um painel que entra na tela.
A6. Qual a diferença de resultado entre transform: translateX(40px) rotate(90deg) e transform: rotate(90deg) translateX(40px)? Desenhe as duas no papel.
A7. O que faz transform-origin: left combinado com scaleX(0) → scaleX(1)? Em que passo da Mão na massa isso apareceu?
A8. Escreva um @keyframes chamado deslizar-direita que leve o elemento de translateX(-100%) e opacidade 0 até translateX(0) e opacidade 1.
A9. Explique animation-fill-mode nos quatro valores. Qual usar em uma entrada com animation-delay?
A10. Por que transform e opacity são as propriedades mais baratas de animar? Cite as etapas do pipeline que cada grupo dispara.
A11. Reescreva de forma performática: .painel { left: -300px; transition: left 300ms; } e .painel.aberto { left: 0; }.
A12. Escreva o bloco prefers-reduced-motion completo e explique por que ele usa 0.01ms em vez de 0 e por que usa !important.
A13. O que significa viewBox="0 0 200 100"? O que muda se você trocar por viewBox="0 0 100 50" mantendo os desenhos?
A14. Quando usar <img src="logo.svg"> e quando usar SVG inline? Dê um caso de cada.
A15. Qual a diferença entre fill e stroke? O que currentColor resolve?
A16. Um <svg> mostra um gráfico com dados que não aparecem em nenhum texto da página. Quais atributos ele precisa? E se fosse um ícone ao lado da palavra "Telefone"?
A17. Por que todo <svg> desta aula tem focusable="false"?
A18. O que fazem stroke-dasharray e stroke-dashoffset juntos? Como descobrir o valor correto para um caminho qualquer?
B1. Construa um botão com os cinco estados animados: normal, :hover (elevação), :active (pressionado), :disabled (opaco e sem movimento) e :focus-visible (anel de foco). Nenhum estado pode ser distinguível apenas pela cor.
Resultado esperado: o botão sobe no hover, afunda no clique, mostra o anel de foco na cor de destaque ao chegar por Tab e, desabilitado, fica opaco com cursor not-allowed — e os cinco estados continuam distinguíveis numa captura de tela em preto e branco.
Dica
Declare a transition no estado base. No :disabled, zere transform e box-shadow e use cursor: not-allowed. Para o anel, outline: 3px solid com outline-offset é mais simples que box-shadow e não some em modo de alto contraste.
B2. Faça os cards do cardápio reagirem em três camadas ao mesmo tempo: o card sobe, a imagem amplia dentro da moldura e um selo ("Novidade") desliza de cima para baixo. Tudo reversível e replicado em :focus-within.
Resultado esperado: ao passar o mouse ou focar o link interno, o card sobe 6 px, a foto cresce 6 % sem vazar da borda arredondada e o selo entra pelo topo em 250 ms; ao sair, tudo volta na mesma velocidade.
Dica
Moldura com position: relative; overflow: hidden. O selo é position: absolute; top: 0; transform: translateY(-100%) no estado base e translateY(0) no hover. Anime só transform nos três elementos.
B3. Refaça o menu do seu projeto autoral com o sublinhado animado do Passo 3, incluindo o estado permanente do item atual via [aria-current="page"] e o retorno por teclado via :focus-visible.
Resultado esperado: em cada uma das três páginas, o item correspondente já aparece sublinhado ao carregar; os demais sublinham ao passar o mouse ou ao receber foco; a saída recolhe pelo lado oposto da entrada.
Dica
O ::after precisa de content: "" e position: absolute dentro de um .nav-link com position: relative. Troque transform-origin entre right (base) e left (ativo) para o efeito de "vem da esquerda, some pela direita".
B4. Desenhe do zero, à mão, um ícone SVG 24×24 relacionado ao seu projeto autoral, usando pelo menos três formas diferentes (rect, circle, path, polygon ou line). Publique-o como <symbol> no sprite e use-o em dois lugares da página.
Resultado esperado: o ícone é reconhecível em 20 px e em 100 px, herda a cor do texto via currentColor e aparece duas vezes na página a partir de uma única definição.
Dica
Comece pelo viewBox="0 0 24 24" e desenhe em papel quadriculado numerando de 0 a 24 — as coordenadas saem prontas. Use fill="currentColor" e nada de width/height dentro do <symbol>: quem define tamanho é o CSS da classe .icone.
B5. Crie um esqueleto de carregamento (skeleton) para três cards do cardápio: retângulos cinza com um brilho passando, que dão lugar ao conteúdo real quando a classe .carregado é adicionada ao contêiner (adicione-a à mão no DevTools).
Resultado esperado: três blocos cinza "brilham" continuamente; ao acrescentar .carregado no contêiner pelo painel Elements, os esqueletos desvanecem e os cards reais surgem com a animação surgir.
Dica
O brilho é um background: linear-gradient(90deg, #e9e2d9 25%, #f7f3ee 50%, #e9e2d9 75%) com background-size: 200% 100% animado pela keyframe brilho da §4.2. A troca usa a dupla opacity + visibility da §2.4 — não use display: none.
B6. Implemente o efeito de "desenhar o traço" na assinatura do rodapé: escreva o nome do café em <path> (ou use um traço decorativo) e faça-o se desenhar em 1,2 s ao carregar a página, permanecendo depois.
Resultado esperado: o traço aparece progressivamente da esquerda para a direita e fica visível ao final; com prefers-reduced-motion: reduce, ele já aparece completo, sem animação.
Dica
Meça o caminho com getTotalLength() no Console e use o número em stroke-dasharray e stroke-dashoffset. animation-fill-mode: forwards mantém o traço desenhado. O bloco prefers-reduced-motion do Passo 11 já cuida do resto, porque com duração de 0,01 ms o traço chega instantaneamente ao offset zero.
B7. Compare medindo: crie duas versões de um mesmo painel lateral, uma animando left e outra animando transform: translateX. Grave as duas no painel Performance com CPU 4× e registre a diferença.
Resultado esperado: uma tabela de duas linhas no README.md com o tempo total gasto em Layout, Paint e Composite em cada versão, e uma frase explicando por que os números são o que são.
Dica
No painel Performance, use o botão de gravar, dispare a animação, pare e olhe o resumo por cores (roxo é Layout, verde é Paint, cinza-claro é Composite). Grave cada versão separadamente e com a mesma duração de gravação, senão os números não são comparáveis.
C1.Cartaz animado do Café Cerrado. Construa, em um arquivo novo promocao.html, um cartaz de página inteira para a promoção "Hora do café — 15 h às 17 h", com: fundo em gradiente que se desloca lentamente; título entrando escalonado letra a letra ou palavra a palavra; um relógio em SVG desenhado por você, com o ponteiro girando uma volta completa em 6 s; três cards de produto entrando em cascata; e um botão de chamada que pulsa três vezes e para. Requisitos técnicos: nenhuma animação de propriedade de layout; toda animação com propósito declarado em comentário; prefers-reduced-motion respeitado; o cartaz continua legível e navegável por teclado com o movimento desligado. Comece pelo relógio e pelo título; o resto pode ser terminado depois.
Dica
O gradiente animado é background: linear-gradient(135deg, …) com background-size: 200% 200% e uma keyframe deslocando background-position — é um único elemento grande, o custo de Paint é aceitável. O ponteiro do relógio é um <line> com transform-origin no centro do mostrador (cuidado: em SVG a origem padrão é o canto do viewBox, então declare transform-origin: 50% 50% no CSS). Para as palavras do título, envolva cada uma em um <span> com style="--i: 1" e reaproveite o calc() do Passo 7.
C2.Auditoria de movimento em um site real. Escolha um site comercial brasileiro que você usa (banco, loja, prefeitura). Liste cinco animações que ele faz, dizendo para cada uma: o que ela comunica, qual propriedade provavelmente está sendo animada (verifique no DevTools) e se ela sobrevive a prefers-reduced-motion: reduce emulado. Conclua com uma recomendação técnica de melhoria.
Dica
Na aba Elements, selecione o elemento animado e olhe o painel Computed com o filtro "transition" ou "animation". A aba Rendering emula a preferência de movimento reduzido; recarregue depois de emular, porque muitos sites só leem a preferência no carregamento.
Um colega mandou o CSS abaixo dizendo "não anima nada, o navegador deve estar bugado". Não está: o navegador está fazendo exatamente o que foi pedido. Há cinco erros conceituais, cada um de um tipo diferente visto hoje. Encontre e corrija todos sem reescrever do zero — a intenção do colega tem que sobreviver.
Todo projeto autoral desta trilha precisa de uma marca. É tentador baixar um PNG genérico de 80 KB que fica borrado na tela do celular. Você vai desenhar a sua — no código, com formas geométricas — e ela vai pesar menos que uma linha de texto desta apostila.
Critérios de pronto
O logotipo é um <svg> inline com viewBox, composto de pelo menos quatro formas (rect, circle, ellipse, polygon, line ou path), sem nenhuma imagem bitmap.
O arquivo .svg correspondente (salvo em img/) tem menos de 2 KB.
Todas as cores usam currentColor ou variáveis do :root, e trocar color no CSS muda a marca inteira.
A marca aparece nítida em 24 px (favicon do navegador) e em 200 px (rodapé), sem editar o código.
Está no cabeçalho das três páginas, com a decisão de acessibilidade explicada em comentário: aria-hidden="true" se houver texto ao lado, role="img" com nome acessível se estiver sozinha.
Uma microinteração no :hover ou :focus-visible (troca de fill, rotação de uma parte, traço que se desenha).
Pistas
Comece pelo viewBox="0 0 64 64" e esboce no papel quadriculado: cada quadradinho é uma unidade, e as coordenadas saem prontas.
Formas simples combinadas superam desenhos elaborados: dois círculos e um retângulo já formam uma xícara, uma folha ou um pino de mapa.
Para o favicon, <link rel="icon" href="img/logo.svg"> funciona em todos os navegadores modernos e dispensa gerar .ico.
Antes de commitar, passe o arquivo pelo svgomg.net e compare os bytes antes e depois — anote os dois números no README.md.
"SVG é mais leve" é uma frase que todo mundo repete e quase ninguém mediu. Você vai medir — e vai descobrir que a frase tem exceções importantes. O objetivo não é provar que SVG ganha: é aprender a decidir com números.
Critérios de pronto
Uma tabela no README.md compara, para três conteúdos diferentes (um ícone simples, um logotipo com texto e uma fotografia), quatro colunas no máximo: conteúdo, peso em PNG, peso em SVG e vencedor.
Cada peso foi medido na aba Network do DevTools (coluna Size, com cache desativado), não estimado.
Um parágrafo explica por que a fotografia inverte o resultado, citando o que cada formato armazena.
Um segundo parágrafo mede o efeito da compressão do servidor: compare o Size (transferido) com o Content (descompactado) do SVG na aba Network e explique a diferença.
O ícone testado foi otimizado com svgomg.net, e o README.md registra o peso antes e depois da otimização.
Pistas
Na aba Network, marque Disable cache e recarregue com Ctrl+Shift+R, senão você mede zero byte.
Para gerar o PNG do mesmo ícone em três densidades (1×, 2×, 3×), qualquer editor serve; some os três pesos, porque é isso que um site responsivo precisaria entregar.
SVG é texto: servidores comprimem texto com gzip ou brotli antes de enviar. O GitHub Pages faz isso automaticamente — daí a diferença entre as colunas Size e Content.
Uma fotografia em SVG só cabe se o pixel virar uma forma; pense em quantas formas tem uma foto de 12 megapixels.
Para ir além: repita a medição com o throttling de rede em "Slow 4G" e registre o tempo até a imagem aparecer, não só o peso.
⭐⭐⭐
⭐⭐⭐ Um gráfico SVG do Café Cerrado, desenhado à mão¶
svganimacaoacessibilidadecss
A página inicial do Café Cerrado vai ganhar uma seção "O café em números" com três indicadores. A tentação é usar uma biblioteca de gráficos com 90 KB de JavaScript. Você não pode: a Unidade 1 é sem JavaScript. Vai desenhar o gráfico em SVG puro, animá-lo com CSS e — a parte difícil — torná-lo compreensível para quem não vê a tela.
Critérios de pronto
Um gráfico de barras ou um anel de progresso (donut) desenhado em SVG, com pelo menos três séries de dados reais do projeto.
As barras (ou o anel) crescem do zero ao valor final ao carregar a página, usando transform: scaleY com transform-origin correto, ou stroke-dasharray/stroke-dashoffset.
Cada valor aparece também como texto legível dentro ou ao lado do gráfico — nada de informação existir só na forma.
O <svg> tem role="img" e um nome acessível que resume o gráfico em uma frase com os números ("Vendas por categoria: café coado 52%, espresso 31%, doces 17%").
Uma tabela HTML equivalente existe na página, visualmente discreta mas presente no DOM (não escondida com display: none), com os mesmos números.
Nenhuma cor é a única portadora de significado: cada série tem também rótulo textual.
Com prefers-reduced-motion: reduce, o gráfico aparece completo e correto, sem crescer.
Uma seção no README.md explica a escolha de escala: qual valor corresponde a 100 % da altura e por quê.
Pistas
Barras são <rect> com height fixo no viewBox e transform: scaleY(var(--valor)) com transform-origin: bottom — anime a escala, nunca a altura.
Para o donut, um <circle> com fill: none, stroke-width grosso e stroke-dasharray igual à circunferência (2 × π × raio) transforma o offset em porcentagem direta.
A tabela equivalente pode ficar dentro de um <details> com <summary>Ver os dados em tabela</summary> — visível para quem quiser, presente para o leitor de tela, e sem display: none.
Escolha a escala pelo maior valor, não pela soma: um gráfico em que a maior barra ocupa 40 % da altura desperdiça o espaço e engana o olho.
Teste o nome acessível na aba Elements → painel Accessibility → campo Computed name, antes de considerar pronto.
Para ir além: meça o contraste entre cada cor de série e o fundo (WebAIM Contrast Checker) e ajuste a paleta até todas passarem em 3:1 — é exatamente o que a próxima aula vai cobrar do site inteiro.
Parte 1 — Leitura (15 min). QUEIRÓS e PORTELA, seções sobre a camada de apresentação avançada. MDN: Usando transições CSS, Usando animações CSS e a página inicial de SVG: Scalable Vector Graphics (links em "Para aprofundar"). Anote uma coisa que a MDN explica e esta aula não.
Parte 2 — Entrega (40 min). Aplique ao seu projeto autoral a Mão na massa completa, com estes seis requisitos:
Três microinterações com transição: links do menu, botões e elevação dos cards — cada :hover com seu :focus-visible ou :focus-within.
Uma animação com @keyframes e propósito declarado em comentário (entrada do título, cascata dos cards ou botão de chamada limitado a três repetições).
SVG inline em pelo menos dois pontos: o logotipo desenhado por você com formas básicas e ao menos três ícones em um sprite <symbol>/<use>.
Ao menos um SVG reagindo ao :hovere ao :focus-visible (troca de fill, rotação ou traço que se desenha).
O bloco prefers-reduced-motion como última regra do css/estilo.css.
No README.md, uma seção "Movimento" com três linhas: o que cada animação do projeto comunica ao usuário.
Critério de pronto: os seis itens presentes; emular prefers-reduced-motion: reduce no DevTools não esconde nem quebra nada; nenhuma animação de width, height, top, left ou margin; nenhum transition: all; o site continua funcionando publicado no GitHub Pages.
Parte 3 — Animação que ajuda e animação que atrapalha (5 min). Em docs/animacao.md, anote o endereço de um site com movimento bem empregado e de outro com movimento excessivo, explicando tecnicamente a diferença: o que cada animação comunica, a duração aproximada e a propriedade animada (verifique no DevTools). Se puder, compare com outra pessoa que esteja estudando.
web.dev — Learn CSS, módulos Transitions e Animations: https://web.dev/learn/css — demonstrações interativas de cada curva de tempo.
W3C — Scalable Vector Graphics (SVG) 2: https://www.w3.org/TR/SVG2/ — a especificação; use como referência pontual, não como leitura linear.
W3C — WCAG 2.2, critérios 2.2.2 e 2.3.1: https://www.w3.org/WAI/WCAG22/quickref/ — filtre pelos números para ler o texto normativo sobre movimento e flashes.
Bootstrap Icons: https://icons.getbootstrap.com/ — biblioteca MIT que combina com o framework escolhido pelo Café Cerrado; copie o SVG e cole no seu sprite.
SVGOMG: https://svgomg.net/ — otimizador de SVG no navegador; use antes de commitar qualquer ícone baixado.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — camada de apresentação avançada (se você tem acesso a uma biblioteca virtual pela sua instituição).
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — capítulo sobre elementos gráficos em interfaces.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — front-end e organização de assets.
Seu site agora responde ao usuário e tem identidade visual própria, desenhada em código. Falta a camada que decide se ele serve para todo mundo. Na próxima aula você fecha a Unidade 1 com acessibilidade e ARIA: skip link, foco visível, contraste medido com números, aria-expanded no menu, aria-live no formulário e uma auditoria ao vivo com o Lighthouse. É também quando aparece o Marco 1 completo — e várias decisões que você tomou hoje (aria-hidden nos ícones, focusable="false", :focus-visible, movimento reduzido) já entram nele.
Explicar o que é acessibilidade web, quem se beneficia dela e o que a legislação brasileira exige de sites comerciais e públicos.
Relacionar os quatro princípios das WCAG (perceptível, operável, compreensível, robusto) a decisões concretas de HTML e CSS, distinguindo os níveis A, AA e AAA.
Descrever como uma tecnologia assistiva lê a página pela árvore de acessibilidade, identificando papel, nome acessível, estado e valor de cada elemento no DevTools.
Medir contraste de cores com números e corrigir uma paleta reprovada, incluindo o indicador de foco.
Tornar o site inteiramente operável por teclado: ordem de foco coerente, foco visível, tabindex usado corretamente e link de salto para o conteúdo.
Aplicar ARIA onde o HTML nativo não basta — aria-label, aria-labelledby, aria-describedby, aria-expanded, aria-controls, aria-current, aria-hidden e regiões aria-live — e justificar quando não usar ARIA.
Auditar o projeto com Lighthouse, WAVE e teste manual de teclado, alcançando nota de acessibilidade ≥ 90 e entendendo por que essa nota não é prova de site acessível.
[ ] Repositório cafe-cerrado publicado no GitHub Pages, com index.html, cardapio.html e contato.html.
[ ] Bootstrap 5.3 via CDN e css/estilo.css depois dele; landmarks header/nav/main/footer (Aula 03) e grid responsivo (Aula 04).
[ ] Microinterações, logotipo em SVG inline, sprite de ícones e bloco prefers-reduced-motion (Aula 05).
[ ] Seu projeto autoral no mesmo estágio, com README.md justificando a escolha do framework.
[ ] Chrome ou Firefox com DevTools; hoje você usa Lighthouse, o painel Accessibility da aba Elements e a aba Rendering.
[ ] Opcional, mas recomendado: NVDA instalado (Windows, gratuito), o VoiceOver do macOS/iOS, ou o TalkBack do seu Android.
Na aula passada o Café Cerrado ganhou movimento e desenho vetorial: transições em tudo o que é clicável, cards em cascata, logotipo em SVG e respeito à preferência de movimento reduzido. Você já tomou, sem perceber, três decisões de acessibilidade — aria-hidden nos ícones decorativos, focusable="false" nos <svg> e :focus-visible nos botões. Hoje essas decisões deixam de ser intuição e viram método: você vai medir o que o site entrega a quem não usa mouse, não enxerga a tela ou não distingue cores, corrigir o que estiver quebrado e fechar a Unidade 1 com a auditoria do Lighthouse. Esta é também a aula que abre o Marco 1.
Acessibilidade web (abreviada como a11y) é a prática de construir sites que qualquer pessoa consegue usar — inclusive quem navega com leitor de tela, apenas teclado, comandos de voz, ampliador de tela ou dispositivos de apontamento adaptados.
A pergunta que a maioria dos estudantes faz na primeira aula do tema é "quantas pessoas realmente precisam disso?". O IBGE, no Censo Demográfico, contabiliza milhões de brasileiros com alguma deficiência — mas o número não é o argumento mais forte. O argumento mais forte é este:
Tipo de limitação
Exemplo
Quem é
Permanente
Cegueira, surdez, ausência de um braço, dislexia
Uma parcela grande e constante do público
Temporária
Braço engessado, conjuntivite, otite, olho dilatado após exame
Você, algumas vezes na vida
Situacional
Sol forte na tela, ambiente barulhento, criança no colo, internet lenta
Você, hoje, várias vezes
Um site que funciona com uma mão só serve tanto para quem perdeu um braço quanto para quem está segurando o café. Um vídeo com legenda serve tanto para quem é surdo quanto para quem está no ônibus sem fone. Acessibilidade não é um recurso extra para um grupo pequeno: é a diferença entre um site que funciona no mundo real e um que só funciona no seu notebook.
🧠 Você sabia?
Chama-se efeito da rampa de calçada (curb-cut effect). As rampinhas nas esquinas foram exigidas por ativistas cadeirantes nos Estados Unidos nos anos 1970, contra a resistência de prefeituras que as consideravam um gasto para poucos. Hoje quem mais usa rampa de calçada é quem empurra carrinho de bebê, carrinho de compras, mala de rodinha ou entrega de aplicativo. O mesmo aconteceu na web: a legenda de vídeo foi criada para pessoas surdas e virou padrão de consumo em qualquer lugar barulhento; a navegação por teclado foi criada para quem não usa mouse e virou a ferramenta preferida de quem programa. O acrônimo a11y, aliás, é um numerônimo: "a" + as 11 letras do meio de accessibility + "y".
A Lei Brasileira de Inclusão (Lei nº 13.146/2015), no artigo 63, determina que é obrigatória a acessibilidade nos sites mantidos por empresas com sede ou representação comercial no país e por órgãos do governo, segundo as melhores práticas e diretrizes de acessibilidade adotadas internacionalmente. O Decreto nº 5.296/2004 já exigia o mesmo dos portais públicos, e o governo federal mantém o eMAG (Modelo de Acessibilidade em Governo Eletrônico), que é a leitura brasileira das WCAG.
Traduzindo para a sua vida profissional: quando você entregar um sistema para uma prefeitura, um hospital, um banco ou qualquer empresa com CNPJ, acessibilidade não é um "extra que o cliente pode cortar do orçamento". É requisito legal, e cabe a você avisá-lo disso — por escrito, de preferência.
A mesma semântica que faz o leitor de tela entender a página faz o buscador entender a página. A mesma estrutura de títulos que ajuda quem navega por atalhos ajuda quem lê no celular. O mesmo contraste que serve a quem tem baixa visão serve a quem está no sol. Sites acessíveis costumam ser mais rápidos, melhor ranqueados e mais fáceis de manter — não por mágica, mas porque exigem HTML bem escrito, e HTML bem escrito é bom para tudo.
As WCAG (Web Content Accessibility Guidelines), do W3C, são o padrão mundial. A versão vigente é a 2.2. Elas se organizam em quatro princípios, lembrados pelo acrônimo POUR:
Princípio
Pergunta-chave
Exemplos práticos
Perceptível
A pessoa consegue perceber o conteúdo?
alt em imagens, contraste suficiente, legenda em vídeo
Operável
Consegue operar a interface?
Tudo funciona por teclado, foco visível, sem limite de tempo
Cada princípio se desdobra em diretrizes, e cada diretriz em critérios de sucesso numerados (1.1.1, 1.4.3, 2.4.7…) e classificados em três níveis:
A — mínimo. Sem isso, há gente que simplesmente não usa o site.
AA — o nível exigido pela maioria das leis e contratos. É a nossa meta nesta trilha.
AAA — máximo. Nem sempre alcançável para todo tipo de conteúdo; o próprio W3C não recomenda exigi-lo como política geral.
Os critérios que mais aparecem no seu trabalho:
Critério
Nível
O que exige
1.1.1 Conteúdo não textual
A
Toda imagem tem alternativa textual
1.3.1 Informação e relações
A
Estrutura visual também existe no código (títulos, listas, tabelas)
1.4.3 Contraste (mínimo)
AA
4,5:1 para texto normal, 3:1 para texto grande
1.4.11 Contraste não textual
AA
3:1 para bordas de campo, ícones e indicador de foco
2.1.1 Teclado
A
Toda função é operável por teclado
2.4.1 Ignorar blocos
A
Link de salto ou landmarks para pular o menu
2.4.7 Foco visível
AA
Dá para ver onde o foco está
3.3.2 Rótulos ou instruções
A
Todo campo de formulário tem rótulo
4.1.2 Nome, função, valor
A
Cada controle expõe o que é, como se chama e em que estado está
📌 Vale gravar
Decore a diferença entre os três níveis e os quatro princípios do POUR, e saiba dizer a qual princípio pertence cada prática que você aplicou no projeto. "Contraste" é perceptível; "foco visível" é operável; "idioma declarado" é compreensível; "ARIA correto" é robusto.
O leitor de tela não lê o seu HTML. Ele lê a árvore de acessibilidade, uma estrutura que o navegador constrói a partir do DOM, descartando o que é puramente visual e expondo, para cada nó que resta, quatro informações:
Informação
Pergunta que responde
Exemplo
Papel (role)
O que é isto?
botão, link, campo de texto, cabeçalho, navegação
Nome acessível
Como isto se chama?
"Enviar mensagem", "Cardápio", "Café Cerrado"
Estado
Em que situação está?
expandido, marcado, desabilitado, página atual
Valor
Que conteúdo carrega?
o texto digitado, a posição de um controle deslizante
Um <button>Enviar</button> chega à árvore com papel "button", nome "Enviar" e estado normal — de graça, sem nenhum atributo extra. Um <div class="botao" onclick="…">Enviar</div> chega com papel "genérico", sem nome, sem estado e sem foco. Visualmente idênticos; funcionalmente, o segundo não existe para quem não vê a tela.
O nome acessível é calculado por uma ordem de precedência que vale a pena guardar:
aria-labelledby (texto de outro elemento) — vence tudo.
aria-label (texto no próprio atributo).
O rótulo nativo: <label for> para campos, o alt para imagens, o conteúdo de texto para botões e links.
title — último recurso, não confiável, não aparece em toque.
🔬 Investigue
Abra o Café Cerrado, pressione F12, vá à aba Elements e selecione o link da marca na navbar. No painel lateral, abra a aba Accessibility. Você verá Computed Properties com o nome acessível calculado e, mais abaixo, a árvore de acessibilidade completa. Repita com: o botão do menu hambúrguer (tem nome?), um ícone SVG dentro de um card (aparece na árvore ou está escondido?) e um campo do formulário de contato. Anote qualquer elemento cujo nome apareça vazio ou como "" — cada um deles é um erro que o Lighthouse vai apontar no Passo 1 da Mão na massa.
A WCAG 1.4.3 exige razão de contraste de 4,5:1 para texto normal e 3:1 para texto grande (a partir de 24 px, ou 18,5 px em negrito). A 1.4.11 exige 3:1 para elementos não textuais que carreguem significado: borda de campo de formulário, ícone que comunica estado e — este é o mais esquecido — o indicador de foco.
A razão de contraste é um número entre 1:1 (mesma cor) e 21:1 (preto sobre branco). Ela é calculada a partir da luminância relativa de cada cor, o que significa que você não consegue estimar no olho: a mesma cor de destaque sobre o marrom da navbar e sobre o creme do conteúdo parece igualmente "legível" na sua tela calibrada, e nos dois casos ela reprova por motivos diferentes.
A paleta do Café Cerrado — a mesma do :root desde a Aula 02, sem uma cor a mais — medida par a par:
Uso
Texto sobre fundo
Razão
Situação
Texto do corpo
#2b2118 sobre #fdfaf6
15,1:1
Passa AAA
Rodapé
#ffffff sobre #4a3325
11,7:1
Passa AAA
Texto secundário
#5c4b3c sobre #fdfaf6
8,0:1
Passa AAA
Navbar
#ffffff sobre #6f4e37
7,4:1
Passa AAA
Link no corpo
#6f4e37 sobre #fdfaf6
7,2:1
Passa AAA
Botão de destaque
#ffffff sobre #c2703d
3,7:1
Reprova (texto normal exige 4,5:1)
Anel de foco na navbar
#c2703d sobre #6f4e37
2,0:1
Reprova (1.4.11 exige 3:1)
Cinza "discreto"
#9e9e9e sobre #ffffff
2,7:1
Reprova
Leia a tabela de baixo para cima, porque é ali que está a lição. As cinco primeiras linhas passam com folga: marrom escuro sobre creme e branco sobre marrom são combinações seguras, e foi por isso que ninguém percebeu nada de errado nas Aulas 02 a 05.
As três últimas são o erro mais comum entre quem está começando. O botão de destaque que você escreveu na Aula 02 (background: var(--cor-destaque) com texto branco) reprova por pouco — 3,7:1 onde a norma pede 4,5:1 — e "por pouco" não existe em acessibilidade. O anel de foco que você escreveu na Aula 05 com var(--cor-destaque) passa sobre o fundo claro do conteúdo (3,6:1, e o critério 1.4.11 pede 3:1) e reprova em cima da navbar marrom, que é justamente onde o Tab começa. E o cinza-claro que os tutoriais chamam de "texto secundário" é o erro de contraste mais comum da web — no Café Cerrado ele nem existe, porque --cor-texto-suave foi escolhido escuro o bastante.
Nenhum desses três defeitos é visível a olho nu. Todos os três aparecem no Lighthouse e no seletor de cor do DevTools. O Passo 6 do Mão na massa corrige os dois primeiros.
Três formas de medir:
DevTools. Inspecione o texto, clique no quadradinho de cor da propriedade color no painel Styles: o seletor mostra a razão de contraste com dois marcadores (AA e AAA) e desenha uma linha na paleta indicando até onde você pode escurecer.
WebAIM Contrast Checker (webaim.org/resources/contrastchecker): cole os dois hexadecimais e leia o veredito.
Lighthouse, que aponta todos os pares reprovados de uma vez, com o seletor CSS de cada um.
🔬 Investigue
Na aba Rendering do DevTools (Ctrl+Shift+P → "Show Rendering"), procure Emulate vision deficiencies e escolha, uma de cada vez: Blurred vision, Protanopia (dificuldade com vermelho) e Achromatopsia (sem percepção de cor). Navegue pelo seu site em cada modo e responda por escrito: existe alguma informação que some? O botão "disponível" e o botão "esgotado" continuam distinguíveis? O link dentro de um parágrafo ainda é reconhecível como link? Se a resposta a alguma dessas perguntas for "não", você depende de cor como única portadora de significado — o que viola o critério 1.4.1.
Largue o mouse. Sério: tire a mão do mouse e navegue pelo seu site só com Tab (avançar), Shift+Tab (voltar), Enter (ativar links e botões), Espaço (marcar caixas e acionar botões) e as setas (dentro de grupos de rádio e listas). Três perguntas:
Consigo alcançar todos os links, botões e campos?
Vejo onde o foco está, a cada momento?
A ordem do foco segue a ordem visual da página?
Se qualquer resposta for "não", há gente que não usa o seu site — e não é pouca gente: além de quem tem limitação motora, é quem usa leitor de tela, quem opera por voz e quem simplesmente prefere teclado.
/* Crime capital — nunca escreva isto sem substituto *//* *:focus { outline: none; } *//* Correto: um anel de dois tons, visível em fundo claro e em fundo escuro */:focus-visible{outline:3pxsolidvar(--cor-marca-escura);outline-offset:3px;box-shadow:0006pxvar(--cor-superficie);border-radius:2px;}
O anel de dois tons resolve um problema real: um anel de cor única sempre reprova em algum fundo — é exatamente o que a tabela da §5 mostrou sobre o anel da Aula 05. O escuro (--cor-marca-escura, 11,3:1 sobre o fundo claro) aparece nas seções de conteúdo; o halo claro (--cor-superficie, 7,4:1 sobre o marrom) aparece sobre a navbar. Juntos, garantem os 3:1 da 1.4.11 em qualquer lugar do site.
A diferença entre :focus e :focus-visible importa: :focus casa sempre que o elemento recebe foco, inclusive por clique de mouse (é por isso que designers pedem para "tirar aquele contorno"); :focus-visible casa quando o navegador julga que o indicador é útil — na prática, quando o foco veio do teclado. Estilize :focus-visible e o problema estético desaparece sem tirar o retorno de quem precisa.
Valores positivos criam uma ordem paralela e frágil: basta acrescentar um campo esquecido para toda a sequência ficar sem sentido. Se você precisou de tabindex="5", o problema está na ordem do HTML, não no atributo.
Quem navega por teclado passaria pelos oito links do menu em cada página antes de chegar ao conteúdo. O link de salto resolve: é o primeiro elemento do <body>, invisível até receber foco.
HTML
<body><aclass="link-pular"href="#conteudo">Pular para o conteúdo</a><header><navaria-label="Navegação principal">Menu do site</nav></header><mainid="conteudo"tabindex="-1">Conteúdo da página</main></body>
CSS
.link-pular{position:absolute;top:0;left:0;z-index:1100;padding:0.75rem1rem;background-color:var(--cor-marca-escura);/* 11,7:1 com o texto branco */color:var(--cor-superficie);font-weight:600;text-decoration:none;transform:translateY(-120%);transition:transformvar(--duracao-rapida)var(--curva-entrada);}.link-pular:focus{transform:translateY(0);}
Três decisões deliberadas nesse CSS:
transform, não top: -50px. É a lição de desempenho da Aula 05 aplicada: o link é escondido pelo compositor, sem tocar no layout.
:focus, não :focus-visible. Este link só é alcançado por teclado; ele precisa aparecer sempre que receber foco.
tabindex="-1" no <main>. Sem ele, alguns navegadores movem apenas o ponto de partida da tabulação, e o leitor de tela continua narrando de onde estava. Com ele, o foco vai de fato para o conteúdo.
⚠️ Atenção
Nunca esconda o link de salto com display: none ou visibility: hidden: elementos assim não são focáveis, e o link deixa de existir para quem ele foi feito. A técnica correta é tirá-lo da área visível mantendo-o no fluxo — com transform, com clip-path ou posicionando-o fora da tela.
Existem três formas de esconder algo, e elas não são intercambiáveis:
CSS
/* 1. Some para todo mundo: olho e leitor de tela */.escondido{display:none;}/* 2. Visível para o olho, ignorado pelo leitor de tela (só para decoração) *//* <svg aria-hidden="true"> *//* 3. Invisível para o olho, disponível para o leitor de tela */.oculto-visualmente{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;}
A terceira serve para dar título a uma seção que visualmente não precisa dele, ou para completar o texto de um link (Ver detalhes <span class="oculto-visualmente">do Espresso do Cerrado</span>). O Bootstrap 5.3 já traz essa classe com o nome visually-hidden — use a dele se preferir, o efeito é o mesmo.
🔎 Por baixo do capô
Por que clip-path: inset(50%) e não width: 0; height: 0? Porque um elemento de dimensão zero é tratado como inexistente por vários motores e some da árvore de acessibilidade junto com o conteúdo. A receita acima mantém 1 pixel de área real, tira o conteúdo da visão por recorte e impede que o texto quebre linha (white-space: nowrap) — ela existe há mais de uma década e cada linha resolve um bug específico de um navegador. Copie-a inteira; não "otimize".
ARIA (Accessible Rich Internet Applications) é um conjunto de atributos que enriquece o que as tecnologias assistivas enxergam: papéis (role), nomes (aria-label, aria-labelledby), descrições (aria-describedby), estados (aria-expanded, aria-current) e regiões que anunciam mudanças (aria-live).
Se um elemento HTML nativo resolve, use o nativo. <button> já é focável, ativável por Enter e Espaço, anunciado como botão e desabilitável. <div role="button"> exige que você recrie tudo isso à mão — tabindex, tratador de teclado, estado desabilitado — e quase sempre sai pela metade.
HTML
<!-- Errado: reinventa o botão e esquece metade do comportamento --><divclass="btn"role="button"onclick="enviar()">Enviar</div><!-- Certo --><buttonclass="btn btn-cafe"type="submit">Enviar</button>
⚠️ AtençãoARIA ruim é pior que ARIA nenhum. Um role errado não deixa de funcionar: ele mente para o leitor de tela. Um <a role="button"> que na verdade navega faz a pessoa esperar uma ação e receber uma mudança de página. Um aria-expanded="false" que nunca vira true faz o menu parecer permanentemente fechado. Na dúvida entre pôr um atributo ARIA e não pôr, não ponha.
aria-label em elemento que já tem texto visível sobrescreve o texto. Um <a aria-label="Cardápio completo">Cardápio</a> faz quem usa comando de voz dizer "clicar em Cardápio" e nada acontecer, porque o nome real virou outro. Se há texto visível, ele deve fazer parte do nome acessível.
aria-hidden="true" em elemento focável cria um fantasma: o Tab para nele, mas o leitor de tela não tem o que anunciar. Nunca coloque aria-hidden em um <a>, <button> ou <input>, nem em um contêiner que os tenha dentro.
Quando o conteúdo muda sem recarregar a página — uma mensagem de "enviado com sucesso", um contador de resultados de busca, um erro de validação — quem enxerga percebe na hora e quem usa leitor de tela não percebe nada, porque o foco não se moveu. A região viva resolve: o leitor de tela vigia aquele trecho e anuncia o que aparecer nele.
Espera a pessoa parar de digitar/ler e então anuncia
Confirmações, contagem de resultados
aria-live="assertive"
Interrompe imediatamente o que estava sendo lido
Erros graves, perda de dados
aria-atomic="true"
Lê a região inteira, não só o pedaço alterado
Mensagens curtas e completas
Dois atalhos que fazem a mesma coisa com um role: role="status" equivale a aria-live="polite", e role="alert" equivale a aria-live="assertive". Declarar os dois é redundante — escolha um.
Duas regras que decidem se funciona:
A região precisa existir no HTML desde o carregamento, vazia. Se você criar o elemento e o texto ao mesmo tempo, muitos leitores não anunciam nada.
Use com parcimônia. Três regiões assertive competindo transformam o site num pregão.
O formulário do Café Cerrado ganha essa região hoje; ela permanece vazia até a Unidade 2, quando o JavaScript passará a escrever "Mensagem enviada, obrigado" ou "Confira o e-mail informado" dentro dela. Deixar a estrutura pronta agora é o que permite que a mudança lá na frente seja de três linhas.
Leitores de tela oferecem um atalho para saltar entre landmarks — as regiões banner (<header> de página), navigation (<nav>), main (<main>), contentinfo (<footer> de página), search e complementary (<aside>). Se a página tem mais de um <nav>, cada um precisa de nome próprio, senão a lista fica com dois itens chamados "navegação".
HTML
<navaria-label="Navegação principal"><ul><li><ahref="index.html"aria-current="page">Início</a></li><li><ahref="cardapio.html">Cardápio</a></li></ul></nav><footer><navaria-label="Links do rodapé"><ul><li><ahref="contato.html">Fale conosco</a></li><li><ahref="cardapio.html">Cardápio completo</a></li></ul></nav></footer>
Não escreva aria-label="Navegação principal do site": o leitor já anuncia o papel ("navegação"), então repetir a palavra vira "navegação navegação principal do site". O nome deve ser curto e distintivo.
Formulário é onde a acessibilidade mais falha e onde ela mais importa — é o ponto em que a pessoa precisa fazer algo, não só ler.
HTML
<formclass="formulario-contato"action="#"method="post"><fieldset><legendclass="h5">Seus dados</legend><divclass="mb-3"><labelclass="form-label"for="nome">Nome completo</label><inputclass="form-control"type="text"id="nome"name="nome"autocomplete="name"requiredaria-describedby="ajuda-nome"><pclass="form-text"id="ajuda-nome">Como você quer ser chamado no atendimento.</p></div><divclass="mb-3"><labelclass="form-label"for="email">E-mail</label><inputclass="form-control"type="email"id="email"name="email"autocomplete="email"requiredaria-describedby="ajuda-email"><pclass="form-text"id="ajuda-email">Usamos apenas para responder a esta mensagem.</p></div></fieldset><fieldsetclass="mt-4"><legendclass="h5">Assunto</legend><divclass="form-check"><inputclass="form-check-input"type="radio"name="assunto"id="assunto-reserva"value="reserva"checked><labelclass="form-check-label"for="assunto-reserva">Reserva de mesa</label></div><divclass="form-check"><inputclass="form-check-input"type="radio"name="assunto"id="assunto-evento"value="evento"><labelclass="form-check-label"for="assunto-evento">Evento no espaço</label></div></fieldset><divclass="mb-3 mt-4"><labelclass="form-label"for="mensagem">Mensagem</label><textareaclass="form-control"id="mensagem"name="mensagem"rows="5"required></textarea></div><buttonclass="btn btn-cafe btn-enviar"type="submit"data-estado="pronto">Enviar mensagem</button><pclass="status-envio"id="status-envio"aria-live="polite"aria-atomic="true"></p></form>
O que cada decisão entrega:
<label for> ligado ao id: dá nome ao campo e aumenta a área de clique — tocar no rótulo foca o campo, o que ajuda quem tem tremor ou usa a tela pequena.
<fieldset> + <legend>: agrupa campos relacionados e dá um nome ao grupo. Em um grupo de rádios isso é obrigatório: sem legend, o leitor anuncia "Reserva de mesa, botão de opção, 1 de 2" sem nunca dizer do que é a escolha.
autocomplete: além de poupar digitação, é um critério WCAG (1.3.5). Para quem tem dificuldade motora ou cognitiva, o preenchimento automático é a diferença entre concluir e desistir.
required: aciona a validação nativa do navegador (Aula 03), que já é acessível e traduzida.
aria-describedby: liga o texto de ajuda ao campo, de modo que ele seja lido depois do rótulo. Na Unidade 2, o mesmo atributo passará a apontar também para a mensagem de erro.
Região aria-live no fim: pronta para receber o resultado do envio.
Duas coisas que não aparecem no código acima, de propósito:
placeholder como rótulo. O texto do placeholder some quando a pessoa começa a digitar, tem contraste baixo por padrão e nem todo leitor de tela o anuncia. Ele serve como exemplo de formato, nunca como nome do campo.
Asterisco vermelho sozinho para "obrigatório". Cor não pode ser a única portadora de significado (critério 1.4.1). Ou escreva "(opcional)" nos campos que não são obrigatórios, ou acrescente a palavra ao rótulo.
E o leitor de tela, que não é uma quinta ferramenta e sim a prova real: NVDA (gratuito, Windows), VoiceOver (macOS/iOS, já instalado) ou TalkBack (Android). Feche os olhos por dois minutos e tente encontrar o preço do espresso no seu próprio site.
⚠️ AtençãoFerramentas automáticas detectam de 30 % a 40 % dos problemas de acessibilidade. Nota 100 no Lighthouse não significa "site acessível": significa "os erros que uma máquina consegue detectar foram resolvidos". Nenhuma ferramenta sabe dizer se o seu alt="imagem1.jpg" descreve a foto, se a ordem do foco faz sentido ou se o rótulo "Clique aqui" é útil. O teste de teclado e o leitor de tela são insubstituíveis — e são justamente os itens do Marco 1 que valem a pena conferir você mesmo, com calma, em vez de confiar só na nota do Lighthouse.
🧠 Você sabia?
A WebAIM publica todo ano o relatório The WebAIM Million, uma varredura automática da página inicial de um milhão de sites. Em todas as edições recentes, mais de 95 % das páginas apresentaram falhas de WCAG detectáveis por máquina — e os erros mais comuns são sempre os mesmos quatro: contraste insuficiente, alt ausente, link sem texto discernível e campo de formulário sem rótulo. Ou seja: os quatro erros que você vai corrigir hoje, em uma tarde, colocam o seu site à frente da esmagadora maioria da web comercial.
💻 Mão na massa — Auditoria e correção do Café Cerrado¶
Este é um roteiro de auditoria: mede antes, corrige, mede depois. Trabalhe com o site publicado no GitHub Pages aberto em uma aba e o DevTools em outra.
Abra index.html no navegador, F12 → aba Lighthouse → marque apenas a categoria Accessibility, modo Navigation, dispositivo Mobile → Analyze. Anote a nota e tire uma captura de tela. Repita em cardapio.html e contato.html.
Crie a pasta evidencias/ no repositório e salve as três capturas como lighthouse-antes-index.png, lighthouse-antes-cardapio.png e lighthouse-antes-contato.png.
💡 Dica
Rode o Lighthouse em uma janela anônima, sem extensões. Extensões injetam elementos na página e produzem erros que não são seus.
<!DOCTYPE html><htmllang="pt-BR"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1"><title>Café Cerrado — cafeteria artesanal em Sinop</title><metaname="description"content="Cafeteria em Sinop com grãos do Cerrado mato-grossense, torra artesanal e espaço para trabalhar."><linkrel="icon"href="img/logo.svg"></head>
Três verificações:
lang="pt-BR" faz o leitor de tela usar a pronúncia portuguesa. Sem ele, "café" vira "kaf" em voz inglesa.
<title> único e descritivo em cada página. É a primeira coisa anunciada ao abrir a aba. cardapio.html recebe Cardápio — Café Cerrado; contato.html, Contato — Café Cerrado. O padrão é "assunto da página — nome do site".
meta viewport sem restrição de zoom. Se em algum tutorial você copiou user-scalable=no ou maximum-scale=1, apague: impedir o zoom viola o critério 1.4.4 e prejudica quem tem baixa visão.
<body><aclass="link-pular"href="#conteudo">Pular para o conteúdo</a><header><navclass="navbar navbar-expand-lg"aria-label="Navegação principal">Menu do site</nav></header><mainid="conteudo"tabindex="-1"><h1>Café do Cerrado, torrado em Sinop</h1><sectionaria-labelledby="titulo-sobre"><h2id="titulo-sobre">Nossa história</h2><p>Começamos em uma garagem no Setor Comercial, com um torrador de dois quilos.</p></section><sectionaria-labelledby="titulo-destaques"><h2id="titulo-destaques">Destaques da semana</h2><h3>Espresso do Cerrado</h3><p>Dose curta, torra média, notas de chocolate.</p></section></main><footer><navaria-label="Links do rodapé">Links secundários</nav><p>Café Cerrado — Sinop, Mato Grosso.</p></footer></body>
Regras que valem a pena guardar para o Marco 1:
Um <h1> por página, e ele descreve a página, não o site inteiro.
Sem saltos na hierarquia: depois de um h2 vem h3, nunca h4. Títulos são a tabela de conteúdo pela qual quem usa leitor de tela navega; um salto é um capítulo faltando no índice.
Título nunca é escolhido por tamanho. Se você quer um h3 grande, use class="h2" do Bootstrap (que muda só a aparência) e mantenha a tag correta.
Cada <section> recebe nome, via aria-labelledby apontando para o id do seu próprio título. Sem nome, a seção não aparece na lista de regiões.
📌 Vale gravar<section> sem nome acessível não vira landmark: o navegador a trata como um contêiner genérico. É por isso que aria-labelledby aponta para o id do <h2> — o título vira o nome da região, sem repetir texto.
Cole o <a class="link-pular"> como primeiro elemento do <body> das três páginas, e acrescente id="a06-conteudo" tabindex="-1" ao <main> de cada uma. O CSS é o da §6.3.
css/estilo.css
CSS
.link-pular{position:absolute;top:0;left:0;z-index:1100;padding:0.75rem1rem;background-color:var(--cor-marca-escura);/* 11,7:1 com o texto branco */color:var(--cor-superficie);font-weight:600;text-decoration:none;transform:translateY(-120%);transition:transformvar(--duracao-rapida)var(--curva-entrada);}.link-pular:focus{transform:translateY(0);}/* O alvo do salto não deve exibir anel de foco: quem chegou ali já sabe onde está */main:focus{outline:none;}
Teste agora: recarregue a página, pressione Tab uma vez. O link deve descer do topo. Pressione Enter e depois Tab de novo: o próximo foco tem de ser o primeiro link dentro do conteúdo, não o menu.
Na Aula 05 você escreveu outline: 3px solid var(--cor-destaque). Meça os dois fundos em que esse anel aparece:
sobre o fundo claro das seções (#c2703d sobre #fdfaf6): 3,6:1 — passa, porque o critério 1.4.11 exige 3:1 para o indicador de foco;
sobre a navbar marrom (#c2703d sobre #6f4e37): 2,0:1 — reprova. E a navbar é o primeiro lugar onde o Tab chega.
Uma cor sozinha não resolve: não existe tom que fique a 3:1 do creme e a 3:1 do marrom. A saída é o anel de dois tons.
css/estilo.css
CSS
/* Anel de foco global: escuro por dentro, claro por fora, visível em qualquer fundo */:focus-visible{outline:3pxsolidvar(--cor-marca-escura);/* 11,3:1 sobre o fundo claro */outline-offset:3px;box-shadow:0006pxvar(--cor-superficie);border-radius:2px;}/* Dentro da navbar escura, inverte as duas camadas */.navbar:focus-visible{outline-color:var(--cor-superficie);/* 7,4:1 sobre o marrom da marca */box-shadow:0006pxvar(--cor-marca-escura);}
Repare que isto substitui a regra de foco da aula passada. Apague a antiga (.btn:focus-visible, .nav-link:focus-visible, .rodape a:focus-visible): duas regras de foco disputando é a origem clássica de "no meu computador aparece e no seu não".
Rode o Lighthouse de novo e leia os itens de contraste. Para cada par reprovado, decida entre escurecer o texto ou escurecer o fundo — nunca "aumentar a fonte para virar texto grande", que é como se burla a regra.
O par reprovado do Café Cerrado é um só, e vem lá da Aula 02: --cor-destaque (#c2703d) como fundo de botão com texto branco dá 3,7:1, e texto normal exige 4,5:1. A correção é escurecer o fundo até passar — sem trocar a família de cor, para a identidade visual não mudar. Acrescente uma variável ao :root e use-a em tudo que leve texto sobre a cor de destaque:
css/estilo.css
CSS
:root{/* … a paleta das Aulas 02 e 04 continua aqui, sem alteração … */--cor-destaque-escura:#a3521f;/* 5,6:1 com o branco; 5,3:1 sobre o fundo claro */}/* Texto sobre a cor de destaque: sempre a versão escura */.btn-destaque,.badge-destaque{background-color:var(--cor-destaque-escura);color:var(--cor-superficie);/* 5,6:1 — passa AA com folga */}/* A cor de destaque original continua válida onde não carrega texto: bordas, ícones decorativos e o sublinhado do menu (só precisam de 3:1). */.produto__preco{color:var(--cor-destaque-escura);/* 5,3:1 sobre o fundo claro */}/* Texto secundário: nada de cinza-claro */.texto-secundario{color:var(--cor-texto-suave);/* 8,0:1 sobre o fundo claro */}
🧠 Você sabia?
A diferença entre #c2703d e #a3521f é quase invisível lado a lado — e é a diferença entre reprovar e passar. Foi por isso que a §5 abriu com "pare de decidir cor no olho": a percepção humana de "mais escuro" não é linear, e a fórmula da luminância relativa da WCAG eleva cada canal a 2,4 justamente para corrigir isso.
Confirme cada número no seletor de cor do DevTools antes de commitar. A frase "está bom assim" não é evidência.
index.html (a navbar completa, repetida nas três páginas com o aria-current no item certo)
HTML
<navclass="navbar navbar-expand-lg"aria-label="Navegação principal"><divclass="container"><aclass="navbar-brand d-flex align-items-center gap-2"href="index.html"><svgclass="logo"viewBox="0 0 64 64"aria-hidden="true"focusable="false"><pathd="M12 26h34v10a17 17 0 0 1-34 0z"fill="currentColor"/><pathd="M46 28h4a7 7 0 0 1 0 14h-2"fill="none"stroke="currentColor"stroke-width="4"/><rectx="8"y="50"width="42"height="4"rx="2"fill="currentColor"/></svg><span>Café Cerrado</span></a><buttonclass="navbar-toggler"type="button"data-bs-toggle="collapse"data-bs-target="#menu-principal"aria-controls="menu-principal"aria-expanded="false"aria-label="Abrir e fechar o menu de navegação"><spanclass="navbar-toggler-icon"aria-hidden="true"></span></button><divclass="collapse navbar-collapse"id="menu-principal"><ulclass="navbar-nav ms-auto"><liclass="nav-item"><aclass="nav-link"href="index.html"aria-current="page">Início</a></li><liclass="nav-item"><aclass="nav-link"href="cardapio.html">Cardápio</a></li><liclass="nav-item"><aclass="nav-link"href="contato.html">Contato</a></li></ul></div></div></nav>
Cinco detalhes, um a um:
aria-label no <nav> nomeia o landmark. O rodapé tem outro <nav>, com nome diferente.
aria-label no botão é obrigatório porque o navbar-toggler-icon do Bootstrap é uma imagem de fundo em CSS: sem o rótulo, o botão chega à árvore com nome vazio, e o Lighthouse reporta "Buttons do not have an accessible name".
aria-expanded="false" declara o estado inicial. O JavaScript do Bootstrap alterna esse atributo sozinho ao abrir e fechar — inspecione o botão no DevTools e clique nele para ver o valor mudar ao vivo. É o primeiro estado ARIA dinâmico que o seu projeto tem, e você não escreveu uma linha de JavaScript para isso.
aria-controls="menu-principal" aponta para o id do bloco que o botão governa. O id precisa existir e ser único na página.
aria-current="page" vai apenas no link da página atual — e é o mesmo atributo que o CSS da Aula 05 usa para manter o sublinhado aceso. Um atributo, dois usos.
🔬 Investigue
Reduza a janela até o menu virar hambúrguer. Selecione o botão no painel Elements e clique nele na página: veja aria-expanded alternando entre "false" e "true" no HTML ao vivo. Agora abra o painel Accessibility com o botão selecionado e leia o campo Name e a lista de estados. Por fim, feche o menu e pressione Tab a partir do botão: os links do menu fechado ainda recebem foco? Se sim, você encontrou um problema real — e a solução dele é justamente o que o componente collapse do Bootstrap faz com display: none no bloco fechado. Confirme no painel Computed.
Passe por todas as imagens e ícones das três páginas com esta régua:
HTML
<!-- Imagem que carrega informação: alt descreve o conteúdo, não o arquivo --><imgsrc="img/espresso.jpg"alt="Xícara de espresso com creme dourado sobre mesa de madeira"class="card-img-top"><!-- Imagem puramente decorativa: alt vazio, para o leitor pular --><imgsrc="img/textura-grao.png"alt=""class="fundo-secao"><!-- Ícone decorativo ao lado de texto que já informa --><svgclass="icone"aria-hidden="true"focusable="false"><usehref="#icone-relogio"/></svg>
Pronto em 2 min
<!-- Link cujo texto visível é curto demais fora de contexto --><ahref="cardapio.html"class="btn btn-cafe">
Ver detalhes<spanclass="oculto-visualmente"> do Espresso do Cerrado</span></a>
Quatro regras:
alt descreve a função da imagem naquele contexto, não o arquivo. alt="espresso.jpg" e alt="imagem" são erros; alt="" em imagem decorativa é a resposta certa, não uma omissão.
Imagem dentro de link sem texto: o alt vira o nome do link. Nesse caso ele descreve o destino ("Cardápio completo"), não a figura.
Ícone decorativo recebe aria-hidden="true" — você já fez isso na Aula 05; confirme que não escapou nenhum.
Texto de link significativo fora de contexto. Quem usa leitor de tela pode pedir a lista de todos os links da página; seis itens chamados "Ver detalhes" são inúteis. A classe .oculto-visualmente completa o nome sem mudar o visual.
Substitua o formulário de contato.html pela versão da §8, incluindo <fieldset>/<legend>, autocomplete, aria-describedby e a região aria-live vazia no fim. Acrescente o CSS da região:
css/estilo.css
CSS
.status-envio{margin-top:1rem;min-height:1.5rem;/* reserva o espaço: o texto não empurra o layout ao aparecer */font-weight:600;}.status-envio:empty{margin-top:0;}/* Campo obrigatório: a marca não é só cor */.form-label.obrigatorio{color:var(--cor-marca-escura);font-weight:700;}
E a classe de conteúdo só para leitor de tela, que o Passo 8 já usou:
A tabela da Aula 03 precisa de duas coisas que quase todo mundo esquece: uma legenda e o escopo dos cabeçalhos.
cardapio.html
HTML
<tableclass="table table-striped caption-top"><caption>Cafés quentes: preços em reais, atualizados semanalmente.</caption><thead><tr><thscope="col">Item</th><thscope="col">Descrição</th><thscope="col">Preço</th></tr></thead><tbody><tr><thscope="row">Espresso do Cerrado</th><td>Dose curta, torra média, notas de chocolate</td><td>R$ 6,00</td></tr><tr><thscope="row">Coado da Casa</th><td>Coador de papel, moagem média na hora, 200 ml</td><td>R$ 8,50</td></tr><tr><thscope="row">Cappuccino Sinop</th><td>Espresso duplo, leite vaporizado e canela do Cerrado</td><td>R$ 12,00</td></tr><tr><thscope="row">Cold Brew da Chapada</th><td>Extração a frio de 18 horas, servido com gelo</td><td>R$ 15,00</td></tr></tbody></table>
<caption> dá nome à tabela na lista de tabelas do leitor de tela. scope="col" e scope="row" dizem a que cabeçalho cada célula pertence, para que a leitura seja "Coado da Casa, Preço, R$ 8,50" em vez de "R$ 8,50" solto. A classe caption-top do Bootstrap coloca a legenda acima da tabela; sem ela, o Bootstrap a exibe embaixo.
Rode o Lighthouse novamente nas três páginas, salve as capturas como lighthouse-depois-*.png em evidencias/ e cole a URL do GitHub Pages no WAVE (wave.webaim.org). Depois registre o resultado:
README.md (nova seção)
Markdown
## Acessibilidade
Auditoria da Unidade 1 (Lighthouse, categoria Accessibility, modo mobile).
| Página | Nota antes | Nota depois |
|---|---|---|
| index.html | 72 | 100 |
| cardapio.html | 68 | 96 |
| contato.html | 64 | 100 |
As três principais correções:
1. Contraste do texto secundário: o cinza claro sobre creme dava 2,7:1 e foi trocado
pelo marrom de texto suave (8,0:1), acima do mínimo de 4,5:1 da WCAG 1.4.3.
2. Botão do menu sem nome acessível: recebeu `aria-label`, porque o ícone do
Bootstrap é imagem de fundo e não gera texto.
3. Link de salto ausente: adicionado como primeiro elemento do body das três
páginas, com `tabindex="-1"` no `<main>` de destino.
Testes manuais: navegação completa por teclado nas três páginas e leitura da
página inicial com o NVDA.
Substitua os números pelos seus — o que vale aqui é o antes/depois real do seu próprio site, não um exemplo copiado.
Lighthouse: as três páginas com Acessibilidade ≥ 90 em modo mobile, janela anônima.
WAVE: zero Errors; Alerts podem existir, mas você precisa saber explicar cada um.
Teclado: o roteiro do Passo 11 percorrido inteiro sem uma única falha.
Foco: cada parada do Tab mostra o anel de dois tons, inclusive sobre a navbar escura.
Link de salto: aparece na primeira tabulação e leva o foco ao <main>.
Menu: o aria-expanded do botão alterna ao abrir e fechar; o aria-current="page" está no item certo de cada página.
Contraste: nenhum par reprovado no Lighthouse; o seletor de cor do DevTools confirma os valores da tabela do Passo 6.
Árvore de acessibilidade: nenhum botão, link ou campo com nome vazio no painel Accessibility.
Leitor de tela: com o NVDA (ou VoiceOver/TalkBack), a página inicial é compreensível de olhos fechados: você chega ao cardápio e descobre o preço de um item.
Movimento reduzido: emular prefers-reduced-motion: reduce continua sem esconder nem quebrar nada.
Resultado esperado: o Café Cerrado passa a ser utilizável por quem não usa mouse, por quem não vê a tela e por quem não distingue cores — com evidência numérica disso no README.md e nas capturas em evidencias/. A Unidade 1 está fechada. Faça o commit com a mensagem Aula 06: acessibilidade, ARIA e auditoria e o push.
B1. Audite uma página do seu projeto autoral com o Lighthouse, liste todos os erros apontados e corrija-os, registrando antes/depois em uma tabela.
Resultado esperado: nota de Acessibilidade ≥ 90 na página escolhida, com um documento (ou seção do README.md) listando cada erro, a causa e a correção aplicada.
Dica
Rode em janela anônima e modo mobile. Comece pelos erros de contraste e de rótulo: são os mais numerosos e os mais rápidos. Cada item do Lighthouse tem um link "Learn more" que leva à documentação com o trecho de código correto.
B2. Implemente o link de salto e o anel de foco de dois tons nas três páginas do seu projeto, e comprove com uma sequência de três capturas de tela do Tab.
Resultado esperado: primeira tabulação revela o link de salto; Enter move o foco para o conteúdo; o anel de foco é visível tanto sobre a área clara quanto sobre a navbar escura.
Dica
Se ao pressionar Enter o foco parecer não se mover, falta tabindex="-1" no <main>. Para capturar o estado de foco em uma imagem, use a ferramenta de captura do sistema com atraso de 3 s, ou force o estado :focus pelo botão :hov no painel Styles.
B3. Torne acessível o formulário do seu projeto: <label for> em todos os campos, <fieldset>/<legend> nos grupos, autocomplete correto, aria-describedby nas ajudas e uma região aria-live vazia para o resultado.
Resultado esperado: cada campo é anunciado com nome, tipo, obrigatoriedade e ajuda; nenhum placeholder faz o papel de rótulo; o Lighthouse não reporta nenhum campo sem rótulo.
Dica
A lista de valores válidos de autocomplete está na especificação HTML e na MDN — os mais usados são name, email, tel, street-address, postal-code. Para conferir o nome anunciado de cada campo, use o painel Accessibility com o campo selecionado.
B4. Encontre e corrija cinco problemas de acessibilidade em um site real que você usa. Documente cada um com captura de tela, o critério WCAG violado e o código corrigido (a correção é sua, no papel — você não vai publicar no site alheio).
Resultado esperado: um documento com cinco itens, cada um nomeando o critério (por número) e mostrando o "antes" real e o "depois" proposto.
Dica
Sites de comércio e de prefeituras costumam falhar em contraste, foco visível, alt e rótulo de campo de busca. Use o WAVE para localizar rápido, mas confirme cada achado à mão: nem todo alerta é erro.
B5. Substitua um componente do seu projeto que esteja feito com <div> por HTML nativo equivalente, e compare o antes e o depois na árvore de acessibilidade.
Resultado esperado: duas capturas do painel Accessibility, uma mostrando papel genérico e nome vazio, outra mostrando papel e nome corretos, sem nenhum atributo ARIA adicionado.
Dica
Os candidatos mais comuns são: <div class="botao"> → <button>; <div class="menu"> com <div> filhos → <nav> + <ul>/<li>; <span class="titulo"> → <h2>; <div class="card"> → <article>.
B6. Escreva os textos alternativos de seis imagens do seu projeto, sendo duas informativas, duas decorativas e duas dentro de links, e justifique cada escolha em uma linha.
Resultado esperado: uma tabela de três colunas — imagem, alt escolhido, justificativa — em que nenhuma das decorativas tem texto e nenhuma das que estão dentro de link descreve a figura em vez do destino.
Dica
O teste do telefone: se você estivesse descrevendo a página por telefone para alguém, você mencionaria essa imagem? Se não mencionaria, ela é decorativa e leva alt="". Se mencionaria, o que você diria é o alt.
B7. Prepare a região aria-live do seu formulário e prove que ela funciona: com o leitor de tela ligado, escreva um texto dentro dela pelo Console do DevTools e confirme que a mensagem é anunciada sem que o foco se mova.
Resultado esperado: um vídeo curto, ou um relato escrito passo a passo, demonstrando que a alteração do conteúdo da região foi anunciada em voz alta.
Dica
No Console, document.querySelector("#status-envio").textContent = "Mensagem enviada" altera o conteúdo. Se nada for anunciado, quase sempre é porque a região foi criada junto com o texto, em vez de já existir vazia — ou porque o elemento estava com display: none.
C1.Auditoria cruzada. Encontre outra pessoa disposta a trocar sites com você — alguém do seu grupo de estudos, um amigo que também esteja aprendendo, ou alguém de uma comunidade online de dev — e troquem a URL do GitHub Pages. Cada um audita o site do outro em 25 minutos, produzindo um relatório com: nota do Lighthouse por página; três erros automáticos com o critério WCAG correspondente; três problemas que só o teste manual revelou (teclado, ordem de foco, alt inútil, texto de link inútil); e uma recomendação priorizada. Depois, cada um corrige o próprio site com base no relatório recebido e mede de novo. Entregue os dois relatórios e as notas antes/depois.
Dica
Reserve 10 minutos para as ferramentas automáticas e 15 para o teste manual — é o manual que encontra o que vale nota. Um bom relatório diz "o quê, onde, qual critério e como corrigir", nunca "o site tem problemas de acessibilidade".
C2.Dez minutos de olhos fechados. Ligue o NVDA (ou o VoiceOver/TalkBack), feche os olhos ou desligue o monitor e tente cumprir três tarefas no site de outra pessoa (peça a alguém do seu grupo de estudos, ou use qualquer site com cardápio e formulário de contato): descobrir o preço de um item do cardápio; encontrar o telefone de contato; e chegar ao campo "Mensagem" do formulário. Cronometre cada tarefa e anote em que ponto você se perdeu.
Dica
No NVDA, H pula entre títulos, D entre landmarks, K entre links e F entre campos de formulário; Insert+F7 abre a lista de elementos. Se você não conseguir navegar por títulos, o problema não é seu: é a hierarquia da página.
Desconecte o mouse. Fisicamente, do computador — ou desative o touchpad. Por vinte minutos você vai usar apenas o teclado: o seu site, o site da sua universidade ou escola, o SIGAA e um site de comércio à sua escolha. A maioria das pessoas desiste nos primeiros três minutos. Não desista: o que incomoda você por vinte minutos é o dia inteiro de alguém.
Critérios de pronto
Um relato de uma página com quatro seções, uma por site, contando onde você travou e o que aconteceu.
Para cada travamento, o critério WCAG correspondente identificado pelo número (2.1.1, 2.4.3, 2.4.7…).
Uma lista dos atalhos que você descobriu ser preciso usar (Tab, Shift+Tab, Espaço, setas, Home/End) e de onde cada um foi necessário.
Pelo menos um problema encontrado no seu próprio site, corrigido e commitado.
Uma frase final respondendo: qual das quatro páginas foi a mais fácil, e o que ela fazia de diferente?
Pistas
Comece pelo seu site: você conhece a estrutura e vai perceber rápido o que falta.
Se o foco "sumir", pressione Tab mais uma vez e olhe a barra de status do navegador: ela mostra o destino do link focado mesmo quando não há indicador visual.
Menus suspensos que só abrem no :hover são armadilhas clássicas — não há hover no teclado.
Antes de acusar um site, confira se o problema não é uma extensão do seu navegador: repita em janela anônima.
Para ir além: repita cinco minutos usando apenas o teclado e com a tela apagada, com o leitor de tela ligado. A diferença de dificuldade entre as duas experiências é o conteúdo de um bom parágrafo no seu relato.
Um "desenvolvedor sênior" entregou este acordeão de perguntas frequentes para o Café Cerrado, orgulhoso do ARIA que usou. O componente é uma coleção de mentiras: os atributos afirmam coisas que o código não cumpre, e o resultado é pior para quem usa leitor de tela do que se não houvesse ARIA nenhum. Encontre seis problemas e reescreva o componente — de preferência com muito menos ARIA do que ele tem.
HTML
<divclass="faq"role="list"><divclass="faq-item"><divclass="faq-titulo"role="button"aria-expanded="true"aria-controls="resposta-1">
Vocês têm opção sem lactose?
<imgsrc="img/seta.png"aria-hidden="true"></div><divid="resposta-01"class="faq-resposta"style="display: none"aria-live="assertive">
Sim: leite de aveia e de castanha, sem custo adicional.
</div></div><divclass="faq-item"><spanclass="faq-titulo"role="button"tabindex="3"aria-label="Clique aqui">
Aceitam animais de estimação?
</span><divclass="faq-resposta"style="display: none">
Sim, na área externa coberta.
</div></div></div>
Critérios de pronto
Os seis problemas listados por escrito, cada um com o critério WCAG ou a regra ARIA que viola.
O componente reescrito e funcionando: abre e fecha por mouse, por Enter e por Espaço.
Nenhum role que possa ser substituído por um elemento HTML nativo permanece no código.
Nenhum tabindex positivo, nenhum aria-controls apontando para id inexistente, nenhum aria-label que apague texto visível.
O estado aberto/fechado é anunciado corretamente pelo leitor de tela, e o conteúdo fechado não recebe foco.
Um parágrafo explicando por que aria-live="assertive" estava errado ali.
Pistas
Existe um elemento HTML nativo que faz acordeão sem uma linha de JavaScript e sem nenhum ARIA — procure details e summary na MDN. Quanto do componente sobra depois dele?
Compare o valor de aria-controls com o id que existe de fato no documento. Leia caractere por caractere.
aria-expanded="true" combinado com display: none é uma contradição: o atributo diz "está aberto" e o CSS diz "está fechado".
Um <img> sem alt não é a mesma coisa que um <img alt="">; e uma seta que indica estado talvez nem devesse ser imagem.
O que role="list" exige dos filhos diretos? Consulte a WAI-ARIA APG antes de responder.
A Lei Brasileira de Inclusão exige acessibilidade em sites de órgãos públicos desde 2015. Você vai verificar, com método, se um deles cumpre — e produzir um documento que poderia, de fato, ser protocolado. Escolha um serviço que você mesmo precisa usar: matrícula, agendamento de saúde, emissão de documento, consulta de processo, transporte público.
Critérios de pronto
Uma tarefa real definida em uma frase ("agendar uma consulta", "emitir a segunda via"), executada do começo ao fim em três condições: com mouse, só com teclado, e com leitor de tela.
Auditoria automática de pelo menos três páginas do fluxo (Lighthouse e WAVE), com os números registrados.
Um mínimo de oito problemas documentados, cada um com: onde ocorre, o critério WCAG pelo número, a evidência (captura ou trecho de código) e o impacto real na tarefa.
Os problemas classificados em três severidades, com o critério de classificação explicado — impedem, dificultam ou incomodam.
Uma proposta de correção em código para os três mais graves.
Uma seção final de meia página em linguagem não técnica, endereçada a quem decide (um gestor, não um programador), explicando o que está em jogo e citando o artigo 63 da Lei nº 13.146/2015.
O documento publicado no repositório do seu projeto autoral, em docs/auditoria-acessibilidade.md.
Pistas
Defina a tarefa antes de abrir o site. Auditoria sem tarefa vira lista de avisos de ferramenta, que é exatamente o que ninguém lê.
Grave a tela nas três condições: você vai esquecer metade do que aconteceu, e o vídeo é a evidência mais convincente que existe.
Formulários de várias etapas concentram os piores problemas: campos sem rótulo, erro anunciado só em vermelho, tempo limite de sessão sem aviso (critério 2.2.1).
Severidade se mede pelo efeito na tarefa, não pelo susto: contraste ruim incomoda; <div> clicável que não recebe foco impede.
Para a seção final, escreva como se explicasse a um parente. Se aparecer a palavra "landmark", reescreva.
Para ir além: envie o documento pelo canal de ouvidoria do órgão. Não é retórica: relatórios bem escritos de estudantes já geraram correção em portais públicos, e o e-mail de resposta vale mais no seu currículo que o certificado do trabalho.
Seis aulas atrás o seu projeto era uma pasta vazia e um repositório recém-criado. Hoje ele é um site de três páginas, semântico, responsivo, com framework CSS, identidade visual em SVG, movimento com propósito e utilizável por quem não vê a tela. O Boss desta unidade não traz nada novo: traz tudo junto, funcionando ao mesmo tempo, no seu tema — e é literalmente o roteiro do Marco 1.
Critérios de pronto
Aula 02 — fundamentos:lang="pt-BR", <title> único e descritivo por página, meta viewport sem restrição de zoom, pastas css/, js/ e img/ organizadas, HTML sem erros no validador do W3C.
Aula 03 — semântica e formulários: landmarks header/nav/main/footer com um único <main> por página; hierarquia de títulos sem saltos; alt correto em todas as imagens; formulário completo com validação nativa; navegação entre as três páginas com aria-current="page".
Aula 04 — framework CSS: Bootstrap 5.3 (ou o framework escolhido) aplicado de forma consistente, com grid responsivo, navbar e cards; escolha justificada em um parágrafo do README.md; nenhuma rolagem horizontal em 360 px.
Aula 05 — animação e SVG: microinterações em todos os elementos clicáveis, com :focus-visible equivalente ao :hover; ao menos uma animação @keyframes com propósito declarado; logotipo autoral em SVG inline e sprite com pelo menos três ícones; bloco prefers-reduced-motion como última regra do CSS; nenhuma animação de propriedade de layout.
Aula 06 — acessibilidade: link de salto funcional; anel de foco visível em fundo claro e escuro; contraste AA em todos os textos, comprovado com números; aria-label nos dois <nav>; aria-expanded/aria-controls no botão do menu; região aria-live no formulário; Lighthouse ≥ 90 nas três páginas; teste de teclado completo aprovado.
Evidências: pasta evidencias/ com capturas do Lighthouse antes e depois de cada página, capturas do site em 360 px, 768 px e 1440 px, e a captura do estado de foco do link de salto.
README: seções "Sobre o projeto", "Framework escolhido e por quê", "Movimento" e "Acessibilidade" (com a tabela antes/depois e as três principais correções).
Repositório: público, com histórico de commits que mostre evolução aula a aula — não um único commit "projeto final" — e o site publicado e acessível pela URL do GitHub Pages.
Pistas
Use os checkpoints das Aulas 02 a 06 como lista de verificação, na ordem. A maior parte você já fez; o Boss é o que falta mais a integração.
Rode o Lighthouse antes de mexer em qualquer coisa e guarde a captura: metade da nota da seção de acessibilidade do README.md é essa comparação.
O item que mais reprova é o contraste, e o segundo é o texto de link inútil ("Saiba mais", "Clique aqui", "Ver detalhes" repetido seis vezes). Os dois são correções de minutos.
Peça a alguém — colega de estudos, amigo, familiar — que use o seu site só pelo teclado por dois minutos e anote onde ele se perdeu. Cada anotação é um problema real a menos no seu site.
Deixe o README.md por último, mas não para a última hora: ele é o único lugar onde você defende as suas decisões, e decisões bem defendidas fecham três critérios do checklist de uma vez.
Para ir além: peça a alguém que não seja da área de tecnologia — um parente, um colega de trabalho — para realizar uma tarefa no seu site sem nenhuma instrução sua ("descubra quanto custa um cappuccino"). Fique calado e cronometre. O que essa pessoa não achou em 30 segundos é o que o seu site esconde.
Parte 1 — Leitura (15 min). QUEIRÓS e PORTELA, seção sobre a camada de comportamento — é a preparação para a Unidade 2, que começa na próxima aula. Leia também a página Acessibilidade da MDN em português e o texto do artigo 63 da Lei nº 13.146/2015 no portal do Planalto (são cinco linhas). Anote uma obrigação legal que você não sabia que existia.
Parte 2 — Entrega (40 min). No seu projeto autoral:
Rode o Lighthouse e o WAVE nas três páginas e registre as notas iniciais.
Corrija todos os erros apontados e execute o teste de teclado completo do Passo 11.
Adicione: link de salto, aria-label nos dois <nav>, aria-current="page" no item certo de cada página, aria-hidden em todos os SVG decorativos e a região aria-live no formulário.
Corrija o contraste de todos os pares reprovados, incluindo o indicador de foco.
Registre no README.md a seção "Acessibilidade" com a tabela de notas antes/depois e as três principais correções, no formato do Passo 12.
Finalize o Marco 1 conforme as instruções da seção seguinte.
Critério de pronto: Lighthouse ≥ 90 nas três páginas; zero Errors no WAVE; teste de teclado percorrido sem falha; seção "Acessibilidade" no README.md com números reais; evidencias/ com as capturas antes e depois.
Parte 3 — Registro (5 min). Em docs/sem-mouse.md, conte em um parágrafo o momento mais difícil da sua navegação por teclado desta semana e o que você mudou no seu site por causa dele. Se puder, compare com outra pessoa que esteja estudando, apontando um problema semelhante ou uma solução diferente.
Guarde no seu repositório: commit + push, com o link do GitHub Pages atualizado.
Este marco fecha a Unidade 1 — Web estática inteira: um website client-side em HTML e CSS, com HTML semântico, layout responsivo, framework CSS, animação/SVG e acessibilidade. Ele é sobre o projeto autoral que vem evoluindo desde a Aula 01 — não o Café Cerrado, que é o projeto de exemplo construído ao longo das aulas.
O projeto deve:
Ter um domínio diferente do Café Cerrado (ex.: catálogo de plantas do Pantanal, agenda de quadras esportivas, mural de estágios, brechó, controle de pescarias, oficina de bicicletas, estúdio de tatuagem — ou outro tema seu, definido na Aula 01).
Ter no mínimo três páginas navegáveis entre si, sendo uma delas com formulário completo.
Estar publicado e funcionando no GitHub Pages.
Ser construído sem JavaScript autoral — o único JavaScript permitido é o do framework CSS escolhido (o bootstrap.bundle.min.js, por exemplo). O comportamento próprio começa na Unidade 2.
HTML semântico: landmarks header, nav, main e footer; um único <main> por página; hierarquia de títulos sem saltos, com um <h1> por página; listas e tabelas usadas para o que são; HTML sem erros no validador do W3C.
Layout responsivo: usável em 360 px, 768 px e 1440 px, sem rolagem horizontal; imagens fluidas; menu adaptado ao celular.
Framework CSS: aplicado de forma consistente nas três páginas (grid, componentes e utilitários), com a escolha justificada em um parágrafo do README.md; CDN com versão fixa na URL.
Animação e SVG: microinterações com transição em todos os elementos clicáveis, cada :hover com o :focus-visible correspondente; pelo menos uma animação @keyframes; logotipo autoral em SVG inline; ao menos três ícones SVG; bloco prefers-reduced-motion no fim do CSS.
Acessibilidade: link de salto; foco visível com contraste adequado; contraste AA em todos os textos; alt correto em todas as imagens; <label> em todos os campos; aria-label nos <nav>; aria-expanded/aria-controls no botão do menu; aria-current="page"; região aria-live no formulário; Lighthouse ≥ 90 na categoria Accessibility, em modo mobile, nas três páginas.
README.md com: nome e descrição do projeto, instruções para abrir localmente, link do GitHub Pages, justificativa do framework, seção "Movimento" e seção "Acessibilidade" com a tabela de notas antes/depois.
Repositório público no GitHub, com histórico de commits mostrando evolução incremental (um commit por aula, no mínimo) e a pasta evidencias/ com as capturas.
O que separa um projeto pronto de um feito às pressas, critério a critério:
HTML semântico e válido em todas as páginas — não só na inicial.
Layout responsivo de verdade em celular, tablet e desktop — não só "não quebra", mas confortável de usar nos três.
Framework CSS aplicado com consistência e com a escolha justificada em texto próprio.
Animação e SVG com propósito declarado, incluindo prefers-reduced-motion.
Acessibilidade completa: Lighthouse ≥ 90, teclado, contraste, ARIA — nas três páginas, não em uma.
Versionamento, README.md e publicação no GitHub Pages, com histórico de commits que mostra o projeto crescendo aula a aula.
Um projeto "pela metade" costuma ter um desses itens presente em uma página e ausente nas outras duas — revise sempre as três antes de considerar algo pronto.
Sobre IA: use como apoio — para explicar um erro, sugerir sintaxe, revisar um trecho —, não como atalho para gerar o projeto inteiro sem entender o que ele faz. O teste real: se alguém apontar para um aria-expanded ou uma transition do seu código e perguntar por que ela está ali, você precisa saber responder.
Faça o teste de teclado completo, sem mouse, nas três páginas.
Abra o site em 360 px, 768 px e 1440 px (DevTools) e confirme que não há rolagem horizontal em nenhuma largura.
Abra o repositório no GitHub: o histórico de commits mostra evolução real (não um único commit "projeto final"), o README.md tem todas as seções pedidas e a pasta evidencias/ guarda as capturas de antes e depois.
W3C — WAI-ARIA Authoring Practices Guide: https://www.w3.org/WAI/ARIA/apg/ — cada componente (acordeão, menu, modal) com o HTML e o comportamento de teclado corretos.
W3C — Perspectivas de acessibilidade (vídeos curtos, legendados em português): https://www.w3.org/WAI/perspective-videos/ — um minuto cada; mostre para quem disser que "ninguém usa isso".
NVDA — leitor de tela gratuito para Windows: https://www.nvaccess.org/download/ — instale e passe dez minutos com ele; é a aula que mais muda a cabeça de um desenvolvedor.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — camada de apresentação e boas práticas de interface (se você tem acesso a uma biblioteca virtual pela sua instituição).
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — usabilidade e padrões de interface.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — qualidade de front-end em escala.
Isso encerra a Unidade 1. Em seis aulas o seu projeto saiu de um repositório vazio para um site de três páginas, semântico, responsivo, com framework CSS, identidade visual desenhada em código, movimento com propósito e acessível a quem não usa mouse nem enxerga a tela — publicado na internet, com endereço próprio. Ele é o Marco 1, e o Boss desta aula é o roteiro para fechá-lo.
Na próxima aula começa a Unidade 2 — Web dinâmica client-side, com a revisão de JavaScript: objetos, funções, eventos e manipulação do DOM. O site que você acabou de entregar deixa de ser só apresentação e passa a reagir: o cardápio vai ser gerado a partir de um vetor de produtos em vez de estar escrito à mão no HTML, o formulário vai validar e responder de verdade — e aquela região aria-live que você deixou vazia hoje vai finalmente ganhar texto.
Nível 2Unidade 2 · Web dinâmica client-side3 aulas de 50 min + 1 h EAD
Aula 07 — Revisão de JavaScript: objetos, funções, eventos e DOM
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 06 você fechou a Unidade 1: o Café Cerrado tem HTML semântico, layout responsivo com Bootstrap, animação, SVG e uma auditoria de acessibilidade aprovada. É um site bonito e imóvel — tudo o que ele mostra está escrito à mão no HTML. Hoje começa a Unidade 2: o site ganha um cérebro. Você cria js/app.js, transforma o cardápio em dados e passa a desenhar a tela a partir desses dados, com eventos reagindo ao que a pessoa faz.
Na aula passada você auditou a acessibilidade do Café Cerrado e fechou o Marco 1 — o site estático completo. Hoje o mesmo repositório ganha um arquivo novo, js/app.js, e o cardápio deixa de ser HTML fixo para virar um array de objetos renderizado por JavaScript. Nada do que você fez na Unidade 1 é descartado: o JS vai ligar e desligar classes e atributos que o seu CSS já sabe estilizar.
Checklist antes de começar:
[ ] Repositório cafe-cerrado clonado, com index.html, cardapio.html, contato.html, css/estilo.css e img/.
[ ] Bootstrap 5.3 carregado pelo CDN em todas as páginas (Aula 04), com a navbar responsiva funcionando.
[ ] Formulário de contato de contato.html com label para todo campo e a região aria-live da Aula 06.
[ ] VS Code com a extensão Live Server (ou npx serve) — abrir com file:// funciona hoje, mas a Aula 10 vai exigir um servidor local.
[ ] Navegador com o DevTools aberto na aba Console. Programe com ele aberto o tempo todo.
Do Nível 1 você já traz: variáveis, tipos, condicionais, laços, funções, DOM e eventos (Aulas 10 a 14). Esta aula não reensina isso do zero — ela revisa em ritmo rápido, corrige os vícios mais comuns e avança para o que a Unidade 2 exige.
O script na página; revisão de valores e tipos; objetos modernos; o array produtos
2
50 min
DOM em profundidade: seleção, <template>, criação de nós; eventos, event, delegação
3
50 min
Mão na massa (cardápio renderizado, tema, validação do formulário) e laboratório
1. A camada de comportamento volta ao Café Cerrado¶
Desde a Aula 02 você trabalha com as três camadas do front-end: HTML (conteúdo e significado), CSS (apresentação) e JavaScript (comportamento). A Unidade 1 inteira foi feita nas duas primeiras. A regra que vale daqui em diante: o JavaScript não deveria escrever estilo. Ele altera dados, classes e atributos; quem decide a cor e o tamanho continua sendo o CSS.
Isso tem uma consequência prática imediata. Quando você quiser destacar um card, não escreva card.style.border = "2px solid orange". Escreva card.classList.add("destaque") e deixe .destaque no css/estilo.css. O visual continua versionado em um lugar só, e o modo escuro, o prefers-reduced-motion e a impressão continuam funcionando.
Três formas de carregar um script, e o que muda em cada uma:
Forma
Quando executa
Quando usar
<script src="…">
Na hora, bloqueando a montagem do HTML
Praticamente nunca
<script src="…" defer>
Depois que o HTML todo virou DOM, na ordem das tags
Padrão para o código da sua página
<script src="…" async>
Assim que o download terminar, fora de ordem
Scripts independentes (métricas, chat)
<script src="…" type="module">
Como defer, e com import/export disponíveis
Quando o código se divide em vários arquivos
Com defer, quando a primeira linha de app.js roda, o document já está completo. É por isso que você não precisa mais envolver tudo em DOMContentLoaded como fazia no Nível 1 — o defer já garante essa ordem. Se o script estivesse no <head>semdefer, document.querySelector("#lista-produtos") devolveria null, porque o elemento ainda não teria sido criado.
🔎 Por baixo do capôdefer não é "esperar um pouco". O navegador continua baixando o arquivo em paralelo com o HTML — o que ele adia é apenas a execução, para o momento imediatamente anterior ao evento DOMContentLoaded. Vários scripts com defer executam na ordem em que aparecem no HTML; com async, na ordem em que chegam da rede, o que é imprevisível. Por isso async é péssimo para código que depende de outro código.
Um script type="module" já é adiado por padrão (o defer seria redundante) e traz duas mudanças de comportamento: o código roda em modo estrito automaticamente e as variáveis de topo não viram globais. Nesta unidade o Café Cerrado cabe em um arquivo só, então ficamos com defer. Quando o app.js crescer e se dividir, migramos para módulos — o Nível 3 vive inteiramente neles.
O Console não serve só para console.log. Vale conhecer o resto do estojo:
JavaScript
constproduto={id:1,nome:"Espresso do Cerrado",preco:6};console.log("valor simples:",produto.preco);console.table([produto,{id:2,nome:"Coado da Casa",preco:8.5}]);console.group("Renderização do cardápio");console.log("itens recebidos:",10);console.groupEnd();console.warn("Imagem sem alt encontrada.");console.error("Falha ao montar o card do produto 4.");console.count("render");
console.table é o mais subestimado: passe um array de objetos e o DevTools desenha uma planilha com uma coluna por propriedade, ordenável por clique. Para depurar o produtos de hoje, é imbatível.
No painel de elementos, clique em qualquer nó e digite $0 no Console: você recebe uma referência ao elemento selecionado e pode inspecioná-lo ($0.className, $0.dataset). E debugger; no meio do código pausa a execução ali, exatamente como um ponto de parada colocado na aba Sources.
🔬 Investigue
Abra o cardapio.html, cole no Console console.table(document.querySelectorAll("img")) e observe: você recebe uma tabela dos elementos, não dos arquivos. Agora rode [...document.querySelectorAll("img")].map((i) => i.alt). Quantos alt estão vazios? Se algum aparecer como string vazia sem ser decorativo, você acabou de achar um bug de acessibilidade que o Lighthouse da Aula 06 pode ter deixado passar.
2. Revisão relâmpago: valores, tipos e comparações¶
constnomeDaCafeteria="Café Cerrado";// nunca será reatribuídoletitensNoCarrinho=0;// vai mudar ao longo do programaitensNoCarrinho=itensNoCarrinho+1;// válido// nomeDaCafeteria = "Outro nome"; // TypeError: Assignment to constant variable.
var não aparece em código novo — o escopo dela vaza de blocos e produz bugs difíceis. A regra prática: comece tudo com const e só troque para let quando precisar mesmo reatribuir. O Console avisa quando você errou, com TypeError: Assignment to constant variable.
Atenção a um detalhe que confunde: const congela a ligação, não o conteúdo. Um objeto declarado com const continua tendo suas propriedades alteráveis:
JavaScript
constproduto={nome:"Espresso do Cerrado",preco:6};produto.preco=6.5;// permitido: o objeto é o mesmo, só mudou dentro// produto = { nome: "Outro" }; // TypeError: Assignment to constant variable.
typeof"cappuccino";// "string"typeof12.5;// "number" — inteiro e decimal são o mesmo tipotypeoftrue;// "boolean"typeofundefined;// "undefined" — declarado, sem valor atribuídotypeofnull;// "object" — um bug histórico da linguagem, mantido por compatibilidadetypeof{id:1};// "object"typeof[1,2,3];// "object" — para arrays use Array.isArray([1, 2, 3])typeofconsole.log;// "function"
JavaScript
5=="5";// true — converte tipos antes de comparar5==="5";// false — compara valor E tipo0=="";// true — mais uma coerção surpreendente0==="";// falsenull==undefined;// truenull===undefined;// false
Use === e !== sempre. A única exceção defensável é valor == null, que testa nullouundefined de uma vez — e mesmo essa é melhor escrita como valor === null || valor === undefined enquanto você ainda está construindo o hábito.
constnome="Ana";consttotal=27.5;constrecibo=`Obrigado, ${nome}!Seu pedido soma R$ ${total.toFixed(2)}.Status: ${total>25?"frete grátis":"frete a calcular"}`;
A crase permite interpolar ${qualquer expressão} e quebrar linha dentro do texto. Concatenação com + só sobrevive em código antigo.
2.4 Verdadeiro, falso e os dois operadores que salvam linhas¶
Os valores falsy que aparecem no dia a dia são seis: false, 0, "", null, undefined e NaN. Todo o resto é truthy — inclusive "0", [] e {}.
JavaScript
constlista=[];if(lista)console.log("um array vazio é truthy!");// imprimeif(lista.length===0)console.log("verificação correta de lista vazia");
Dois operadores modernos resolvem os casos que antes exigiam if aninhado:
JavaScript
constconfig={titulo:"Cardápio",itensPorPagina:0};// ?? (coalescência nula): usa o lado direito só se o esquerdo for null ou undefinedconfig.itensPorPagina??12;// 0 — respeita o zeroconfig.itensPorPagina||12;// 12 — o || trata 0 como "vazio" e atropela o valor// ?. (encadeamento opcional): interrompe o acesso em vez de estourar erroconstproduto={nome:"Torta de Frango"};produto.avaliacao?.media;// undefined, sem erro// produto.avaliacao.media; // TypeError: Cannot read properties of undefined (reading 'media')
🧠 Você sabia??? e ?. só chegaram ao JavaScript em 2020 (ES2020) — vinte e cinco anos depois da primeira versão da linguagem. Brendan Eich escreveu o protótipo do JavaScript em dez dias, em maio de 1995, para a Netscape. O nome "Java" foi decisão de marketing: as duas linguagens não têm parentesco. O comitê que hoje decide o futuro da linguagem, o TC39, publica uma versão nova por ano — e cada proposta passa por cinco estágios públicos antes de virar padrão.
Objetos merecem uma seção inteira porque tudo na Unidade 2 e na Unidade 3 é objeto: o DOM é uma árvore de objetos, o event que chega no clique é um objeto, a resposta de uma API chega em JSON — que é literalmente a notação de objeto do JavaScript. Investir aqui rende juros até a última aula do curso.
constproduto={id:2,nome:"Coado da Casa",categoria:"cafes",preco:8.5,descricao:"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.",imagem:"img/coado.jpg",};produto.nome;// "Coado da Casa" — notação de pontoproduto["preco"];// 8.5 — notação de colchetesconstcampo="categoria";produto[campo];// "cafes" — colchetes aceitam variável; o ponto não
Use ponto sempre que souber o nome da propriedade ao escrever o código; use colchetes quando o nome vier de uma variável (é o caso do filtro por categoria da próxima aula).
Guarde isto para a Aula 08: this depende de como a função é chamada, e as arrow functions se comportam de outro jeito. Por enquanto, a regra é simples: método que usa this se escreve com a sintaxe acima.
const{nome:nomeDoProduto,preco:precoDoProduto}=produto;const{descricao,imagem}=produto;const{avaliacao="sem avaliações"}=produto;// valor padrão se não existir
A desestruturação também funciona em parâmetros de função — e é aí que ela brilha, porque documenta o que a função usa:
JavaScript
functionresumirProduto({nome,preco,categoria}){return`${nome} (${categoria}): R$ ${preco.toFixed(2)}`;}resumirProduto(produto);// "Coado da Casa (cafes): R$ 8.50"
constoriginal={nome:"Bolo de Milho Verde",preco:9.5};constcomDesconto={...original,preco:8.5};// cópia com um campo trocadooriginal.preco;// 9.5 — intactocomDesconto.preco;// 8.5
O espalhamento (...) copia um nível. Se o objeto tiver outro objeto dentro, a cópia compartilha o interno:
JavaScript
constpedido={cliente:{nome:"Ana"},itens:2};constcopia={...pedido};copia.cliente.nome="Bruno";pedido.cliente.nome;// "Bruno" — o objeto interno é o mesmo!constcopiaProfunda=structuredClone(pedido);// cópia independente de verdade
⚠️ Atenção
Objetos e arrays são passados por referência. Duas variáveis podem apontar para o mesmo objeto na memória, e alterar por uma altera por outra. Metade dos bugs de "mudei uma coisa e a outra mudou junto" nasce aqui. Antes de modificar um objeto que veio de fora da sua função, copie.
Um objeto assim, usado como dicionário de tradução, é o jeito idiomático de mapear o código interno ("cafes") para o texto que a pessoa lê ("Cafés"). O Café Cerrado vai usar exatamente isso hoje.
consttexto=JSON.stringify(produto);// objeto → stringconstdeVolta=JSON.parse(texto);// string → objetoJSON.stringify(produto,null,2);// formatado, com 2 espaços de indentação
JSON é um subconjunto da notação de objeto: só aceita string, número, booleano, null, array e objeto. Funções, undefined e Date não sobrevivem à viagem (uma Date vira string). Na Aula 10 você vai buscar um arquivo data/produtos.json; na Unidade 3, o Express vai devolver JSON. É a mesma coisa que você está escrevendo agora, só que em texto.
O array continua o que você já conhece do Nível 1: lista ordenada, índice a partir de zero, length, push, for…of. O que muda no Nível 2 é o conteúdo: quase todo array de uma aplicação real é um array de objetos.
JavaScript
constnotas=[8,7.5,9,6];notas.length;// 4notas[0];// 8notas.push(10);// 5 — push devolve o novo tamanhofor(constnotaofnotas){console.log(nota);}for(const[indice,nota]ofnotas.entries()){console.log(`posição ${indice}: ${nota}`);}
4.1 Decidindo os campos antes de escrever o array¶
Antes de digitar o cardápio em JavaScript, decida o formato de um item. Essa decisão vai atravessar toda esta trilha: a mesma estrutura vira JSON na Aula 10, corpo de requisição na Aula 13 e linha de banco no Nível 3. Para o Café Cerrado:
Campo
Tipo
Por que existe
id
número
Identidade estável; sobrevive a mudanças de nome
nome
string
O que a pessoa lê no card
categoria
string curta
Chave de filtro; nunca o texto exibido
preco
número
Precisa ser somado e comparado
descricao
string
Uma frase de venda
imagem
string
Caminho relativo do arquivo
Duas decisões merecem explicação:
Por que preco: 8.5 e não preco: "R$ 8,50"? Porque texto não soma, não compara e não ordena. A formatação é responsabilidade da camada de apresentação, na hora de desenhar. Guarde número; formate na saída.
Por que categoria: "cafes" e não categoria: "Cafés"? Porque o valor guardado é uma chave técnica: sem acento, minúsculo, estável. O texto visível pode mudar (para "Nossos cafés", para outro idioma) sem quebrar nenhum filtro. É o mesmo raciocínio do value de um <option> contra o texto que aparece nele.
💡 Dicaid como número inteiro sequencial resolve por enquanto. Na Unidade 3, quem gera o id passa a ser o servidor — e ele nunca reaproveita um número já usado, mesmo depois de uma exclusão. Se você acostumar o código a tratar id como um valor opaco (comparar, nunca calcular), a migração não vai doer.
Você usa funções desde o Nível 1. O que precisa ficar explícito agora, porque é a base da Aula 08 e de toda a programação assíncrona da unidade: em JavaScript, função é um valor. Pode ser guardada em variável, colocada dentro de um array ou objeto, passada como argumento e devolvida por outra função.
JavaScript
// 1. Declaração — sofre hoisting: pode ser chamada antes da linha em que aparecefunctioncalcularSubtotal(preco,quantidade){returnpreco*quantidade;}// 2. Expressão — a função é um valor guardado numa variávelconstcalcularFrete=function(subtotal){returnsubtotal>=50?0:8;};// 3. Arrow function — a forma dominante no JS moderno (aprofundada na Aula 08)constaplicarDesconto=(valor,percentual)=>valor*(1-percentual/100);calcularSubtotal(6.5,3);// 19.5calcularFrete(19.5);// 8aplicarDesconto(19.5,10);// 17.55
Parâmetros podem ter valor padrão, e a função pode receber outra função como argumento:
JavaScript
functionsaudar(nome="visitante"){return`Bem-vindo(a) ao Café Cerrado, ${nome}!`;}saudar();// "Bem-vindo(a) ao Café Cerrado, visitante!"saudar("Ana");// "Bem-vindo(a) ao Café Cerrado, Ana!"// Função que recebe função: o coração do addEventListenerfunctionrepetir(vezes,acao){for(leti=1;i<=vezes;i++){acao(i);}}repetir(3,(n)=>console.log(`tentativa ${n}`));
Quando você escreve botao.addEventListener("click", minhaFuncao), você está entregando uma função ao navegador para que ele a chame depois. Repare no detalhe que derruba muita gente: passa-se minhaFuncao, sem os parênteses. Com parênteses você chamaria a função na hora e entregaria o resultado dela — quase sempre undefined.
Duas boas práticas que valem para o resto do curso:
Uma função, uma responsabilidade.renderizarProdutos desenha; filtrarProdutos filtra. Se o nome precisa de um "e" no meio, provavelmente são duas funções.
Prefira funções puras. Uma função pura recebe tudo pelos parâmetros, devolve um valor e não altera nada fora dela. É trivial de testar e não produz efeitos a distância. Nem tudo pode ser puro (mexer no DOM é efeito colateral por definição), mas quanto mais cálculo puro e menos DOM espalhado, melhor.
O navegador lê o HTML e constrói o DOM: uma árvore de objetos que representa a página viva na memória. O HTML é a receita; o DOM é o bolo. Alterar o DOM muda a tela na hora; recarregar a página joga o DOM fora e reconstrói tudo a partir do arquivo.
consttitulo=document.querySelector("h1");// o primeiro que casaconstlista=document.querySelector("#lista-produtos");// por idconstcards=document.querySelectorAll(".card-produto");// todos → NodeListconstprimeiroCafe=document.querySelector('[data-categoria="cafes"]');
querySelector aceita qualquer seletor CSS — todo o conhecimento da Unidade 1 vale aqui. Duas armadilhas:
JavaScript
cards.length;// NodeList tem length e forEach// cards.map(…); // TypeError: cards.map is not a function[...cards].map((c)=>c.dataset.id);// vire array antes de usar map/filter/reduce
E querySelectorAll devolve um retrato estático: elementos criados depois não entram nessa lista. Já document.getElementsByClassName devolve uma coleção viva, que se atualiza sozinha — comportamento surpreendente dentro de um laço. Prefira querySelectorAll.
titulo.textContent="Cardápio";// texto purotitulo.innerHTML="Cardápio <span>do dia</span>";// interpreta como HTML
⚠️ Atenção
Nunca coloque em innerHTML um valor que veio de fora — campo de formulário, parâmetro de URL, resposta de API. Uma string como <img src=x onerror="alert(1)"> seria executada: é a porta do ataque XSS (Cross-Site Scripting). Para escrever texto, textContent sempre. Ele trata tudo como texto, inclusive < e >, e ainda por cima é mais rápido, porque não invoca o analisador de HTML.
Atributo é o que está escrito no HTML; propriedade é o campo do objeto no DOM. Na maioria dos casos os dois andam juntos, mas não sempre:
JavaScript
constcampo=document.querySelector("#nome");campo.setAttribute("aria-describedby","erro-nome");// atributocampo.getAttribute("type");// "text"campo.hasAttribute("required");// truecampo.removeAttribute("disabled");campo.value="Ana";// propriedade: o valor atual digitadocampo.disabled=true;// propriedade booleana
Para guardar dados seus em um elemento, use data-*, que chega ao JavaScript pelo dataset:
constcard=document.querySelector(".card-produto");card.dataset.id;// "3" — sempre string!Number(card.dataset.id);// 3card.dataset.categoria;// "cafes"card.dataset.emEstoque="sim";// vira data-em-estoque="sim" no HTML
Repare: data-em-estoque no HTML vira dataset.emEstoque no JS (traço vira maiúscula). E todo valor de dataset é string — comparar com === contra um número sempre dá false.
card.classList.add("destaque");card.classList.remove("oculto");card.classList.toggle("aberto");// liga se está desligado e vice-versacard.classList.toggle("concluido",true);// força para ligadocard.classList.contains("destaque");// true/falsecard.classList.replace("antigo","novo");
toggle com segundo argumento é a forma mais limpa de sincronizar classe com um booleano do seu estado — sem if.
constitem=document.createElement("li");item.className="item-cardapio";item.textContent="Pão de Queijo Mineiro";constoutroItem=document.createElement("li");outroItem.textContent="Torta de Frango";constlista=document.querySelector("#lista-produtos");lista.appendChild(item);// insere no fimlista.prepend(outroItem);// insere no iníciolista.append(item,outroItem);// aceita vários de uma vez, e também texto puroitem.remove();// remove a si mesmolista.replaceChildren();// limpa o contêiner (melhor que innerHTML = "")
Montar um card inteiro com dez createElement funciona, mas espalha marcação dentro do JavaScript — e quem for mexer no visual vai ter que ler código. O elemento <template> resolve: ele guarda um pedaço de HTML inerte (não é exibido, imagens não são baixadas, scripts não rodam) que o JS clona quantas vezes precisar.
constmolde=document.querySelector("#template-produto");constcopia=molde.content.cloneNode(true);// true = clona com os filhoscopia.querySelector('[data-campo="nome"]').textContent="Espresso do Cerrado";document.querySelector("#lista-produtos").appendChild(copia);
molde.content é um DocumentFragment — um contêiner leve que não faz parte da página. Ao dar appendChild nele, o navegador move os filhos para o destino e descarta a casca. Duas vantagens: o HTML fica no HTML, e a inserção acontece de uma vez só.
constfragmento=document.createDocumentFragment();for(constprodutoofprodutos){constitem=document.createElement("li");item.textContent=produto.nome;fragmento.appendChild(item);// ainda fora do documento}lista.appendChild(fragmento);// uma única alteração no DOM
Para dez produtos, a diferença é imperceptível. Para quinhentos, aparece — principalmente no celular. Adotar o hábito agora não custa nada.
Crie o formatador uma vez e reutilize: construir um Intl.NumberFormat é caro comparado a chamar .format().
🧠 Você sabia?
O objeto Intl carrega, dentro do navegador, as regras de formatação de mais de 400 idiomas e regiões — vindas do CLDR, o repositório de dados de localização mantido pelo consórcio Unicode. Além de moeda, ele formata datas (Intl.DateTimeFormat), listas ("café, pão e bolo" com Intl.ListFormat), tempo relativo ("há 3 dias") e faz ordenação alfabética que entende acentos (Intl.Collator). Tudo isso sem uma linha de biblioteca externa.
Evento é qualquer acontecimento na página: clique, tecla, envio de formulário, rolagem, mudança de campo. O navegador é uma máquina orientada a eventos — entre um e outro, seu código simplesmente não roda.
constbotao=document.querySelector("#btn-tema");botao.addEventListener("click",(evento)=>{console.log("clicou em:",evento.target);});// Opções úteis do terceiro argumento:botao.addEventListener("click",aoClicarUmaVez,{once:true});// remove sozinho após dispararwindow.addEventListener("scroll",aoRolar,{passive:true});// promete não chamar preventDefault
Para remover um ouvinte, você precisa da mesma referência de função — por isso funções anônimas não podem ser removidas:
JavaScript
functionaoClicarUmaVez(){console.log("primeira e única vez");}botao.removeEventListener("click",aoClicarUmaVez);
Há uma forma mais moderna, que remove vários ouvintes de uma vez:
JavaScript
constcontrolador=newAbortController();document.addEventListener("keydown",aoTeclar,{signal:controlador.signal});window.addEventListener("resize",aoRedimensionar,{signal:controlador.signal});controlador.abort();// remove os dois de uma vez
Isso será muito útil na SPA da Aula 10, quando trocar de tela exigir desligar os ouvintes da tela anterior.
Toda função ouvinte recebe um objeto com o que aconteceu:
JavaScript
document.querySelector("#lista-produtos").addEventListener("click",(evento)=>{evento.target;// o elemento MAIS PROFUNDO onde o clique aconteceuevento.currentTarget;// o elemento onde o ouvinte foi registrado (#lista-produtos)evento.type;// "click"evento.preventDefault();// cancela o comportamento padrão do navegadorevento.stopPropagation();// impede que o evento suba na árvore});
A confusão entre target e currentTarget é a fonte número um de bugs em delegação. Se você clica no <h3> dentro do card, target é o <h3>; currentTarget continua sendo o contêiner que ouve.
preventDefault() cancela a ação natural do elemento: recarregar a página no submit, navegar no click de um link, digitar a tecla no campo. Cancele com critério — cancelar o padrão de um link sem oferecer navegação alternativa quebra a acessibilidade.
Um clique não acontece em um elemento só. Ele desce da raiz até o alvo (fase de captura) e depois sobe de volta (fase de borbulhamento). Ouvintes registrados sem opção especial escutam na subida — o que permite um truque fundamental:
JavaScript
constlista=document.querySelector("#lista-produtos");lista.addEventListener("click",(evento)=>{constbotao=evento.target.closest("[data-acao]");if(!botao)return;// clique fora de qualquer botão: ignoraconstcard=botao.closest(".card-produto");constid=Number(card.dataset.id);if(botao.dataset.acao==="detalhes")mostrarDetalhes(id);if(botao.dataset.acao==="favoritar")alternarFavorito(id);});
Isso é delegação de eventos: um único ouvinte no contêiner atende todos os filhos, inclusive os que ainda não existem. Como o cardápio é renderizado do array — e re-renderizado a cada filtro na próxima aula —, registrar ouvinte card por card significaria registrar tudo de novo a cada render. Com delegação, você registra uma vez e esquece.
closest(seletor) sobe a árvore a partir do elemento e devolve o primeiro ancestral (ou ele mesmo) que casa com o seletor — ou null. É o companheiro inseparável da delegação.
O menu do Café Cerrado é a navbar do Bootstrap, que já cuida do aria-expanded sozinha. Mas o padrão vale para qualquer botão que abre e fecha alguma coisa — um "ver mais" no card, um acordeão de perguntas frequentes — e você vai precisar dele no projeto autoral:
JavaScript
constgatilho=document.querySelector("#btn-detalhes");constpainel=document.querySelector("#painel-detalhes");gatilho.addEventListener("click",()=>{constaberto=painel.classList.toggle("aberto");gatilho.setAttribute("aria-expanded",String(aberto));});document.addEventListener("keydown",(evento)=>{if(evento.key!=="Escape")return;if(!painel.classList.contains("aberto"))return;painel.classList.remove("aberto");gatilho.setAttribute("aria-expanded","false");gatilho.focus();// devolve o foco a quem abriu — regra de ouro});
Três detalhes que separam um componente acessível de um componente quebrado: o aria-expanded acompanha o estado real; Escape fecha; e o foco volta para o botão que abriu, senão quem navega por teclado é despejado no início da página.
🔬 Investigue
Com o cardapio.html aberto, cole no Console: document.body.addEventListener("click", (e) => console.log(e.target.tagName, "→", e.currentTarget.tagName)). Agora clique no título de um card, na imagem e no espaço vazio ao lado. O segundo valor nunca muda (BODY), o primeiro muda sempre. Você acabou de ver o borbulhamento em ação — e por que a delegação funciona.
Antes de ir ao projeto, o esqueleto que organiza tudo o que vem pela frente:
JavaScript
// 1. ESTADO — a fonte única da verdadeconstprodutos=[];// os dadosletcategoriaAtiva="";// o que o usuário escolheu// 2. REFERÊNCIAS — selecionadas uma vez sóconstels={lista:document.querySelector("#lista-produtos"),filtro:document.querySelector("#filtro-categoria"),};// 3. RENDERIZAÇÃO — desenha a tela a partir do estadofunctionrenderizar(){constvisiveis=categoriaAtiva?produtos.filter((produto)=>produto.categoria===categoriaAtiva):produtos;els.lista.replaceChildren();for(constprodutoofvisiveis){els.lista.appendChild(criarCard(produto));}}// 4. EVENTOS — capturam a intenção e mudam o estadofunctionregistrarEventos(){els.filtro.addEventListener("change",(evento)=>{categoriaAtiva=evento.target.value;renderizar();});}// 5. INICIALIZAÇÃOfunctioniniciar(){registrarEventos();renderizar();}
O fluxo é sempre: usuário age → evento → o estado muda → renderizar() redesenha. Nunca corrija o DOM na mão para refletir um dado. Se o preço mudou, mude o array e renderize; não saia procurando o <p class="preco"> certo. Quando DOM e dados divergem, os bugs viram caça ao fantasma.
Esse é o mesmo princípio que Vue e React automatizam no Nível 3. Aprender a fazer na mão agora é o que vai fazer o framework parecer óbvio depois.
O contato.html já tem required, type="email" e minlength desde a Aula 03 — validação nativa do HTML. O navegador barra o envio e mostra um balão. O problema: esse balão não é estilizável, some sozinho, aparece só um por vez e alguns leitores de tela o anunciam mal.
A saída profissional não é jogar a validação nativa fora e reescrever tudo com if. É usar a Constraint Validation API: você desliga só a exibição do balão com novalidate no <form> e continua consultando o veredito do navegador pelo objeto validity de cada campo.
JavaScript
constform=document.querySelector("#form-contato");constcampo=form.elements.email;campo.validity.valid;// false se qualquer regra falhoucampo.validity.valueMissing;// true se required e vaziocampo.validity.typeMismatch;// true se type="email" e formato inválidocampo.validity.tooShort;// true se abaixo do minlengthcampo.validity.patternMismatch;// true se não casa com o patterncampo.checkValidity();// o resumo: true/false
Isso dá o melhor dos dois mundos: as regras continuam declaradas no HTML (onde qualquer pessoa as lê) e as mensagens são suas, em português, no lugar que você escolher.
JavaScript
functionmensagemDeErro(campo){constv=campo.validity;if(v.valueMissing)return"Este campo é obrigatório.";if(v.typeMismatch)return"Digite um e-mail no formato nome@dominio.com.";if(v.tooShort)return`Escreva pelo menos ${campo.minLength} caracteres.`;if(v.patternMismatch)return"O formato digitado não é aceito.";return"Valor inválido.";}
Para o erro ser percebido por quem usa leitor de tela, três atributos trabalham juntos: aria-invalid marca o campo como inválido, aria-describedby liga o campo ao parágrafo da mensagem, e a região com aria-live="polite" anuncia o resumo sem interromper. Você já preparou esse terreno na Aula 06 — hoje ele entra em uso.
📌 Vale gravar
Validação no cliente é conveniência, não segurança. Qualquer pessoa desliga o JavaScript, edita o HTML pelo DevTools ou manda a requisição direto pelo terminal. A validação que protege os dados é a do servidor, que você vai escrever na Aula 13. Toda regra precisa existir nos dois lados: no cliente para dar resposta rápida, no servidor para valer.
💻 Mão na massa — o Café Cerrado ganha comportamento¶
Cinco passos. Ao final, o cardápio é desenhado a partir de um array, o site tem alternância de tema com memória, e o formulário de contato valida com mensagens acessíveis.
Passo 1 — criar o js/app.js e ligá-lo em todas as páginas¶
Crie a pasta js/ e o arquivo js/app.js. Em index.html, cardapio.html e contato.html, adicione ao final do <head>, depois das tags do Bootstrap que você colocou na Aula 04:
cardapio.html (trecho do <head>)
HTML
<scriptsrc="js/app.js"defer></script>
Como o mesmo arquivo roda nas três páginas, ele vai tentar selecionar elementos que só existem em uma delas. A defesa é iniciar cada funcionalidade separadamente e sair cedo quando o elemento não existe:
js/app.js
JavaScript
// ===== Café Cerrado — camada de comportamento =====// Este arquivo roda em todas as páginas. Cada bloco "iniciar…" confere// se os elementos de que precisa existem antes de fazer qualquer coisa.functioniniciar(){iniciarTema();iniciarCardapio();iniciarContato();}iniciar();
Ainda não existem as três funções — o Console vai reclamar com Uncaught ReferenceError: iniciarTema is not defined. Os próximos passos preenchem cada uma. Escreva as funções acima da chamada iniciar() para manter a leitura de cima para baixo.
constprodutos=[{id:1,nome:"Espresso do Cerrado",categoria:"cafes",preco:6,descricao:"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.",imagem:"img/espresso.jpg",},{id:2,nome:"Coado da Casa",categoria:"cafes",preco:8.5,descricao:"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.",imagem:"img/coado.jpg",},{id:3,nome:"Cappuccino Sinop",categoria:"cafes",preco:12,descricao:"Espresso duplo, leite vaporizado e canela do Cerrado por cima.",imagem:"img/cappuccino.jpg",},{id:4,nome:"Latte de Baunilha",categoria:"cafes",preco:14,descricao:"Espresso, leite vaporizado e calda de baunilha feita na casa.",imagem:"img/latte.jpg",},{id:5,nome:"Cold Brew da Chapada",categoria:"geladas",preco:15,descricao:"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.",imagem:"img/cold-brew.jpg",},{id:6,nome:"Frappê de Café",categoria:"geladas",preco:16,descricao:"Espresso batido com gelo, leite e chantili. Também sai sem lactose.",imagem:"img/frappe.jpg",},{id:7,nome:"Pão de Queijo Mineiro",categoria:"salgados",preco:7,descricao:"Porção com quatro unidades de polvilho azedo com queijo canastra.",imagem:"img/pao-de-queijo.jpg",},{id:8,nome:"Torta de Frango",categoria:"salgados",preco:13,descricao:"Fatia generosa com massa amanteigada e recheio de frango desfiado.",imagem:"img/torta-de-frango.jpg",},{id:9,nome:"Bolo de Milho Verde",categoria:"doces",preco:9.5,descricao:"Fatia de bolo cremoso feito com milho da feira do produtor.",imagem:"img/bolo-de-milho.jpg",},{id:10,nome:"Brownie de Castanha",categoria:"doces",preco:11,descricao:"Chocolate meio amargo com castanha-do-pará. Sem glúten.",imagem:"img/brownie.jpg",},];constROTULOS_CATEGORIA={cafes:"Cafés",geladas:"Bebidas geladas",salgados:"Salgados",doces:"Doces",};constformatadorMoeda=newIntl.NumberFormat("pt-BR",{style:"currency",currency:"BRL",});functionformatarPreco(valor){returnformatadorMoeda.format(valor);}
São exatamente os dez produtos que você escreveu à mão nas Aulas 03 e 04, com os mesmos nomes, os mesmos preços e as mesmas quatro categorias — agora como dados, não como marcação. Este array é o contrato do projeto: ele vira js/dados.js na Aula 09, data/produtos.json na Aula 10 e as linhas da sua API na Unidade 3. Não invente produto novo aqui.
Coloque as dez imagens em img/. Se ainda não tem as fotos, use qualquer arquivo .jpg com o nome certo — o importante hoje é a estrutura. Nomes de arquivo sempre em minúsculas, sem acento e sem espaço: servidores Linux (como o do GitHub Pages) diferenciam maiúsculas de minúsculas, e Café.JPG funciona no seu Windows e quebra no ar.
Passo 3 — o cardápio renderizado a partir dos dados¶
Em cardapio.html, substitua os quatro grids escritos à mão por um contêiner vazio e o molde. As quatro <section> por categoria deixam de existir como marcação — mas as âncoras #cafes, #geladas, #salgados e #docesnão podem sumir: os botões "Ver" dos destaques de index.html (Aula 04) e a <nav> de atalhos apontam para elas. A solução é preservá-las como alvos de rolagem dentro da própria <nav> de atalhos, que agora vira o filtro visual do cardápio:
Agora a função que transforma um objeto em um card, e a que desenha a lista inteira:
js/app.js
JavaScript
functioncriarCardProduto(produto){constmolde=document.querySelector("#template-produto");constcopia=molde.content.cloneNode(true);constartigo=copia.querySelector(".card-produto");artigo.dataset.id=produto.id;artigo.dataset.categoria=produto.categoria;constimagem=copia.querySelector(".card-img-top");imagem.src=produto.imagem;imagem.alt=`Foto de ${produto.nome}`;imagem.loading="lazy";copia.querySelector('[data-campo="categoria"]').textContent=ROTULOS_CATEGORIA[produto.categoria];copia.querySelector('[data-campo="nome"]').textContent=produto.nome;copia.querySelector('[data-campo="descricao"]').textContent=produto.descricao;copia.querySelector('[data-campo="preco"]').textContent=formatarPreco(produto.preco);returncopia;}functionrenderizarProdutos(lista){constcontainer=document.querySelector("#lista-produtos");constaviso=document.querySelector("#cardapio-vazio");container.replaceChildren();aviso.classList.toggle("d-none",lista.length>0);constfragmento=document.createDocumentFragment();for(constprodutooflista){fragmento.appendChild(criarCardProduto(produto));}container.appendChild(fragmento);}functioniniciarCardapio(){constcontainer=document.querySelector("#lista-produtos");if(!container)return;// não estamos no cardapio.htmlrenderizarProdutos(produtos);container.addEventListener("click",(evento)=>{constcard=evento.target.closest(".card-produto");if(!card)return;constid=Number(card.dataset.id);constproduto=produtos.find((p)=>p.id===id);console.log("card clicado:",produto.nome,formatarPreco(produto.preco));});}
O ouvinte de clique já usa delegação: um só, no contêiner, atendendo os dez cards e os que vierem. Por enquanto ele só imprime no Console — na Aula 08 esse mesmo ouvinte vai jogar o produto no carrinho.
⚠️ Cuidado
Os quatro id que sobraram na <nav> (cafes, geladas, salgados, doces) existem para que os links antigos continuem funcionando: cardapio.html#geladas ainda leva alguém à página certa, agora rolando até a barra de atalhos em vez de até uma seção. Na Aula 08, quando o filtro por categoria entrar, esses mesmos links passam a acionar o filtro — e aí eles voltam a fazer o que prometem. Não apague nem renomeie esses id: eles são referenciados por index.html e pela <nav> do cabeçalho.
Três decisões que valem comentário. O estado inicial respeita a preferência do sistema operacional (prefers-color-scheme) quando não há escolha salva — é a mesma consulta de mídia que você usou no CSS na Aula 05. aria-pressed transforma o botão em um interruptor que leitores de tela anunciam como "pressionado" ou "não pressionado". E localStorage guarda a escolha no navegador da pessoa, sobrevivendo ao fechamento da aba — é a primeira vez que o Café Cerrado tem memória.
Passo 5 — validação acessível do formulário de contato¶
O formulário de contato.htmlnão muda de estrutura: continuam ali os dois <fieldset> com <legend>, o <select> de assunto com <optgroup>, telefone, CEP, pessoas, data, horário, os rádios de canal, o checkbox de novidades e o de consentimento — treze campos, os mesmos das Aulas 03, 04 e 06, que o Marco 1 cobre. Três acréscimos, e só:
id="a07-form-contato" e novalidate no <form>;
um <p class="invalid-feedback d-block m-0" id="a07-erro-…"> logo abaixo de cada campo que você vai validar, com o id do campo no nome (erro-nome, erro-email, erro-mensagem) e um aria-describedby no campo apontando para ele;
nada na região viva: o <p id="a07-status-envio"> da Aula 06 continua exatamente como está, e é ele que o JavaScript vai usar.
contato.html (os três campos que ganham parágrafo de erro; o restante do formulário fica intacto)
functionmensagemDeErro(campo){constv=campo.validity;if(v.valueMissing)return"Este campo é obrigatório.";if(v.typeMismatch)return"Digite um e-mail no formato nome@dominio.com.";if(v.tooShort)return`Escreva pelo menos ${campo.minLength} caracteres.`;return"Valor inválido.";}functionvalidarCampo(campo){constalvoDoErro=document.querySelector(`#erro-${campo.id}`);constvalido=campo.checkValidity();campo.setAttribute("aria-invalid",String(!valido));campo.classList.toggle("is-invalid",!valido);alvoDoErro.textContent=valido?"":mensagemDeErro(campo);returnvalido;}functioniniciarContato(){constform=document.querySelector("#form-contato");if(!form)return;constcampos=[form.elements.nome,form.elements.email,form.elements.mensagem];conststatus=document.querySelector("#status-envio");for(constcampoofcampos){// valida ao sair do campo e, depois do primeiro erro, a cada teclacampo.addEventListener("blur",()=>validarCampo(campo));campo.addEventListener("input",()=>{if(campo.getAttribute("aria-invalid")==="true")validarCampo(campo);});}form.addEventListener("submit",(evento)=>{evento.preventDefault();constinvalidos=campos.filter((campo)=>!validarCampo(campo));if(invalidos.length>0){status.textContent=`Corrija ${invalidos.length} campo(s) antes de enviar.`;status.className="status-envio text-danger";invalidos[0].focus();// leva a pessoa direto ao primeiro problemareturn;}status.textContent=`Obrigado, ${form.elements.nome.value.trim()}! Sua mensagem foi registrada.`;status.className="status-envio text-success";form.reset();for(constcampoofcampos){campo.removeAttribute("aria-invalid");campo.classList.remove("is-invalid");document.querySelector(`#erro-${campo.id}`).textContent="";}});}
Repare no ritmo da validação: o erro aparece quando a pessoa sai do campo (blur), não a cada tecla — ninguém merece ver "e-mail inválido" ao digitar a primeira letra. Depois que o erro apareceu, aí sim o input revalida a cada tecla, para o erro sumir assim que for corrigido. Esse é o comportamento que formulários bem-feitos têm.
Ainda não enviamos nada para lugar nenhum — a mensagem só é registrada na tela. O envio real vai depender do fetch da Aula 10 e da API da Unidade 3.
Abra cardapio.html com o Live Server. Devem aparecer dez cards em três colunas no desktop, uma no celular.
No DevTools, aba Elements, expanda #lista-produtos: os <div class="col"> estão lá, mas não estão no arquivo .html. Foi o JavaScript que os criou.
Cole document.querySelectorAll(".card-produto").length no Console. Deve devolver 10.
Clique em um card e confira a linha impressa no Console, com nome e preço formatado em reais.
Clique no botão de tema. A página inteira troca de cor; recarregue (F5) e a escolha permanece. No DevTools, Application → Local Storage, veja a chave cafe-cerrado:tema.
Em contato.html, clique em "Enviar mensagem" com tudo vazio: três mensagens aparecem, o foco vai para o campo Nome e a região de status anuncia "Corrija 3 campo(s) antes de enviar".
Digite ana@ no e-mail e saia do campo: aparece a mensagem de formato. Complete para ana@exemplo.br e a mensagem some sozinha.
Rode o Lighthouse de novo. A nota de acessibilidade não pode ter caído — se caiu, o culpado costuma ser uma imagem gerada sem alt.
Commit com mensagem descritiva:
Terminal
gitadd.
gitcommit-m"feat: cardapio renderizado por JS, alternancia de tema e validacao do formulario"
gitpush
A1. Preveja a saída de cada linha sem rodar, depois confira no Console:
JavaScript
constp={nome:"Espresso do Cerrado",preco:6,extras:{canela:false}};constcopia={...p,preco:7};copia.extras.canela=true;console.log(p.preco);console.log(p.extras.canela);console.log(Object.keys(copia).length);console.log(p.avaliacao?.media??"sem nota");
A2. O código abaixo está no <head> sem defer e falha com Uncaught TypeError: Cannot read properties of null (reading 'addEventListener'). Explique em duas linhas por que document.querySelector("#btn-tema") devolveu null e cite duas formas diferentes de corrigir.
A3. Qual é a diferença entre evento.target e evento.currentTarget no ouvinte de #lista-produtos do Passo 3? Clique no <h3> de um card e escreva o valor de cada um.
A4. Complete a função para que ela devolva o rótulo legível de uma categoria e a string "Outros" quando a categoria não existir no dicionário:
JavaScript
functionrotuloDaCategoria(chave){returnROTULOS_CATEGORIA[chave];}rotuloDaCategoria("doces");// deve devolver "Doces"rotuloDaCategoria("bebidas");// deve devolver "Outros"
A5. Verdadeiro ou falso, com justificativa de uma linha cada: (a) card.dataset.id === 3 é true quando o HTML tem data-id="a07-3"; (b) textContent é mais seguro que innerHTML para exibir um nome digitado por alguém; (c) querySelectorAll devolve um array; (d) com novalidate no <form>, campo.validity.valueMissing para de funcionar.
A6. Em três linhas, explique por que o array produtos guarda preco: 8.5 e categoria: "cafes" em vez de preco: "R$ 8,50" e categoria: "Cafés".
B1. Contador de itens no cardápio. Acima da grade de cards, mostre em um <p id="a07-contador-cardapio"> a frase "10 itens no cardápio", calculada a partir do array e atualizada dentro de renderizarProdutos.
Resultado esperado: apagar dois objetos do array e recarregar faz o texto virar "8 itens no cardápio", sem editar o HTML.
Dica
O número vem de lista.length, não de contar elementos no DOM. Atualize o texto no mesmo lugar onde você já decide se o aviso de lista vazia aparece.
B2. Destaque por faixa de preço. Faça os cards de produtos com preço abaixo de R$ 10 receberem a classe .economico, e defina no css/estilo.css uma borda ou selo para essa classe. O JavaScript não pode conter nenhuma cor.
Resultado esperado: três cards destacados (id 1, 2 e 6); mudar o preço de um produto no array muda o destaque ao recarregar, sem tocar no CSS.
Dica
artigo.classList.toggle("economico", produto.preco < 10) resolve em uma linha, dentro de criarCardProduto.
B3. Detalhes sob demanda. Adicione ao template um botão "Ver detalhes" e um parágrafo escondido com uma informação extra (por exemplo, Categoria: Cafés · Código 3). Use delegação no contêiner e mantenha aria-expanded sincronizado no botão.
Resultado esperado: cada card abre e fecha o próprio parágrafo; o aria-expanded do botão alterna entre "true" e "false" na aba Elements.
Dica
No ouvinte do contêiner, use evento.target.closest("[data-acao='detalhes']") para saber se o clique foi no botão, e botao.closest(".card-produto") para achar o card correspondente.
B4. Contador de caracteres da mensagem. No contato.html, mostre abaixo do <textarea> quantos caracteres faltam para atingir o minlength, atualizado a cada tecla, com aria-live="polite".
Resultado esperado: com o campo vazio, "faltam 10 caracteres"; ao passar do mínimo, "mensagem com tamanho suficiente".
Dica
O evento é input. O número que falta é campo.minLength - campo.value.length, nunca menor que zero — Math.max(0, …) resolve.
B5. Ordem alfabética por acento correto. Renderize o cardápio em ordem alfabética de nome usando Intl.Collator("pt-BR") em vez de comparar strings direto, e escreva no README uma frase explicando a diferença.
Resultado esperado: nenhum nome acentuado fica fora de lugar; a lista começa por "Bolo de Milho Verde" e termina em "Torta de Frango".
Dica
const comparador = new Intl.Collator("pt-BR"); e depois [...produtos].sort((a, b) => comparador.compare(a.nome, b.nome)). Copie o array antes de ordenar — a próxima aula explica por quê.
C1. Renderização à prova de dados sujos. Alguém vai alimentar o array produtos a partir de uma planilha, e a planilha é bagunçada: pode faltar descricao, o preco pode vir como a string "12,50", a imagem pode apontar para um arquivo inexistente e o nome pode conter <script>alert(1)</script>. Torne criarCardProduto resistente aos quatro casos, sem esconder problemas: campo ausente vira texto padrão, preço em string é convertido, imagem quebrada cai numa imagem genérica e o nome com HTML aparece como texto, sem executar nada.
Resultado esperado: com um array proposital de cinco itens defeituosos, a página renderiza os cinco cards sem nenhum erro no Console, e o card do nome malicioso mostra literalmente <script>alert(1)</script> na tela.
Dica
Para o preço, Number(String(valor).replace(",", ".")) seguido de Number.isFinite. Para a imagem, o evento error do próprio <img> (imagem.addEventListener("error", () => { imagem.src = "img/sem-foto.jpg"; })). Para o nome, você já está protegido se usar textContent — comprove trocando por innerHTML e vendo a diferença. E ?? "Descrição em breve." cobre o campo ausente.
Um colega mexeu no app.js do Café Cerrado antes de sair de férias e agora o cardápio não aparece. O Console mostra Uncaught TypeError: Cannot read properties of null (reading 'content') e mais nada. Ele plantou três defeitos diferentes neste trecho — um de seletor, um de argumento esquecido e um de formatação, e só o primeiro produz mensagem de erro. Encontre os três usando o DevTools, não lendo o código linha a linha até adivinhar.
JavaScript
// js/app.js — versão com defeitosconstmolde=document.querySelector("#template_produto");functioncriarCardProduto(produto){constcopia=molde.content.cloneNode();copia.querySelector('[data-campo="nome"]').textContent=produto.nome;copia.querySelector('[data-campo="preco"]').textContent="R$ "+produto.preco;returncopia;}functionrenderizarProdutos(lista){constcontainer=document.querySelector("#lista-produtos");for(constprodutooflista){container.appendChild(criarCardProduto(produto));}}renderizarProdutos(produtos);
Critérios de pronto
Um arquivo DEPURACAO.md no repositório lista os três defeitos, cada um com: a mensagem de erro exata que ele produz, o recurso do DevTools que o revelou e a correção aplicada.
Pelo menos um dos defeitos foi localizado com um ponto de parada (aba Sources ou debugger;), com print da tela pausada mostrando o valor da variável no painel Scope.
Depois das três correções, os dez cards aparecem e o Console fica limpo.
Uma frase final explica por que cloneNode() sem argumento devolve um nó vazio.
Pistas
A primeira mensagem já entrega o primeiro defeito: se molde é null, o querySelector não achou nada. Compare caractere por caractere o seletor com o id no HTML — traço e sublinhado não são a mesma coisa.
cloneNode() aceita um argumento booleano. Leia na MDN o que muda entre cloneNode() e cloneNode(true).
Coloque debugger; na primeira linha de criarCardProduto e inspecione copia no painel Scope: quantos filhos ele tem?
O terceiro defeito não gera erro — gera texto errado na tela. Compare o preço exibido do "Coado da Casa" com o do "Espresso do Cerrado" e pense no que "R$ " + 6 produz.
Troque textContent por innerHTML em criarCardProduto e adicione ao array um produto chamado <img src=x onerror="document.body.style.filter='invert(1)'">. Recarregue a página. O que acontece é uma versão inofensiva de um ataque XSS armazenado — o mesmo mecanismo que, com outro código dentro, rouba a sessão de quem visita o site. Sua tarefa é entender o mecanismo, medir o estrago possível e blindar o render.
Critérios de pronto
Um print (ou GIF) mostra a página sendo alterada pelo "produto" malicioso, e o DEPURACAO.md explica em três linhas por que o onerror roda mesmo sem ninguém clicar em nada.
A versão corrigida usa textContent para todo dado variável e continua funcionando com o produto malicioso no array — que agora aparece como texto na tela.
Existe uma função escaparHtml(texto) no projeto, com teste manual documentado, para o caso em que você precisa montar HTML (por exemplo, destacar o termo buscado em negrito na Aula 08).
O README ganha um parágrafo curto respondendo: se os produtos viessem de um banco de dados alimentado por outros usuários, em que camadas essa proteção precisaria existir?
Pistas
Procure "Cross-site scripting" na MDN e leia a diferença entre XSS refletido e armazenado.
O onerror dispara porque src=x é um caminho inválido — o navegador tenta carregar, falha, e executa o manipulador. Nenhum clique é necessário.
Uma implementação curta de escaparHtml usa um elemento descartável: crie um div, atribua o texto com textContent e leia de volta o innerHTML.
A resposta do README tem mais de uma camada: entrada (validação), armazenamento e saída (escape na renderização). Pense em qual delas é obrigatória mesmo que as outras existam.
Delegação parece um detalhe de estilo até você medir. Gere 200 produtos falsos, renderize duas versões do cardápio — uma registrando addEventListener em cada card, outra com um único ouvinte no contêiner — e compare tempo de renderização e memória. Depois responda com dados: a delegação vale a pena por performance, por manutenção, ou pelos dois?
Critérios de pronto
Um script gera os 200 produtos a partir do array real (variando nome, preço e categoria), sem 200 objetos escritos à mão.
O tempo das duas versões é medido com performance.now() em volta da renderização, com pelo menos 5 execuções de cada e a mediana registrada.
Uma tabela no DEPURACAO.md compara: número de ouvintes (visível em Elements → Event Listeners), tempo mediano de render e o que acontece com cada versão quando a lista é re-renderizada 10 vezes seguidas.
Um parágrafo final defende uma das duas abordagens para o Café Cerrado, citando os números obtidos.
Pistas
Array.from({ length: 200 }, (_, i) => ({ ...produtos[i % produtos.length], id: i + 1 })) gera a base sem repetir código.
performance.now() devolve milissegundos com casas decimais; guarde o valor antes e depois e subtraia.
No DevTools, o painel Memory tira um "heap snapshot"; compare o número de objetos EventListener entre as duas versões.
Re-renderizar 10 vezes é onde a versão sem delegação escorrega: descubra o que acontece com os ouvintes dos elementos removidos e por que isso pode virar vazamento de memória.
Quem entra no Café Cerrado toda semana sempre pede as mesmas duas coisas. Dê a essa pessoa um jeito de marcar favoritos que sobreviva ao fechamento do navegador, funcione só com teclado, seja anunciado corretamente por leitores de tela e não dependa de nenhuma biblioteca. É um exercício completo de estado, persistência, renderização e acessibilidade — as quatro coisas de hoje juntas.
Critérios de pronto
Cada card tem um botão de favoritar com aria-pressed refletindo o estado, rótulo acessível que inclui o nome do produto (por exemplo, "Favoritar Espresso do Cerrado") e foco visível.
Os favoritos são guardados em localStorage sob uma única chave, como array de id, e sobrevivem a recarregar a página e fechar o navegador.
Existe um botão "Mostrar só favoritos" que alterna a lista renderizada, com contagem ("3 favoritos") atualizada, e um estado vazio próprio ("Você ainda não marcou favoritos").
Toda a interação funciona sem mouse: Tab até o botão, Enter ou Espaço para marcar, e a mudança é anunciada em uma região aria-live.
O estado dos favoritos vive em uma variável de estado; a tela é sempre redesenhada a partir dela, nunca corrigida na mão.
O localStorage é lido dentro de try/catch: em janela anônima com armazenamento bloqueado, o site continua funcionando sem favoritos, em vez de quebrar.
Pistas
localStorage só guarda strings: JSON.stringify(favoritos) para gravar, JSON.parse(localStorage.getItem(chave) ?? "[]") para ler.
Um Set é mais confortável que um array para "contém / adiciona / remove"; converta com [...conjunto] na hora de salvar.
Um <button> de verdade já responde a Enter e Espaço sem nenhum código. Se você usar <div> ou <span>, vai ter que reimplementar teclado, foco e papel — não faça isso.
Para o anúncio, uma única região aria-live="polite" na página, com texto trocado a cada ação ("Espresso do Cerrado adicionado aos favoritos"), é melhor do que uma região por card.
Ao re-renderizar depois de favoritar, o foco se perde. Guarde o id do card afetado e devolva o foco ao botão correspondente depois do render — esse detalhe é o que separa um protótipo de um componente utilizável.
Crie js/app.js e vincule com defer em todas as páginas.
Modele os itens do seu domínio (produtos, serviços, plantas, quadras, vagas) como um array de objetos com, no mínimo, os campos id, nome, categoria, preco (ou outro valor numérico) e descricao.
Substitua os cards escritos à mão de uma das páginas pela renderização a partir desse array, usando <template> e textContent.
Implemente uma interação com evento: alternância de tema, abrir/fechar detalhes de um item ou menu próprio — com o atributo ARIA correspondente sincronizado.
Adicione ao seu formulário a validação de pelo menos dois campos com mensagens em português e foco no primeiro campo inválido.
Critério de pronto: o Console fica sem erros nas três páginas; os cards existem no DOM mas não no arquivo .html; o formulário mostra mensagem própria e não recarrega a página ao ser enviado vazio.
Guarde no seu repositório: commit + push.
Leitura dirigida (se você tem acesso a uma biblioteca virtual pela sua instituição): Queirós e Portela, capítulo da camada de comportamento (JavaScript); Purewal, capítulo de JavaScript e interatividade.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — camada de comportamento: JavaScript.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — JavaScript e interatividade.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — organização de código JavaScript.
Na próxima aula as funções deixam de ser coadjuvantes: arrow functions, callbacks e as operações de vetores (map, filter, find, sort, reduce) entram em cena, e o cardápio ganha busca por nome, filtro por categoria, ordenação por preço e um carrinho que soma sozinho.
Nível 2Unidade 2 · Web dinâmica client-side3 aulas de 50 min + 1 h EAD
Aula 08 — Arrow functions, callbacks e operações em vetores
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 07 você plantou uma ideia quase de passagem: em JavaScript, função é um valor. Ela apareceu no addEventListener, quando você entregou uma função ao navegador para que ele a chamasse depois. Hoje essa ideia deixa de ser detalhe e vira a ferramenta principal. Arrow functions, callbacks e os métodos de array que os recebem são o vocabulário com que se escreve JavaScript profissional — e são exatamente o que as Promises da próxima aula pressupõem que você já domina.
Na aula passada o Café Cerrado ganhou js/app.js: o cardápio virou o array produtos e passou a ser desenhado por renderizarProdutos, o formulário de contato passou a validar com mensagens acessíveis, e um ouvinte por delegação já escuta os cliques nos cards. Hoje o cardápio ganha inteligência — busca, filtro por categoria, ordenação por preço e um carrinho que soma sozinho —, tudo construído com callbacks e métodos de array.
Checklist antes de começar:
[ ] js/app.js da Aula 07 funcionando: dez cards renderizados a partir do array produtos.
[ ] O elemento <template id="a08-template-produto"> e o contêiner #lista-produtos no cardapio.html.
[ ] As funções criarCardProduto, renderizarProdutos e formatarPreco no seu app.js.
[ ] O dicionário ROTULOS_CATEGORIA mapeando as chaves de categoria para o texto exibido.
[ ] Console do DevTools aberto — hoje metade dos exercícios acontece nele.
Do Nível 1 você já traz laços for e for…of (Aula 12) e funções (Aula 13). Hoje a maioria desses laços vai desaparecer do seu código, substituída por métodos que dizem o que você quer, em vez de como percorrer.
// 1. Declaração (function declaration)functiondobrar(numero){returnnumero*2;}// 2. Expressão de função — a função é um valor guardado numa variávelconsttriplicar=function(numero){returnnumero*3;};// 3. Arrow function (ES2015) — a forma que vai dominar seu código daqui em dianteconstquadruplicar=(numero)=>{returnnumero*4;};dobrar(5);// 10triplicar(5);// 15quadruplicar(5);// 20
As três são chamadas exatamente do mesmo jeito. As diferenças são três, e todas importam:
Hoisting. Declarações são içadas para o topo do escopo: você pode chamar dobrar(5) numa linha acima da definição. Expressões e arrows, não — a variável existe, mas ainda não recebeu valor, e você recebe ReferenceError: Cannot access 'triplicar' before initialization.
JavaScript
console.log(dobrar(5));// 10 — funciona, a declaração foi içadaconsole.log(triplicar(5));// ReferenceError: Cannot access 'triplicar' before initializationfunctiondobrar(n){returnn*2;}consttriplicar=function(n){returnn*3;};
Nome nas pilhas de erro. Uma função nomeada aparece com nome no rastro de erro do Console; uma anônima aparece como <anonymous>. Atribuir a arrow a uma const já resolve: o motor usa o nome da variável.
this. A diferença mais profunda, tratada na seção 2.4.
Na prática, a convenção que este curso adota é a mesma da maioria dos times: declaração para funções nomeadas de topo (function renderizarProdutos(lista)), arrow para tudo que é passado como argumento (callbacks de evento, de map, de filter). É o que você já viu no app.js da Aula 07.
constdobrar=(numero)=>{returnnumero*2;};// forma completaconstdobrar=(numero)=>numero*2;// sem chaves: retorno IMPLÍCITOconstdobrar=numero=>numero*2;// 1 parâmetro: parênteses opcionaisconstsomar=(a,b)=>a+b;// 2+ parâmetros: parênteses obrigatóriosconstagora=()=>newDate();// sem parâmetros: () obrigatórioconstnaoFazNada=()=>{};// corpo vazio: útil como callback padrão
Este curso mantém os parênteses mesmo com um parâmetro só ((numero) => …). É a convenção do Prettier e do Vue, e evita reescrever a linha quando aparecer um segundo parâmetro. Consistência vale mais que dois caracteres.
As chaves abrem um corpo de função comum, que só devolve algo com return explícito. Ou tudo em uma expressão sem chaves, ou chaves com return. Não existe meio-termo — e este é o erro número um de quem está aprendendo a sintaxe.
constcriarItem=(nome,preco)=>{nome,preco};// undefined — as chaves viram corpoconstcriarItemCerto=(nome,preco)=>({nome,preco});// objeto, graças aos parênteses
Os parênteses avisam o interpretador: "o que vem aqui é uma expressão, não um bloco". Você vai usar isso o tempo todo em map, para transformar cada item em um objeto novo.
Arrow functions não têm this próprio. Elas herdam o this do lugar onde foram escritas. Em métodos de objeto, isso quebra tudo:
JavaScript
constcarrinho={itens:[{nome:"Espresso do Cerrado",preco:6,quantidade:2},{nome:"Torta de Frango",preco:13,quantidade:1},],// Certo: sintaxe de método — this é o objeto carrinhototal(){returnthis.itens.reduce((soma,item)=>soma+item.preco*item.quantidade,0);},// Errado: arrow como método — this NÃO é o carrinhoquantidadeTotal:()=>{returnthis.itens.length;},};carrinho.total();// 31.5carrinho.quantidadeTotal();// TypeError: Cannot read properties of undefined (reading 'itens')
Repare que dentro de total() a arrow do reduce funciona perfeitamente — ela herda o this do método, que é o carrinho. Essa herança é justamente a vantagem: antes do ES2015, programadores escreviam const self = this; para conseguir o mesmo efeito.
A mesma pegadinha aparece em ouvintes de evento:
JavaScript
botao.addEventListener("click",function(){this.disabled=true;// this é o botão});botao.addEventListener("click",()=>{this.disabled=true;// this NÃO é o botão});botao.addEventListener("click",(evento)=>{evento.currentTarget.disabled=true;// funciona sempre, com qualquer sintaxe});
A regra prática que evita o assunto inteiro: use evento.currentTarget, nunca this, dentro de ouvintes. E use sintaxe de método (total() { }) quando o objeto precisa se referir a si mesmo.
⚠️ Atenção
Arrow functions também não têm arguments, não podem ser usadas com new e não podem ser geradoras. Nada disso atrapalha o que você escreve nesta unidade — mas explica por que uma arrow nunca substitui um construtor ou um método de classe que dependa de this.
Callback é uma função passada como argumento para outra função, que decide quando e quantas vezes chamá-la. Você já usa desde a Aula 07, sem o nome:
JavaScript
// O navegador chama a sua função quando o clique acontecer — talvez nunca:botao.addEventListener("click",()=>console.log("clicou"));// setTimeout chama UMA vez, depois do tempo dado (em milissegundos):setTimeout(()=>console.log("2 segundos depois"),2000);// setInterval chama REPETIDAMENTE, até você cancelar:constrelogio=setInterval(()=>console.log("tique"),1000);clearInterval(relogio);
Uma função que recebe (ou devolve) outra função é chamada de função de ordem superior. Você também escreve as suas:
Callbacks com valor padrão evitam um if a cada chamada:
JavaScript
functionsalvar(dados,aoSucesso=()=>{},aoErro=()=>{}){if(!dados.nome){aoErro("Nome é obrigatório.");return;}aoSucesso(dados);}salvar({nome:"Ana"},(d)=>console.log("salvo:",d.nome));salvar({},undefined,(mensagem)=>console.warn(mensagem));
Volte ao setTimeout e observe a ordem de execução:
JavaScript
console.log("1 — início");setTimeout(()=>console.log("3 — o callback, depois"),0);console.log("2 — fim do script");// Saída: 1, 2, 3 — mesmo com 0 milissegundos de espera
O programa não para para esperar. Ele agenda o callback e segue. Essa é a sua primeira visão da execução assíncrona, tema central da próxima aula.
Isso ficou conhecido como callback hell — ou "pirâmide da perdição". Três problemas: a indentação cresce sem limite, a ordem de leitura não é a ordem de execução, e tratar erro exige repetir verificação em cada nível. Foi exatamente para resolver isso que as Promises foram criadas. Você as conhece na próxima aula; hoje basta entender o problema.
🧠 Você sabia?
A ideia de passar funções como valores não nasceu no JavaScript — vem do Lisp, de 1958, e da programação funcional. map, filter e reduce são desse mesmo tronco: já existiam em Lisp antes de existir mouse. O que mudou foi a escala: em 2004, dois engenheiros do Google publicaram o artigo MapReduce, mostrando como aplicar essas duas operações em milhares de máquinas para processar a web inteira. O map que você vai escrever agora, em dez produtos, é a mesma ideia — em outra ordem de grandeza.
Os métodos de array recebem um callback e o aplicam a cada elemento. Eles substituem a maioria dos laços for por código mais curto e, principalmente, declarativo: você diz o que quer, não como percorrer.
A base de exemplos é o cardápio da Aula 07:
JavaScript
constprodutos=[{id:1,nome:"Espresso do Cerrado",categoria:"cafes",preco:6},{id:2,nome:"Coado da Casa",categoria:"cafes",preco:8.5},{id:3,nome:"Cappuccino Sinop",categoria:"cafes",preco:12},{id:4,nome:"Latte de Baunilha",categoria:"cafes",preco:14},{id:5,nome:"Cold Brew da Chapada",categoria:"geladas",preco:15},{id:6,nome:"Frappê de Café",categoria:"geladas",preco:16},{id:7,nome:"Pão de Queijo Mineiro",categoria:"salgados",preco:7},{id:8,nome:"Torta de Frango",categoria:"salgados",preco:13},{id:9,nome:"Bolo de Milho Verde",categoria:"doces",preco:9.5},{id:10,nome:"Brownie de Castanha",categoria:"doces",preco:11},];
Cada callback recebe até três argumentos: o item, o índice e o array inteiro. Quase sempre você usa só o primeiro.
forEach devolve undefined. Ele existe para o efeito colateral (imprimir, inserir no DOM), não para produzir um valor. E não dá para interromper no meio: break é erro de sintaxe e return só pula um item. Se você precisa parar antes do fim, use for…of, find ou some.
constnomes=produtos.map((produto)=>produto.nome);// ["Espresso do Cerrado", "Coado da Casa", "Cappuccino Sinop", …] — 10 nomesconstcomDesconto=produtos.map((produto)=>({...produto,preco:produto.preco*0.9}));// novo array, 10% mais barato; o array original continua intactoconstopcoes=produtos.map((produto)=>`<option value="${produto.id}">${produto.nome}</option>`);
map sempre devolve um array do mesmo tamanho do original. Se o seu map não usa o valor devolvido, você queria forEach.
O callback do filter é um teste: devolva true para manter o item, false para descartar. O resultado tem de 0 a N itens — e nunca é null, o que dispensa verificação antes de percorrer.
produtos.find((p)=>p.preco>12);// { id: 4, nome: "Latte de Baunilha", … } — o PRIMEIROprodutos.find((p)=>p.preco>100);// undefined — cuidado ao acessar .nome depoisprodutos.findIndex((p)=>p.id===5);// 4 — a posição, ou -1 se não acharprodutos.some((p)=>p.preco>13);// true — ALGUM passa no teste?produtos.every((p)=>p.preco<20);// true — TODOS passam?nomes.includes("Espresso do Cerrado");// true — comparação direta, sem callback
find é o irmão eficiente do filter: para na primeira ocorrência e devolve o item, não um array. Depois de um find, sempre lembre que o resultado pode ser undefined:
JavaScript
constescolhido=produtos.find((p)=>p.id===Number(idClicado));if(!escolhido)return;// guarda de segurançaconsole.log(escolhido.nome);
reduce é o mais poderoso e o mais temido. Ele recebe dois argumentos: um callback (acumulador, item) => novoAcumulador e o valor inicial do acumulador.
JavaScript
consttotal=produtos.reduce((soma,produto)=>soma+produto.preco,0);// 112 — soma começa em 0 e vai acumulando
⚠️ Atenção
Sempre passe o valor inicial. Sem ele, reduce usa o primeiro item como acumulador — e em um array vazio estoura TypeError: Reduce of empty array with no initial value. A única exceção legítima é o caso "achar o extremo" acima, em que o acumulador é do mesmo tipo dos itens e o array comprovadamente não está vazio.
🧠 Você sabia?
Agrupar com reduce é tão comum que a linguagem ganhou um atalho: Object.groupBy(produtos, (p) => p.categoria) faz o mesmo que o bloco acima, em uma linha. Ele entrou no ES2024 e já está disponível nos navegadores atuais e no Node 21 ou superior. Vale conhecer — e vale continuar sabendo escrever o reduce na mão, porque é ele que aparece nas bases de código com alguns anos de vida.
⚠️ Atençãosortaltera o array original e devolve o mesmo array (não uma cópia). É o único método desta seção que faz isso, junto de reverse, push, splice e pop. Ordenar produtos direto significa perder a ordem original para sempre — e o próximo filtro passa a trabalhar sobre uma lista embaralhada. Sempre copie antes: [...produtos].sort(…).
Como map e filter devolvem arrays, as operações se encaixam umas nas outras:
JavaScript
// "Nomes dos cafés e doces abaixo de R$ 12, do mais barato ao mais caro"constresultado=produtos.filter((p)=>p.categoria==="cafes"||p.categoria==="doces").filter((p)=>p.preco<12).sort((a,b)=>a.preco-b.preco).map((p)=>p.nome);// ["Espresso do Cerrado", "Coado da Casa", "Bolo de Milho Verde", "Brownie de Castanha"]
Repare que o .sort() aqui é seguro sem copiar: o filter anterior já produziu um array novo, e é esse array intermediário que é ordenado. produtos continua intacto. A regra fica assim: copie antes de ordenar apenas quando o sort for a primeira operação da cadeia.
Uma cadeia percorre a lista uma vez por operação — quatro passagens no exemplo acima. Para dez produtos isso é irrelevante; para cem mil, um único reduce seria mais rápido. Legibilidade primeiro; otimize quando medir e comprovar que precisa.
🔬 Investigue
Cole no Console: [10, 9, 100, 25].sort() e depois [10, 9, 100, 25].sort((a, b) => a - b). Agora o teste que revela a mutação: const n = [3, 1, 2]; const m = n.sort(); m.push(99); console.log(n);. O 99 aparece em n também — porque m e n são o mesmo array. Repita trocando n.sort() por [...n].sort() e veja a diferença.
Os métodos de array não aposentaram o laço. Prefira for…of quando precisar interromper no meio (break), quando o corpo for assíncrono e precisar de await em sequência (Aula 09), ou quando estiver processando centenas de milhares de itens e tiver medido a diferença. Fora esses casos, o método declarativo comunica melhor a intenção.
Todo bug de "o filtro parou de funcionar depois que ordenei" tem a mesma raiz: alguém alterou o array de dados. O array produtos é a sua fonte da verdade. Filtros, ordenações e transformações produzem visões dele — nunca o substituem.
JavaScript
// Errado: destrói a ordem original e o dadoprodutos.sort((a,b)=>a.preco-b.preco);produtos.forEach((p)=>{p.preco=p.preco*0.9;});// Certo: derive uma lista nova a cada necessidadeconstordenados=[...produtos].sort((a,b)=>a.preco-b.preco);constpromocionais=produtos.map((p)=>({...p,preco:p.preco*0.9}));
A linguagem ganhou versões que não alteram nada. Elas fazem parte do padrão desde 2023 e já funcionam nos navegadores atuais:
JavaScript
constordenados=produtos.toSorted((a,b)=>a.preco-b.preco);// cópia ordenadaconstinvertidos=produtos.toReversed();// cópia invertidaconsttrocado=produtos.with(0,{...produtos[0],preco:7});// cópia com um item trocado
[...array].sort(…) continua sendo o jeito mais compatível, e é o que este curso usa. Conheça toSorted para ler código moderno.
Na Aula 07 o fluxo era: estado → renderização → eventos. Com filtros entra uma etapa no meio:
JavaScript
constestado={termo:"",categoria:"",ordenacao:"nome",};// DERIVAÇÃO: uma função pura que calcula o que deve aparecer,// a partir do estado e da fonte da verdade. Não toca no DOM.functionprodutosVisiveis(){returnprodutos.filter((produto)=>produto.nome.toLowerCase().includes(estado.termo)).filter((produto)=>estado.categoria===""||produto.categoria===estado.categoria).sort(ORDENADORES[estado.ordenacao]);}// RENDERIZAÇÃO: desenha o resultado da derivaçãofunctionrender(){constvisiveis=produtosVisiveis();renderizarProdutos(visiveis);atualizarResumo(visiveis);}
Três propriedades tornam esse desenho robusto:
A lista visível nunca é guardada. Ela é recalculada a cada render(). Não existe a possibilidade de o estado e a tela discordarem.
produtosVisiveis é uma função pura. Recebe (pelo estado) e devolve, sem tocar no DOM nem alterar produtos. Dá para testá-la no Console isoladamente.
Cada evento faz duas coisas e só: muda um campo do estado e chama render(). Nenhum ouvinte manipula o DOM diretamente.
Quando quiser um filtro novo, o roteiro é sempre o mesmo: um campo a mais no estado, um .filter a mais na derivação, um ouvinte a mais. Nada mais precisa mudar.
O evento input dispara a cada tecla. Digitar "cappuccino" são dez renderizações completas do cardápio. Com dez produtos ninguém percebe; com uma busca que consulta um servidor (Aula 10), são dez requisições para uma pesquisa só.
A técnica é adiar a execução até que a pessoa pare de digitar por um instante. E o mais bonito: ela se escreve como uma função que devolve outra função — o auge de "função é valor".
Como funciona: cada chamada cancela o agendamento anterior e cria um novo. Só sobrevive o último — o que acontece quando a pessoa para de digitar por 300 ms. A variável temporizador continua viva entre as chamadas porque a função devolvida a "lembra": isso é um closure, e é o mesmo mecanismo que faz um contador manter sua contagem.
JavaScript
constbuscar=comAtraso((termo)=>console.log("buscando por:",termo),300);buscar("c");buscar("ca");buscar("caf");// só esta chega ao console, 300 ms depois
🔎 Por baixo do capôsetTimeout devolve um identificador numérico, não a função agendada. clearTimeout(id) diz ao navegador para descartar aquele agendamento antes que a fila de tarefas o alcance. Nenhuma das chamadas canceladas chega a executar — não é que elas rodem e o resultado seja ignorado: elas simplesmente somem da fila. Você vai olhar essa fila de frente na próxima aula, quando falarmos de event loop.
Isso é o padrão Strategy (Estratégia): um conjunto de algoritmos intercambiáveis, encapsulados atrás de uma mesma interface, escolhidos em tempo de execução. Acrescentar "ordenar por categoria" agora é acrescentar uma linha ao objeto — a função ordenar não muda. Em JavaScript o padrão fica quase invisível, porque funções são valores e um objeto já serve de registro de estratégias; em linguagens sem essa facilidade, ele exige uma interface e uma classe por algoritmo. Você vai reencontrar Strategy no Nível 3, e a essa altura ele já vai parecer óbvio.
💻 Mão na massa — busca, filtros, ordenação e carrinho¶
Cinco passos, todos no cardapio.html e no js/app.js da Aula 07.
<formclass="row g-3 align-items-end mb-4"id="controles-cardapio"role="search"><divclass="col-12 col-md-5"><labelclass="form-label"for="busca">Buscar no cardápio</label><inputclass="form-control"type="search"id="busca"name="busca"placeholder="café, pão de queijo, brownie"autocomplete="off"></div><divclass="col-6 col-md-3"><labelclass="form-label"for="filtro-categoria">Categoria</label><selectclass="form-select"id="filtro-categoria"name="categoria"><optionvalue="">Todas</option></select></div><divclass="col-6 col-md-4"><labelclass="form-label"for="ordenacao">Ordenar por</label><selectclass="form-select"id="ordenacao"name="ordenacao"><optionvalue="nome">Nome (A a Z)</option><optionvalue="preco-asc">Preço (menor primeiro)</option><optionvalue="preco-desc">Preço (maior primeiro)</option></select></div></form><pclass="text-secondary"id="resumo-cardapio"role="status"aria-live="polite"></p>
O <select> de categoria tem só a opção "Todas": as demais são geradas do array, para que acrescentar um produto de categoria nova apareça no filtro sem editar HTML.
Acrescente também o botão ao template do card, dentro do card-body, depois do preço:
cardapio.html (dentro do <template id="a08-template-produto">)
HTML
<buttonclass="btn btn-sm btn-cafe-vazado mt-3 align-self-start"type="button"data-acao="adicionar">
Adicionar ao pedido
</button>
E, ao final da seção do cardápio, o painel do pedido:
No js/app.js, logo abaixo do array produtos e das constantes da Aula 07:
js/app.js
JavaScript
// ===== Estado da tela do cardápio =====constestado={termo:"",categoria:"",ordenacao:"nome",carrinho:[],};constcomparadorPtBr=newIntl.Collator("pt-BR",{sensitivity:"base"});// Estratégias de ordenação (padrão Strategy): a chave vem do <select>constORDENADORES={nome:(a,b)=>comparadorPtBr.compare(a.nome,b.nome),"preco-asc":(a,b)=>a.preco-b.preco,"preco-desc":(a,b)=>b.preco-a.preco,};// Derivação pura: do estado para a lista que deve aparecerfunctionprodutosVisiveis(){constcomparador=ORDENADORES[estado.ordenacao]??ORDENADORES.nome;returnprodutos.filter((produto)=>produto.nome.toLowerCase().includes(estado.termo)).filter((produto)=>estado.categoria===""||produto.categoria===estado.categoria).sort(comparador);}
O .sort() no fim da cadeia é seguro: ele opera sobre o array produzido pelo filter, não sobre produtos.
functionadicionarAoCarrinho(id){constjaNoCarrinho=estado.carrinho.find((item)=>item.id===id);if(jaNoCarrinho){jaNoCarrinho.quantidade+=1;}else{constproduto=produtos.find((p)=>p.id===id);if(!produto)return;estado.carrinho.push({id:produto.id,nome:produto.nome,preco:produto.preco,quantidade:1,});}renderizarCarrinho();}functionremoverDoCarrinho(id){estado.carrinho=estado.carrinho.filter((item)=>item.id!==id);renderizarCarrinho();}functiontotalDoCarrinho(){returnestado.carrinho.reduce((total,item)=>total+item.preco*item.quantidade,0);}functionrenderizarCarrinho(){constlista=document.querySelector("#lista-carrinho");constcontador=document.querySelector("#contador-carrinho");consttotal=document.querySelector("#total-carrinho");lista.replaceChildren();if(estado.carrinho.length===0){constvazio=document.createElement("li");vazio.className="list-group-item text-secondary";vazio.textContent="Nenhum item no pedido ainda.";lista.appendChild(vazio);}else{constfragmento=document.createDocumentFragment();estado.carrinho.forEach((item)=>{constli=document.createElement("li");li.className="list-group-item d-flex justify-content-between align-items-center";consttexto=document.createElement("span");texto.textContent=`${item.quantidade}x ${item.nome} — ${formatarPreco(item.preco*item.quantidade)}`;constbotao=document.createElement("button");botao.className="btn btn-sm btn-cafe-vazado";botao.type="button";botao.dataset.acao="remover";botao.dataset.id=item.id;botao.textContent="Remover";botao.setAttribute("aria-label",`Remover ${item.nome} do pedido`);li.append(texto,botao);fragmento.appendChild(li);});lista.appendChild(fragmento);}constquantidadeTotal=estado.carrinho.reduce((soma,item)=>soma+item.quantidade,0);contador.textContent=quantidadeTotal;total.textContent=`Total: ${formatarPreco(totalDoCarrinho())}`;}
Repare em removerDoCarrinho: em vez de procurar o índice e usar splice, ele substitui o array por um novo, filtrado. Menos código e nenhum risco de errar o índice.
Passo 5 — ligar tudo: eventos, opções geradas e debounce¶
js/app.js
JavaScript
functioncomAtraso(funcao,milissegundos=300){lettemporizador;return(...argumentos)=>{clearTimeout(temporizador);temporizador=setTimeout(()=>funcao(...argumentos),milissegundos);};}functionpreencherFiltroDeCategorias(){constselect=document.querySelector("#filtro-categoria");constcategorias=[...newSet(produtos.map((produto)=>produto.categoria))];constopcoes=categorias.map((categoria)=>{constopcao=document.createElement("option");opcao.value=categoria;opcao.textContent=ROTULOS_CATEGORIA[categoria]??categoria;returnopcao;});select.append(...opcoes);}functionrender(){constvisiveis=produtosVisiveis();renderizarProdutos(visiveis);atualizarResumo(visiveis);}functioniniciarCardapio(){constcontainer=document.querySelector("#lista-produtos");if(!container)return;// não estamos no cardapio.htmlpreencherFiltroDeCategorias();render();renderizarCarrinho();constbusca=document.querySelector("#busca");busca.addEventListener("input",comAtraso((evento)=>{estado.termo=evento.target.value.trim().toLowerCase();render();},300),);document.querySelector("#filtro-categoria").addEventListener("change",(evento)=>{estado.categoria=evento.target.value;render();});document.querySelector("#ordenacao").addEventListener("change",(evento)=>{estado.ordenacao=evento.target.value;render();});document.querySelector("#controles-cardapio").addEventListener("submit",(evento)=>{evento.preventDefault();// <Enter> na busca não deve recarregar a página});// Delegação: um ouvinte para os cards, outro para o pedidocontainer.addEventListener("click",(evento)=>{constbotao=evento.target.closest('[data-acao="adicionar"]');if(!botao)return;constcard=botao.closest(".card-produto");adicionarAoCarrinho(Number(card.dataset.id));});document.querySelector("#lista-carrinho").addEventListener("click",(evento)=>{constbotao=evento.target.closest('[data-acao="remover"]');if(!botao)return;removerDoCarrinho(Number(botao.dataset.id));});}
Esta iniciarCardapio substitui a da Aula 07. As funções criarCardProduto, renderizarProdutos, formatarPreco, iniciarTema e iniciarContato continuam iguais.
Abra cardapio.html. O <select> de categoria deve ter cinco opções: "Todas" mais as quatro geradas do array.
Digite caf na busca. Sobra um card ("Frappê de Café" — é o único nome que contém caf) e o resumo começa com "1 de 10 itens". Troque para co e sobram dois ("Coado da Casa" e "Cold Brew da Chapada").
Digite zzz. Nenhum card, a mensagem de lista vazia aparece e o resumo diz "Nenhum item corresponde à sua busca."
Limpe a busca, escolha "Doces" e ordene por "Preço (maior primeiro)": Brownie de Castanha (R$ 11,00) antes de Bolo de Milho Verde (R$ 9,50).
Ordene por "Nome (A a Z)" com "Todas" selecionado. O primeiro card deve ser "Bolo de Milho Verde" e o último "Torta de Frango", com "Frappê de Café" entre "Espresso do Cerrado" e "Latte de Baunilha" — acento no lugar certo, graças ao Intl.Collator.
Clique duas vezes em "Adicionar ao pedido" no Coado da Casa e uma vez na Torta de Frango. O pedido mostra "2x Coado da Casa — R$ 17,00", "1x Torta de Frango — R$ 13,00", contador 3 e "Total: R$ 30,00".
Clique em "Remover" no Coado da Casa. O total cai para R$ 13,00 e o contador para 1.
No Console, rode produtos.map((p) => p.nome) depois de ordenar por preço na tela. A ordem original tem que estar intacta — se mudou, algum sort está mordendo o array de origem.
Prova do debounce: coloque console.count("render") como primeira linha de render() e digite "cappuccino" na busca. Devem aparecer uma ou duas contagens, não dez.
Terminal
gitadd.
gitcommit-m"feat: busca, filtro por categoria, ordenacao e carrinho no cardapio"
gitpush
A2. As três definições abaixo deveriam ser equivalentes, mas duas estão erradas. Aponte quais, diga o que cada uma devolve ao ser chamada com 5 e corrija.
A3. Escreva, em uma linha cada, usando o array produtos da aula: (a) os nomes de todos os produtos da categoria "geladas"; (b) quantos produtos custam R$ 10 ou mais; (c) true ou false para "existe algum produto abaixo de R$ 7"; (d) a soma dos preços dos salgados.
A4. A função abaixo devolve undefined e o Console acusa TypeError: Cannot read properties of undefined (reading 'itens'). Explique em duas linhas por quê e corrija sem mudar a lógica.
JavaScript
constpedido={itens:["Espresso do Cerrado","Torta de Frango"],quantos:()=>this.itens.length,};
A5. Verdadeiro ou falso, com justificativa de uma linha: (a) map pode devolver um array menor que o original; (b) forEach pode ser interrompido com break; (c) sort sem comparador ordena números corretamente; (d) filter nunca devolve undefined; (e) [...produtos].sort() deixa produtos intacto.
A6. Reescreva o laço abaixo usando um único encadeamento de métodos de array, sem for e sem variável auxiliar.
B1. Faixa de preço. Acrescente ao cardápio um filtro de preço máximo (um <input type="range"> de 5 a 15) encadeado aos filtros existentes, com o valor atual exibido ao lado.
Resultado esperado: arrastar o controle reduz a lista em tempo real e o resumo acompanha; combinar com busca e categoria funciona nos três ao mesmo tempo.
Dica
Um campo a mais no estado, um .filter a mais em produtosVisiveis, um ouvinte de input a mais. Nada além disso precisa mudar — é o teste de que a arquitetura da seção 6 está correta.
B2. Quantidades no pedido. Acrescente botões "+" e "−" em cada item do carrinho. Ao chegar a zero, o item sai da lista.
Resultado esperado: o total e o contador acompanham cada clique; remover o último item volta a mostrar "Nenhum item no pedido ainda."
Dica
Use data-acao="aumentar" e data-acao="diminuir" no mesmo ouvinte delegado que já trata "remover". Depois de diminuir, estado.carrinho = estado.carrinho.filter((i) => i.quantidade > 0) limpa os zerados de uma vez.
B3. Resumo por categoria. Abaixo da grade, mostre quantos itens e qual o preço médio de cada categoria presente na lista visível, gerado com reduce.
Resultado esperado: uma linha por categoria ("Cafés: 4 itens · média R$ 10,13"), recalculada a cada filtro; categorias sem itens visíveis não aparecem.
Dica
Agrupe primeiro com o reduce de acumulador-objeto da seção 4.5, depois Object.entries do resultado com map para montar as linhas.
B4. Ordenação estável e reversível. Acrescente ao ORDENADORES duas estratégias novas — "Categoria (A a Z)" e "Nome (Z a A)" — sem tocar na função ordenar nem em produtosVisiveis.
Resultado esperado: duas opções novas no <select> funcionando; o diff do commit mostra alteração apenas no objeto ORDENADORES e no HTML.
Dica
Para inverter uma ordenação existente, envolva o comparador: (a, b) => ORDENADORES.nome(b, a). Para ordenar por categoria e, dentro dela, por nome, some os comparadores: comparadorPtBr.compare(a.categoria, b.categoria) || comparadorPtBr.compare(a.nome, b.nome).
B5. Busca em mais de um campo. Faça a busca considerar também a descricao, e mostre no resumo quantos resultados vieram só pela descrição.
Resultado esperado: buscar "polvilho" encontra o Pão de Queijo Mineiro, cujo nome não contém a palavra; o resumo informa "1 resultado encontrado na descrição".
Dica
Um filter cujo teste é nome.includes(termo) || descricao.includes(termo). Para a contagem, um segundo filter sobre a lista visível, testando só a descrição — e cuidado com o termo vazio, que casa com tudo.
C1. Uma passada só. Hoje o render() percorre a lista cinco vezes: dois filter, um sort, o forEach do render e dois reduce do resumo. Reescreva a derivação e o resumo para que a lista seja percorrida uma vez para filtrar e calcular todas as estatísticas ao mesmo tempo (contagem, soma, média, mais barato, mais caro), mantendo o sort como única passagem adicional. Depois prove, com medição, se valeu a pena.
Resultado esperado: um único reduce devolve { visiveis, total, media, maisBarato, maisCaro }; a tela continua idêntica; e uma tabela no README compara o tempo das duas versões com 10 e com 50.000 produtos gerados.
Dica
O acumulador do reduce é um objeto com todos os campos, inicializado com { visiveis: [], soma: 0, maisBarato: null, maisCaro: null }. Dentro do callback, teste o produto: se não passar nos filtros, devolva o acumulador sem mudança. Meça com performance.now() em volta de render() e rode cada versão 5 vezes, comparando a mediana. Prepare-se para a possibilidade de a diferença ser irrelevante em 10 itens — esse também é um resultado válido, e a conclusão honesta é o que vale nota.
Um colega escreveu a ordenação por preço assim e jurou que testou: produtos.sort((a, b) => a.preco > b.preco). Com três produtos, funcionou. Com os nove do Café Cerrado, a lista sai quase ordenada — mas não totalmente, e em outro navegador sai diferente. Descubra por que, e por que "quase certo" é pior que errado.
Critérios de pronto
Um arquivo INVESTIGACAO.md mostra a saída errada com os dez produtos e explica, com base na documentação, qual valor o comparador deveria devolver e o que acontece quando ele devolve um booleano.
O texto responde: por que o resultado pode variar entre navegadores, se todos seguem o mesmo padrão?
Uma segunda parte demonstra o efeito colateral: rodar a versão errada duas vezes seguidas e mostrar que produtos já não está na ordem original.
A correção usa subtração para números e Intl.Collator para texto, e o array de origem permanece intacto.
Pistas
Leia na MDN a assinatura de Array.prototype.sort e a seção sobre a função de comparação: ela precisa devolver um número negativo, zero ou positivo.
true e false viram 1 e 0 quando o motor os trata como número. Repare no que nunca é produzido: um valor negativo.
Procure "sort stability" e "implementation-defined": desde o ES2019 a ordenação é estável, mas o algoritmo continua livre — e algoritmos diferentes reagem diferente a um comparador inconsistente.
Para provar a mutação, guarde const antes = produtos.map((p) => p.id) antes do sort e compare com o depois.
Este relatório do Café Cerrado funciona, mas percorre o array cinco vezes e repete a mesma estrutura em todas. Refatore-o com métodos de array, meça as duas versões e decida com dados — não com gosto — qual entra no projeto.
Versão A: cada bloco vira o método de array adequado (reduce, filter, map, Set), com no máximo uma linha por estatística.
Versão B: um único reduce produz o objeto inteiro em uma passagem.
As três versões (original, A e B) devolvem exatamente o mesmo objeto para o array de dez produtos e para um array vazio — este último documentado, porque a original quebra com lista vazia.
Uma tabela no README compara o tempo das três com 100.000 itens gerados, com pelo menos 5 execuções e a mediana registrada.
Um parágrafo final escolhe uma versão para o projeto e justifica considerando legibilidade e número.
Pistas
[...new Set(lista.map((p) => p.categoria))] resolve a lista de categorias sem includes dentro de laço — que é quadrático.
Para a versão B, o acumulador começa como { total: 0, caros: 0, maisCaro: null, categorias: new Set(), nomes: [] } e você converte o Set no fim.
Repare no que a função original faz com lista[0] quando lista é []. É esse tipo de detalhe que só aparece em produção.
Ao medir, gere o array grande uma vez, fora da medição, e alterne a ordem das execuções — o primeiro a rodar costuma pagar o custo de aquecimento do motor.
Sua busca funciona lindamente com o mouse. Agora feche os olhos: como alguém que usa leitor de tela sabe que a lista mudou? Que 2 de 10 itens sobraram? Que a busca não achou nada? Faça o cardápio filtrado ser tão utilizável de ouvido quanto de olho — e prove com um leitor de tela de verdade.
Critérios de pronto
O resumo de resultados é anunciado a cada filtragem por uma região aria-live="polite", sem interromper o que a pessoa está digitando.
O anúncio só acontece depois que a digitação para (o debounce também protege o leitor de tela de ser bombardeado a cada tecla) e informa quantidade e critério ("2 de 10 itens, categoria Cafés").
O estado vazio é anunciado com um texto útil, que sugere o que fazer ("Nenhum item corresponde a 'zzz'. Limpe a busca ou escolha outra categoria.").
Navegar com Tab do campo de busca até o primeiro card funciona, e o foco nunca é jogado para o topo por causa de uma re-renderização.
Um vídeo ou roteiro escrito documenta o teste com NVDA, VoiceOver ou o leitor do Android, listando o que foi anunciado em cada passo.
Pistas
Uma região aria-live só é anunciada quando o texto dentro dela muda; se ela é criada junto com o texto, o anúncio se perde. Deixe o elemento na página desde o começo.
role="status" já implica aria-live="polite" — não precisa dos dois, mas ter os dois não atrapalha.
Sobre o foco: replaceChildren() destrói os elementos, e o foco que estava em um deles vai para o <body>. Se a pessoa estava no campo de busca (fora do contêiner), nada acontece — verifique se é o seu caso.
Leia o padrão de "results message" no ARIA Authoring Practices Guide antes de inventar o seu.
Alguém filtra o cardápio por "Doces, ordenados por preço", acha o combo perfeito e quer mandar no grupo da família. Copia a URL, cola, e a outra pessoa abre o cardápio inteiro sem filtro nenhum — porque o estado só existe na memória do navegador. Faça o estado morar na URL: filtros compartilháveis, botão Voltar funcionando e recarregar sem perder nada. É o primeiro passo do que vira roteamento na Aula 10.
Critérios de pronto
Cada mudança de busca, categoria ou ordenação atualiza a URL para algo como cardapio.html?busca=cafe&categoria=doce&ordem=preco-asc, sem recarregar a página.
Abrir a URL diretamente (ou apertar F5) reconstrói o estado exatamente: campos preenchidos, lista filtrada, resumo correto.
O botão Voltar do navegador desfaz uma mudança de filtro por vez, e o Avançar refaz.
A gravação na URL é adiada como a busca: digitar "cappuccino" não deixa dez entradas no histórico.
Parâmetros ausentes, vazios ou inválidos (?ordem=xyz) caem em um padrão seguro, sem erro no Console.
O INVESTIGACAO.md explica em um parágrafo a diferença entre history.pushState e history.replaceState, e justifica onde você usou cada um.
Pistas
new URLSearchParams(location.search) lê os parâmetros; parametros.get("categoria") ?? "" já traz o padrão junto.
Para escrever sem recarregar: history.pushState(null, "",?${parametros}). O URLSearchParams vira string sozinho na interpolação.
O botão Voltar dispara o evento popstate em window — é lá que você relê a URL e chama render().
Digitar deve usar replaceState (substitui a entrada atual) e mudar de categoria deve usar pushState (cria entrada nova). Pense em quantas vezes a pessoa quer apertar Voltar em cada caso.
Ao reconstruir o estado, não esqueça de preencher os controles do formulário — senão a lista aparece filtrada e o <select> diz "Todas", e ninguém entende o que está vendo.
Refatore o js/app.js: converta os callbacks de evento para arrow functions e substitua os laços for que só percorrem listas por map, filter ou forEach.
Implemente a busca por texto nos seus itens, com evento input e debounce de 300 ms.
Acrescente um segundo filtro encadeado ao primeiro — categoria por <select> ou ordenação por um campo numérico — usando o padrão estado → derivação → renderização.
Exiba um resumo calculado com reduce ou length, atualizado a cada filtragem (por exemplo, "8 itens encontrados · total R$ 320,00").
Trate a lista vazia com uma mensagem útil, que diga o que a pessoa pode fazer em seguida.
Critério de pronto: os filtros funcionam combinados; o array de origem permanece na ordem original depois de qualquer ordenação (comprove no Console); nenhum laço for sobrou onde um método de array serviria; o Console fica sem erros.
Guarde no seu repositório: commit + push.
Leitura dirigida (se você tem acesso a uma biblioteca virtual pela sua instituição): Queirós e Portela, seções de JavaScript avançado; Loudon, padrões de código JavaScript escalável; MDN, "Array" (métodos) e "Introducing asynchronous JavaScript" — preparação direta para a próxima aula.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — JavaScript: funções e coleções.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — padrões de código JavaScript escalável.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — funções e manipulação de dados.
Na próxima aula o setTimeout de hoje deixa de ser curiosidade e vira o assunto principal: por que o JavaScript não pode parar para esperar, o que é o event loop e como o callback evolui para a Promise. O cardápio do Café Cerrado vai passar a "buscar" seus dados com atraso simulado, ganhando estados de carregando e de erro — o ensaio final antes do fetch de verdade.
Nível 2Unidade 2 · Web dinâmica client-side3 aulas de 50 min + 1 h EAD
Aula 09 — Promises e async/await
Nível 2 — Desenvolvimento Web · WebLab
Até agora todos os dados do Café Cerrado estavam prontos no momento em que a página abria: o array produtos já existia dentro do js/app.js, e renderizar os cards era uma questão de percorrer o que já estava na memória. No mundo real isso não acontece. Os dados moram em outro computador, do outro lado de uma rede que pode estar rápida, lenta ou fora do ar — e o navegador não pode parar tudo para esperar. Hoje você aprende o mecanismo que o JavaScript inventou para lidar com valores que ainda não chegaram: a Promise, e a sintaxe que a tornou confortável, o async/await.
[ ] Repositório cafe-cerrado clonado e funcionando, com index.html, cardapio.html, contato.html, css/estilo.css e js/app.js.
[ ] O array produtos de objetos { id, nome, categoria, preco, descricao, imagem } renderizado como cards em cardapio.html (Aula 07).
[ ] Busca por nome, filtro por categoria e ordenação por preço funcionando com filter, sort e map (Aula 08).
[ ] VS Code com a extensão Live Server — abra o projeto sempre por http://127.0.0.1, nunca por file:// (na próxima aula isso deixa de ser recomendação e vira obrigação).
[ ] Navegador com DevTools e a aba Network disponível (vamos simular conexão lenta nela).
Na aula passada as funções deixaram de ser blocos de código e viraram valores: você passou arrow functions para filter, sort, map e reduce, e viu o setTimeout receber um callback que dispara depois. Hoje o "depois" vira o assunto principal. Você vai entender por que o JavaScript não pode simplesmente parar e esperar, conhecer o objeto que representa um valor futuro e refatorar o cardápio do Café Cerrado para que ele nasça de uma fonte de dados que demora — e que às vezes falha.
O JavaScript do navegador roda em uma única thread — a mesma que calcula o layout, pinta os pixels, responde ao clique, rola a página e processa a digitação. Isso é uma decisão de projeto antiga, e ela tem uma consequência brutal: enquanto o seu código roda, nada mais acontece na página.
Não é uma metáfora. Abra qualquer site, abra o console (F12) e cole isto:
JavaScript
console.log("1 — começou");constlimite=Date.now()+3000;while(Date.now()<limite){// Laço vazio de propósito: segura a única thread por 3 segundos.}console.log("2 — terminou");
Durante esses três segundos, tente rolar a página, clicar em um link, selecionar um texto. Nada responde. O cursor pode até virar uma ampulheta. A aba está viva, mas surda.
🔬 Investigue
Rode o trecho acima em uma página com alguma animação CSS visível (o próprio WebLab serve). Observe: a animação congela durante os 3 segundos e depois "salta" para onde deveria estar. Agora troque 3000 por 300 e repita. Em 300 ms a maioria das pessoas não percebe travamento; a partir de ~100 ms de bloqueio, uma interação já parece "pesada". Guarde esse número: é o orçamento de tempo que você tem para qualquer trabalho síncrono.
Agora pense numa requisição de rede. Buscar dados de um servidor leva de 50 ms (rede boa, servidor perto) a vários segundos (4G ruim no meio do Mato Grosso, servidor nos Estados Unidos). Se essa espera fosse síncrona, cada carregamento de cardápio congelaria a página inteira. Inaceitável.
A plataforma resolve isso assim: operações lentas não são executadas pelo seu código. Você as entrega ao navegador (que tem outras threads, escritas em C++) junto com uma instrução: "quando terminar, execute esta função". Seu código continua rodando na hora.
JavaScript
console.log("1 — início");setTimeout(()=>console.log("3 — callback, mesmo com 0 ms de espera"),0);console.log("2 — fim do script");
Saída, sempre nessa ordem:
Texto
1 — início
2 — fim do script
3 — callback, mesmo com 0 ms de espera
Repare no detalhe que incomoda todo mundo na primeira vez: mesmo pedindo 0 milissegundos, o callback só roda depois que todo o código síncrono terminou. O 0 não significa "agora"; significa "na primeira oportunidade depois que a thread ficar livre".
1.3 Por baixo do capô: pilha, filas e o event loop¶
O mecanismo que coordena isso tem quatro peças. Vale conhecer os nomes, porque eles aparecem em toda discussão séria sobre performance web.
Peça
O que é
Quem coloca coisas lá
Pilha de chamadas
As funções em execução agora
O motor JS, ao chamar funções
Fila de tarefas
Callbacks aguardando a vez
setTimeout, eventos, rede
Fila de microtarefas
Callbacks com prioridade
.then de Promises
Event loop
O laço que move da fila para a pilha
O navegador, continuamente
O event loop faz sempre a mesma coisa, para sempre: se a pilha estiver vazia, esvazie toda a fila de microtarefas; depois pegue uma tarefa da fila de tarefas e execute; repita.
Saída: 1, 2, 3, 4. As duas linhas síncronas rodam primeiro (elas estão na pilha). Depois o event loop esvazia as microtarefas — a Promise. Só então pega a próxima tarefa — o setTimeout.
🔎 Por baixo do capô
Entre esvaziar as microtarefas e pegar a próxima tarefa, o navegador tem a chance de renderizar: recalcular estilos, fazer layout e pintar. É por isso que uma cadeia infinita de microtarefas trava a tela tanto quanto um while infinito, enquanto uma cadeia de setTimeout deixa a página respirar entre uma e outra. Se você algum dia precisar processar 100 mil itens sem congelar a interface, a solução é fatiar o trabalho em tarefas, não em microtarefas.
📌 Vale gravar
Saber prever a saída de um trecho que mistura console.log síncrono, setTimeout(fn, 0) e Promise.resolve().then(fn) é questão clássica. A regra em uma frase: síncrono primeiro, depois microtarefas, depois tarefas — e o número em milissegundos do setTimeout é um mínimo, nunca uma garantia.
2. Callbacks: a primeira solução, e por que ela não bastou¶
Antes das Promises, "avise-me quando terminar" se escrevia passando uma função. Você já faz isso desde a Aula 07 com addEventListener, e desde a Aula 08 com setTimeout.
Como uma operação assíncrona pode falhar, a comunidade padronizou uma convenção: o callback recebe o erro como primeiro parâmetro e o resultado como segundo. Se o primeiro for null, deu certo.
JavaScript
// Exemplo didático: fonte de dados fake com callback erro-primeiro.functionbuscarProdutosComCallback(aoTerminar){setTimeout(()=>{constdeuErro=Math.random()<0.3;if(deuErro){aoTerminar(newError("Servidor do cardápio fora do ar"),null);return;}aoTerminar(null,["Espresso do Cerrado","Coado da Casa"]);},800);}buscarProdutosComCallback((erro,nomes)=>{if(erro){console.error("Falhou:",erro.message);return;}console.log("Chegaram:",nomes);});
Funciona. O problema aparece quando uma operação depende da anterior.
Imagine que, para montar a tela do cardápio, você precise: buscar as categorias, depois buscar os produtos da primeira categoria, depois buscar as avaliações do produto mais vendido. Cada passo depende do anterior. Com callbacks:
JavaScript
// Exemplo do que NÃO queremos escrever: cada nível aninha mais um.buscarCategorias((erro,categorias)=>{if(erro){mostrarErro(erro);return;}buscarProdutosDaCategoria(categorias[0],(erroProdutos,produtos)=>{if(erroProdutos){mostrarErro(erroProdutos);return;}buscarAvaliacoes(produtos[0],(erroAvaliacoes,avaliacoes)=>{if(erroAvaliacoes){mostrarErro(erroAvaliacoes);return;}renderizar(produtos,avaliacoes);});});});
Três operações, e o código já anda para a direita como uma escada. Com cinco ou seis, vira o famoso callback hell: difícil de ler, difícil de reordenar e, sobretudo, difícil de tratar erro — repare que o tratamento de falha está repetido três vezes, com nomes de variável artificialmente diferentes.
Há um incômodo pior que a estética. Quando você passa um callback para uma função de terceiros, você entrega o controle: quem garante que ela vai chamar o seu callback exatamente uma vez? Uma biblioteca com bug pode chamar duas vezes (e o seu carrinho ganha dois itens), nunca (e a tela fica em "Carregando…" para sempre), ou de forma síncrona quando você esperava assíncrona.
A Promise resolve isso pelo design: um objeto Promise só muda de estado uma vez, e essa mudança é irreversível. Quem cria a Promise controla o valor; quem consome controla o que fazer com ele. Ninguém precisa confiar em ninguém.
Uma Promise é um objeto que representa um valor que ainda não existe, mas vai existir — ou vai falhar tentando. É um comprovante, um número de protocolo: você não tem o café ainda, mas tem a garantia de que ele será entregue ou de que alguém virá dizer que acabou.
Uma Promise nasce pending e vai uma única vez para fulfilled ou rejected. Depois disso está settled (assentada) e nunca mais muda. Chamar resolve() duas vezes não faz nada na segunda; chamar reject() depois de resolve() também não.
3.2 Criando uma Promise para entender o mecanismo¶
No dia a dia você quase sempre consome Promises que outra pessoa criou (fetch, APIs de banco de dados, bibliotecas). Mas criar uma à mão desmistifica o objeto — e é exatamente o que faremos no Café Cerrado, para simular a demora da rede antes de termos rede de verdade.
O construtor recebe uma função chamada executor, que roda imediatamente e recebe duas funções: resolve (entregar o valor) e reject (entregar o erro).
JavaScript
// Exemplo: uma fonte de dados que demora 1,5 s e pode falhar.constcatalogo=[{id:1,nome:"Espresso do Cerrado",preco:6},{id:2,nome:"Coado da Casa",preco:8.5},];functionbuscarProduto(id){returnnewPromise((resolve,reject)=>{setTimeout(()=>{constproduto=catalogo.find((item)=>item.id===id);if(produto){resolve(produto);}else{reject(newError(`Produto ${id} não encontrado`));}},1500);});}console.log(buscarProduto(1));
O console.log da última linha imprime Promise { <pending> } — porque o valor ainda não chegou. Guarde essa imagem: sempre que você vir Promise { <pending> } no console, é sinal de que você está olhando para o comprovante em vez de olhar para o café.
⚠️ Atenção
Sempre rejeite com um objeto Error (reject(new Error("..."))), nunca com uma string. O Error carrega message, name e a pilha de chamadas — sem ele, você perde a informação de onde a falha nasceu, que é justamente o que você vai precisar às 23h de um dia de entrega.
buscarProduto(1).then((produto)=>{console.log("Chegou:",produto.nome);}).catch((erro)=>{console.error("Falhou:",erro.message);}).finally(()=>{console.log("Terminou — com sucesso ou com falha");});
Três métodos, três papéis:
.then(aoResolver) — roda quando a Promise resolve, recebendo o valor.
.catch(aoRejeitar) — roda quando a Promise (ou qualquer .then anterior da cadeia) falha, recebendo o erro.
.finally(sempre) — roda nos dois casos, sem receber nada. É o lugar certo para "esconder o Carregando…".
O que salva a legibilidade é uma regra simples: .then devolve uma nova Promise. Se o callback do .then retornar um valor comum, a próxima Promise resolve com esse valor. Se retornar outra Promise, a cadeia espera por ela. É isso que achata a pirâmide.
JavaScript
buscarProduto(1).then((produto)=>{console.log("Primeiro:",produto.nome);returnbuscarProduto(2);// retornar uma Promise encadeia a próxima etapa}).then((outro)=>{console.log("Segundo:",outro.nome);returnoutro.preco*2;// retornar um valor comum também funciona}).then((dobro)=>console.log("Dobro do preço:",dobro)).catch((erro)=>console.error("Algo na cadeia falhou:",erro.message)).finally(()=>console.log("Fim da cadeia"));
Compare com a pirâmide da seção 2.2: o código desce em vez de andar para a direita, e um único .catch cobre todos os passos. Se qualquer etapa rejeitar, a cadeia pula direto para o .catch, ignorando os .then intermediários — igualzinho ao que um try/catch faz com código síncrono.
🧠 Você sabia?
Promises não nasceram no JavaScript nem em 2015. O conceito de promise / future é dos anos 1970, em linguagens de pesquisa como MultiLisp. Na web, cada biblioteca tinha a sua versão incompatível — o $.Deferred do jQuery, o Q, o when.js, o bluebird. Em 2012 a comunidade escreveu a especificação aberta Promises/A+, que define em detalhe como o .then deve se comportar; o ES2015 padronizou o objeto Promise seguindo essa especificação. É por isso que qualquer objeto com um método .then compatível — um "thenable" — funciona dentro de um await até hoje, mesmo vindo de uma biblioteca antiga.
3.5 Um detalhe que confunde: o .catch no lugar errado¶
A ordem importa. Um .catch() só enxerga o que aconteceu antes dele na cadeia:
JavaScript
// (a) o .catch cobre os dois .thenbuscarProduto(99).then((p)=>console.log(p.nome)).then(()=>console.log("segundo passo")).catch((erro)=>console.error("pego:",erro.message));// (b) o .catch NÃO cobre o .then que vem depois delebuscarProduto(99).catch((erro)=>console.error("pego:",erro.message)).then(()=>console.log("este .then roda mesmo depois da falha"));
No caso (b), o .then final roda normalmente, porque o .catch "consertou" a cadeia: ele devolveu uma Promise resolvida (com undefined). Isso é útil de propósito às vezes — é assim que se implementa um valor padrão em caso de falha — mas pega desprevenido quem não sabe.
O ES2017 acrescentou açúcar sintático sobre Promises. Não é um mecanismo novo: por baixo, tudo continua sendo Promise, microtarefa e event loop. O que muda é a leitura.
Duas palavras-chave, duas regras:
async antes de uma função faz com que ela sempre retorne uma Promise, aconteça o que acontecer.
await pausa a execução daquela função até a Promise resolver, e entrega o valor. Enquanto isso, a thread fica livre para o resto da página.
Compare a mesma lógica da seção 3.4 nas duas sintaxes:
JavaScript
// Com .thenfunctionmostrarDois(){returnbuscarProduto(1).then((primeiro)=>{console.log("Primeiro:",primeiro.nome);returnbuscarProduto(2);}).then((segundo)=>{console.log("Segundo:",segundo.nome);returnsegundo.preco*2;});}// Com async/await — mesma coisa, sem aninhamento e sem callbacksasyncfunctionmostrarDoisComAwait(){constprimeiro=awaitbuscarProduto(1);console.log("Primeiro:",primeiro.nome);constsegundo=awaitbuscarProduto(2);console.log("Segundo:",segundo.nome);returnsegundo.preco*2;}
A segunda versão lê de cima para baixo, como qualquer código que você já escreveu. É por isso que ela virou o padrão da indústria — e é a que usaremos no resto do curso.
asyncfunctionnumero(){return42;}console.log(numero());// uma Promise já resolvida — não o número 42numero().then((n)=>console.log(n));// 42
Uma função asyncembrulha o retorno numa Promise. Consequência prática: quem chama uma função async precisa lidar com uma Promise — com await (se estiver dentro de outra async) ou com .then.
Este é o mal-entendido número um de quem está aprendendo async/await. Quando o motor encontra um await, ele:
Registra o resto da função como uma continuação (uma microtarefa).
Devolve o controle para quem chamou a função.
Volta a executar a função só quando a Promise assentar.
Ou seja: a página continua rolando, animando e respondendo a cliques durante o await. Só aquela função está parada.
JavaScript
asyncfunctiondemonstrar(){console.log("A — antes do await");awaitbuscarProduto(1);console.log("C — depois do await");}demonstrar();console.log("B — esta linha roda antes do C");
Saída: A, B, C. A linha B está fora da função e não espera nada.
4.3 try/catch/finally: erro assíncrono tratado como erro comum¶
Com await, uma Promise rejeitada lança uma exceção no ponto do await. Isso significa que você trata falha de rede com a mesma estrutura que trata qualquer outro erro:
JavaScript
asyncfunctionexibirProdutoSeguro(id){try{constproduto=awaitbuscarProduto(id);console.log("Chegou:",produto.nome);returnproduto;}catch(erro){console.error("Falhou:",erro.message);returnnull;}finally{console.log("Terminou — com sucesso ou com falha");}}
O mapeamento é direto: .then → o corpo do try; .catch → o bloco catch; .finally → o bloco finally.
asyncfunctionerrado(){constproduto=buscarProduto(1);// faltou o awaitconsole.log(produto.nome);// undefined}
produto recebe a Promise, não o objeto. produto.nome é undefined porque uma Promise não tem propriedade nome. O sintoma no console é sempre o mesmo: Promise { <pending> } ou undefined onde você esperava um dado.
O navegador nem chega a rodar: SyntaxError: await is only valid in async functions and the top level bodies of modules. A mensagem entrega a exceção da regra — em um módulo ES (<script type="module">), await funciona no nível superior do arquivo, sem função nenhuma. Você vai usar isso na próxima aula.
💡 Dica
Configure o VS Code para avisar antes de o navegador reclamar: com o arquivo .js aberto, um await sem async já aparece sublinhado em vermelho. E crie o reflexo de escrever async e await juntos, no mesmo momento — como quem escreve o } logo depois do {.
5. Onde os erros assíncronos caem (e onde eles se perdem)¶
asyncfunctionvalidarPedido(quantidade){if(quantidade<=0){thrownewError("Quantidade precisa ser maior que zero");}returnquantidade;}validarPedido(0).catch((erro)=>console.error("Rejeitou:",erro.message));
Um throw dentro de uma função async não estoura na hora: ele rejeita a Promise que a função devolve. Por isso ele é capturável com .catch() ou com um try/catch de quem der await.
Se ninguém trata a rejeição, o navegador reclama sozinho no console:
Texto
Uncaught (in promise) Error: Servidor do cardápio fora do ar
Nada quebra visivelmente — e é justamente esse o perigo. A tela fica em "Carregando…" para sempre e o usuário não faz ideia do porquê. Toda operação assíncrona que chega perto da interface precisa de um catch.
Para caçar esses casos em desenvolvimento, um ouvinte global ajuda:
JavaScript
// Cole no console (ou no topo do app.js durante a depuração) para não perder rejeição nenhuma.window.addEventListener("unhandledrejection",(evento)=>{console.warn("Rejeição sem tratamento:",evento.reason);});
5.3 Erro com contexto vale mais que erro genérico¶
"Erro ao carregar" não ajuda ninguém. Enriqueça a mensagem antes de repassá-la:
JavaScript
asyncfunctioncarregarComContexto(id){try{returnawaitbuscarProduto(id);}catch(causa){thrownewError(`Não foi possível carregar o produto ${id}`,{cause:causa});}}carregarComContexto(99).catch((erro)=>{console.error(erro.message);// mensagem amigável, com o idconsole.error("Causa original:",erro.cause.message);// o erro técnico preservado});
Repare no return await dentro do try: sem o await, a Promise seria devolvida antes de falhar, e o catch local nunca rodaria. É uma das poucas situações em que return await não é redundante.
📌 Vale gravar
Duas confusões comuns: (1) "await bloqueia a página" é falsa — ele pausa apenas a função async onde está escrito; (2) "uma função async pode retornar um valor comum" é falsa na prática — o valor sai sempre embrulhado numa Promise.
6. O padrão de interface: carregando, sucesso, erro, vazio¶
Aqui está a parte que separa um trabalho de aluno de uma aplicação de verdade. Toda operação assíncrona que alimenta a tela tem quatro desfechos possíveis, e a interface precisa de uma resposta visual para cada um.
Estado
O que o usuário vê
O que quase todo mundo esquece
Carregando
Mensagem ou esqueleto de conteúdo
Anunciar para leitores de tela
Sucesso
Os dados renderizados
Limpar a mensagem de carregando
Erro
Mensagem clara + ação de recuperação
Oferecer "tentar de novo"
Vazio
"Nenhum item encontrado"
Diferenciar de erro e de carregando
O estado vazio merece atenção: uma busca que não encontra nada é um sucesso técnico (a operação funcionou) com resultado vazio. Mostrar tela em branco nesse caso faz o usuário achar que o site quebrou.
Uma pessoa que usa leitor de tela não vê o spinner girar. Para que a mudança seja anunciada, a região de status precisa de atributos ARIA:
HTML
<pid="status-cardapio"class="status"role="status"aria-live="polite">Carregando o cardápio…</p>
role="status" marca o elemento como área de mensagens de estado.
aria-live="polite" faz o leitor anunciar a mudança sem interromper o que ele está lendo. Use assertive só para emergências.
E na região que está sendo preenchida, aria-busy avisa que o conteúdo está em construção:
JavaScript
elementoDaLista.setAttribute("aria-busy","true");// depois de renderizar:elementoDaLista.setAttribute("aria-busy","false");
💡 Dica
O elemento com aria-live precisa existir no HTML antes de a mensagem chegar. Se você criar o <p> e o texto ao mesmo tempo, muitos leitores de tela não anunciam nada. Deixe o parágrafo vazio na página e troque só o textContent.
Um problema que só aparece com rede lenta: o usuário digita "ca", a busca dispara; digita "caf", dispara de novo; digita "café", dispara a terceira. Se a primeira requisição for a mais lenta, ela chega por último e sobrescreve o resultado certo com o resultado velho.
A defesa mais simples é numerar as requisições e ignorar as respostas atrasadas:
JavaScript
letrequisicaoAtual=0;asyncfunctionbuscarComProtecao(termo){constminhaVez=++requisicaoAtual;constresultado=awaitbuscarProdutosPorTermo(termo);if(minhaVez!==requisicaoAtual){return;// chegou uma resposta mais nova depois desta: descarta}renderizarProdutos(resultado);}
Guarde esse padrão: ele reaparece em toda tela de busca do mundo real, e é um dos desafios de hoje.
asyncfunctioncompararTempos(){// SEQUENCIAL — ~3 s: a segunda só começa quando a primeira terminaconstinicioSequencial=performance.now();constprodutosSeq=awaitbuscarProdutos();constcategoriasSeq=awaitbuscarCategorias();console.log("sequencial:",Math.round(performance.now()-inicioSequencial),"ms",produtosSeq.length,"produtos e",categoriasSeq.length,"categorias");// PARALELO — ~1,5 s: as duas começam juntasconstinicioParalelo=performance.now();const[produtosPar,categoriasPar]=awaitPromise.all([buscarProdutos(),buscarCategorias()]);console.log("paralelo:",Math.round(performance.now()-inicioParalelo),"ms",produtosPar.length,"produtos e",categoriasPar.length,"categorias");}compararTempos();
A diferença é gritante e gratuita: as duas buscas não dependem uma da outra, então não há motivo para enfileirá-las. A regra prática: use await em sequência apenas quando o passo seguinte precisa do resultado do anterior.
O detalhe técnico é que Promise.all não "dispara" nada: quem dispara é a chamada da função. Quando você escreve buscarProdutos() dentro do array, a operação já começou. O Promise.all só junta os comprovantes e espera todos.
Promise.any — o primeiro que der certo. Útil quando há fontes alternativas: tente o servidor principal e o espelho, fique com quem responder primeiro com sucesso.
⚠️ AtençãoPromise.race com um timeout não cancela a operação original — ela continua rodando em segundo plano até terminar; você só parou de esperar por ela. Cancelamento de verdade exige AbortController, que aparece na próxima aula, junto com o fetch.
💻 Mão na massa — o cardápio do Café Cerrado que chega depois¶
Objetivo do dia: o array produtos sai de dentro do js/app.js e passa a vir de uma "fonte de dados" que demora, que pode falhar e que o resto do código só acessa por Promises. Quando trocarmos essa fonte por um fetch de verdade na próxima aula, nenhuma linha do app.js precisará mudar — é esse o ponto.
Passo 1 — Criar js/dados.js, a fonte que finge ser um servidor¶
Crie o arquivo js/dados.js no repositório cafe-cerrado. O conteúdo é exatamente o array produtos que você escreveu na Aula 07 — os mesmos dez itens, os mesmos ids, os mesmos preços, as mesmas quatro categorias. Ele só muda de casa: sai do js/app.js e passa a ser entregue por funções que devolvem Promises.
JavaScript
// cafe-cerrado/js/dados.js// Fonte de dados simulada do Café Cerrado.// Ela existe para treinar código assíncrono antes de termos um servidor de verdade:// devolve Promises, demora de propósito e falha de vez em quando.constCATALOGO=[{id:1,nome:"Espresso do Cerrado",categoria:"cafes",preco:6,descricao:"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.",imagem:"img/espresso.jpg",},{id:2,nome:"Coado da Casa",categoria:"cafes",preco:8.5,descricao:"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.",imagem:"img/coado.jpg",},{id:3,nome:"Cappuccino Sinop",categoria:"cafes",preco:12,descricao:"Espresso duplo, leite vaporizado e canela do Cerrado por cima.",imagem:"img/cappuccino.jpg",},{id:4,nome:"Latte de Baunilha",categoria:"cafes",preco:14,descricao:"Espresso, leite vaporizado e calda de baunilha feita na casa.",imagem:"img/latte.jpg",},{id:5,nome:"Cold Brew da Chapada",categoria:"geladas",preco:15,descricao:"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.",imagem:"img/cold-brew.jpg",},{id:6,nome:"Frappê de Café",categoria:"geladas",preco:16,descricao:"Espresso batido com gelo, leite e chantili. Também sai sem lactose.",imagem:"img/frappe.jpg",},{id:7,nome:"Pão de Queijo Mineiro",categoria:"salgados",preco:7,descricao:"Porção com quatro unidades de polvilho azedo com queijo canastra.",imagem:"img/pao-de-queijo.jpg",},{id:8,nome:"Torta de Frango",categoria:"salgados",preco:13,descricao:"Fatia generosa com massa amanteigada e recheio de frango desfiado.",imagem:"img/torta-de-frango.jpg",},{id:9,nome:"Bolo de Milho Verde",categoria:"doces",preco:9.5,descricao:"Fatia de bolo cremoso feito com milho da feira do produtor.",imagem:"img/bolo-de-milho.jpg",},{id:10,nome:"Brownie de Castanha",categoria:"doces",preco:11,descricao:"Chocolate meio amargo com castanha-do-pará. Sem glúten.",imagem:"img/brownie.jpg",},];// As mesmas quatro categorias das Aulas 03 a 08, agora com id e rótulo separados:// o id é a chave técnica (o que está em produto.categoria), o nome é o texto da tela.constCATEGORIAS=[{id:"cafes",nome:"Cafés"},{id:"geladas",nome:"Bebidas geladas"},{id:"salgados",nome:"Salgados"},{id:"doces",nome:"Doces"},];// Probabilidade de falha simulada (0 = nunca falha, 1 = sempre falha).// Deixe em 0.25 para ver os dois caminhos; zere quando for gravar um vídeo de demonstração.constCHANCE_DE_FALHA=0.25;/** * Devolve uma Promise que resolve com uma cópia de `valor` depois de `atraso` ms, * ou rejeita com um Error se a "rede" simulada falhar. */functionresponderComAtraso(valor,atraso,descricao){returnnewPromise((resolve,reject)=>{setTimeout(()=>{if(Math.random()<CHANCE_DE_FALHA){reject(newError(`Falha simulada de rede ao buscar ${descricao}`));return;}// Cópia rasa para ninguém alterar o catálogo original por acidente.resolve(valor.map((item)=>({...item})));},atraso);});}functionbuscarProdutos(){returnresponderComAtraso(CATALOGO,1200,"os produtos");}functionbuscarCategorias(){returnresponderComAtraso(CATEGORIAS,800,"as categorias");}functionbuscarProdutoPorId(id){returnnewPromise((resolve,reject)=>{setTimeout(()=>{constproduto=CATALOGO.find((item)=>item.id===Number(id));if(!produto){reject(newError(`Produto ${id} não encontrado`));return;}resolve({...produto});},600);});}
💡 DicaCATALOGO e CATEGORIAS em maiúsculas é uma convenção para constantes de configuração — não é regra da linguagem, mas ajuda a bater o olho e saber que aquilo não muda em tempo de execução.
⚠️ Atenção
Não invente produto novo aqui, não renomeie nenhum e não mexa nas chaves de categoria. Esses dez objetos são o contrato do projeto: eles viram data/produtos.json na Aula 10 e as linhas da sua API na Unidade 3. Qualquer diferença aqui vira um bug três aulas adiante, quando o filtro do front-end deixar de casar com o que o servidor devolve.
Passo 2 — Carregar os dois arquivos, na ordem certa¶
Em cardapio.html, substitua o <script> único pelos dois arquivos. O atributo defer faz o navegador baixar os scripts em paralelo e executá-los na ordem em que aparecem, depois que o HTML terminou de ser lido:
HTML
<!-- cafe-cerrado/cardapio.html — antes do </body> --><scriptsrc="js/dados.js"defer></script><scriptsrc="js/app.js"defer></script>
Faça o mesmo em index.html e contato.html. O app.js é o mesmo arquivo nas três páginas — ele continua ligando o botão de tema (Aula 07) e a validação do formulário de contato (Aula 07), e só monta o cardápio quando encontra a grade #lista-produtos. Manter o par de tags igual nas três páginas evita o erro clássico de esquecer uma linha em um arquivo e passar a tarde procurando por que "no contato não funciona".
⚠️ Atenção
A ordem importa: app.js usa funções declaradas em dados.js. Se você inverter as linhas, o console mostra Uncaught ReferenceError: buscarProdutos is not defined. E não troque defer por async aqui — async executa assim que cada arquivo terminar de baixar, sem garantir ordem.
Passo 3 — Reservar o espaço do status e do botão de recuperação¶
O cardapio.html que você fechou na Aula 08 já tem quase tudo: o formulário #controles-cardapio com busca, filtro e ordenação, o parágrafo #resumo-cardapio, a grade #lista-produtos, o aviso #cardapio-vazio, o <template id="a09-template-produto"> e o painel do pedido. Nada disso muda. Faltam duas coisas: a região que anuncia o carregamento e o botão que oferece uma saída quando a busca falha.
Acrescente as duas linhas entre o resumo e a grade:
HTML
<!-- cafe-cerrado/cardapio.html — entre o #resumo-cardapio e a grade de cards --><pclass="status"id="status-cardapio"role="status"aria-live="polite"></p><buttonclass="btn btn-cafe-vazado mb-3"type="button"id="tentar-de-novo"hidden>
Tentar de novo
</button>
A região do cardápio fica assim (só as duas linhas marcadas com <!-- novo --> são de hoje):
HTML
<!-- cafe-cerrado/cardapio.html — região do cardápio, versão da Aula 09 --><sectionclass="container py-5"aria-labelledby="titulo-cardapio"><h2id="titulo-cardapio"class="mb-4">Nosso cardápio</h2><navaria-label="Seções do cardápio"class="mb-4"><ulclass="nav gap-2"><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"id="cafes"href="#cafes">Cafés</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"id="geladas"href="#geladas">Bebidas geladas</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"id="salgados"href="#salgados">Salgados</a></li><liclass="nav-item"><aclass="btn btn-sm btn-cafe-vazado"id="doces"href="#doces">Doces</a></li></ul></nav><formclass="row g-3 align-items-end mb-4"id="controles-cardapio"role="search"><divclass="col-12 col-md-5"><labelclass="form-label"for="busca">Buscar no cardápio</label><inputclass="form-control"type="search"id="busca"name="busca"placeholder="café, pão de queijo, brownie"autocomplete="off"></div><divclass="col-6 col-md-3"><labelclass="form-label"for="filtro-categoria">Categoria</label><selectclass="form-select"id="filtro-categoria"name="categoria"><optionvalue="">Todas</option></select></div><divclass="col-6 col-md-4"><labelclass="form-label"for="ordenacao">Ordenar por</label><selectclass="form-select"id="ordenacao"name="ordenacao"><optionvalue="nome">Nome (A a Z)</option><optionvalue="preco-asc">Preço (menor primeiro)</option><optionvalue="preco-desc">Preço (maior primeiro)</option></select></div></form><pclass="text-secondary"id="resumo-cardapio"role="status"aria-live="polite"></p><pclass="status"id="status-cardapio"role="status"aria-live="polite"></p><!-- novo --><buttonclass="btn btn-cafe-vazado mb-3"type="button"id="tentar-de-novo"hidden><!-- novo -->
Tentar de novo
</button><divclass="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-4"id="lista-produtos"aria-busy="false"></div><pclass="text-center text-secondary d-none"id="cardapio-vazio"role="status">
Nenhum item do cardápio para mostrar.
</p></section>
Repare que a grade ganhou aria-busy="false": é o atributo que o JavaScript vai virar para "true" durante a espera. E note que existem agora duas regiões vivas com papéis diferentes — #resumo-cardapio fala sobre o resultado dos filtros ("3 de 10 itens…"), #status-cardapio fala sobre a operação ("Carregando o cardápio…", "Não foi possível carregar…"). Estado vazio e estado de erro deixam de se confundir porque cada um tem o seu lugar na tela.
/* cafe-cerrado/css/estilo.css — estados assíncronos (Aula 09) */.status{min-height:1.5rem;margin:0.75rem0;font-weight:600;}.status--erro{color:#a4161a;}.status--vazio{color:var(--cor-texto-suave);font-weight:500;}.esqueleto{height:11rem;border-radius:var(--raio);background:linear-gradient(90deg,#e9e4dd25%,#f5f1ec50%,#e9e4dd75%);background-size:200%100%;animation:brilho1.2slinearinfinite;}@keyframesbrilho{from{background-position:200%0;}to{background-position:-200%0;}}@media(prefers-reduced-motion:reduce){.esqueleto{animation:none;background:#e9e4dd;}}
O bloco prefers-reduced-motion não é enfeite: é a mesma regra de acessibilidade da Aula 05, e vale para qualquer animação nova que você criar.
Passo 5 — Refatorar js/app.js: os dados passam a chegar¶
Aqui está a diferença entre um profissional e um copiador de tutorial: nada do que você escreveu nas Aulas 07 e 08 é apagado hoje. O <template>, criarCardProduto, renderizarProdutos, produtosVisiveis, o objeto ORDENADORES, atualizarResumo, render, o carrinho inteiro (adicionarAoCarrinho, removerDoCarrinho, totalDoCarrinho, renderizarCarrinho), o comAtraso, a delegação de cliques, iniciarTema e iniciarContato continuam byte por byte iguais. Muda só a origem dos dados — quatro edições cirúrgicas.
Edição 1 — o array deixa de ser literal. No topo do js/app.js, apague as oitenta linhas do const produtos = [ … ] e o objeto ROTULOS_CATEGORIA escrito à mão. Eles agora moram no js/dados.js. No lugar deles:
JavaScript
// cafe-cerrado/js/app.js — topo do arquivo (Aula 09)// Os dados não estão mais aqui: eles chegam de js/dados.js, por Promise.letprodutos=[];// preenchido quando buscarProdutos() resolveconstROTULOS_CATEGORIA={};// preenchido quando buscarCategorias() resolve
let no lugar de const porque produtos passa a ser reatribuído quando os dados chegam. ROTULOS_CATEGORIA continua const: o objeto é sempre o mesmo, ele só ganha chaves. E é por isso que produtosVisiveis(), atualizarResumo() e adicionarAoCarrinho() não precisam de uma vírgula sequer: elas leem produtos como sempre leram — só que agora o array começa vazio e enche depois.
O formatadorMoeda e a formatarPreco da Aula 07 ficam onde estão: formatação é assunto de tela, não de dados.
Edição 2 — o filtro de categorias vem da fonte, não do array. Na Aula 08, preencherFiltroDeCategorias deduzia as categorias com um Set sobre os produtos. Agora elas chegam prontas, com id e rótulo, e a mesma função aproveita para preencher o ROTULOS_CATEGORIA que os cards usam:
JavaScript
functionpreencherFiltroDeCategorias(categorias){constselect=document.querySelector("#filtro-categoria");constopcoes=categorias.map((categoria)=>{ROTULOS_CATEGORIA[categoria.id]=categoria.nome;// rótulo do badge do cardconstopcao=document.createElement("option");opcao.value=categoria.id;opcao.textContent=categoria.nome;returnopcao;});select.append(...opcoes);}
Duas responsabilidades em uma função pequena, e nenhuma delas repetida em outro lugar: o nome legível de cada categoria passa a existir em um ponto do sistema, o js/dados.js. Quando a Unidade 3 trocar o dados.js por uma API, o rótulo vem do servidor e nada mais muda aqui.
Edição 3 — três funções novas: status, esqueleto e carregamento. Acrescente-as acima de iniciarCardapio:
JavaScript
functionmostrarStatus(mensagem,modificador=""){conststatus=document.querySelector("#status-cardapio");status.textContent=mensagem;status.className=modificador?`status ${modificador}`:"status";}functionrenderizarEsqueleto(quantidade){constcontainer=document.querySelector("#lista-produtos");container.replaceChildren();container.setAttribute("aria-busy","true");constfragmento=document.createDocumentFragment();for(leti=0;i<quantidade;i+=1){constcoluna=document.createElement("div");coluna.className="col";constcaixa=document.createElement("div");caixa.className="esqueleto";caixa.setAttribute("aria-hidden","true");// decoração: o leitor de tela ignoracoluna.appendChild(caixa);fragmento.appendChild(coluna);}container.appendChild(fragmento);}asyncfunctioncarregarCardapio(){constcontainer=document.querySelector("#lista-produtos");constbotaoTentarDeNovo=document.querySelector("#tentar-de-novo");botaoTentarDeNovo.hidden=true;document.querySelector("#cardapio-vazio").classList.add("d-none");mostrarStatus("Carregando o cardápio…");renderizarEsqueleto(6);constinicio=performance.now();try{const[produtosRecebidos,categorias]=awaitPromise.all([buscarProdutos(),buscarCategorias(),]);produtos=produtosRecebidos;// a variável declarada no topo do arquivopreencherFiltroDeCategorias(categorias);container.setAttribute("aria-busy","false");mostrarStatus("");render();// a mesma render() da Aula 08, sem alteraçãoconsole.log(`Cardápio carregado em ${Math.round(performance.now()-inicio)} ms`);}catch(erro){console.error(erro);container.replaceChildren();container.setAttribute("aria-busy","false");document.querySelector("#resumo-cardapio").textContent="";mostrarStatus("Não foi possível carregar o cardápio. Verifique sua conexão.","status--erro");botaoTentarDeNovo.hidden=false;botaoTentarDeNovo.focus();// quem navega por teclado não precisa procurar}}
O catch faz cinco coisas, e nenhuma delas é decorativa: registra o erro técnico no console (para você), tira os esqueletos da tela (senão eles pulsam para sempre), desliga o aria-busy, mostra uma mensagem em português para a pessoa e oferece o caminho de volta. Um catch que só faz console.error é um catch que mente: a tela continua dizendo "Carregando…".
Edição 4 — iniciarCardapio chama o carregamento. É a única função da Aula 08 que muda, e muda em três linhas. Substitua-a inteira por esta versão:
JavaScript
functioniniciarCardapio(){constcontainer=document.querySelector("#lista-produtos");if(!container)return;// não estamos no cardapio.htmlrenderizarCarrinho();// o pedido começa vazio e não depende da redecarregarCardapio();// no lugar de preencherFiltroDeCategorias() + render()document.querySelector("#tentar-de-novo").addEventListener("click",()=>{constselect=document.querySelector("#filtro-categoria");select.length=1;// descarta as opções da tentativa anterior, mantém "Todas"select.value="";estado.categoria="";carregarCardapio();});constbusca=document.querySelector("#busca");busca.addEventListener("input",comAtraso((evento)=>{estado.termo=evento.target.value.trim().toLowerCase();render();},300),);document.querySelector("#filtro-categoria").addEventListener("change",(evento)=>{estado.categoria=evento.target.value;render();});document.querySelector("#ordenacao").addEventListener("change",(evento)=>{estado.ordenacao=evento.target.value;render();});document.querySelector("#controles-cardapio").addEventListener("submit",(evento)=>{evento.preventDefault();// <Enter> na busca não deve recarregar a página});// Delegação: um ouvinte para os cards, outro para o pedidocontainer.addEventListener("click",(evento)=>{constbotao=evento.target.closest('[data-acao="adicionar"]');if(!botao)return;constcard=botao.closest(".card-produto");adicionarAoCarrinho(Number(card.dataset.id));});document.querySelector("#lista-carrinho").addEventListener("click",(evento)=>{constbotao=evento.target.closest('[data-acao="remover"]');if(!botao)return;removerDoCarrinho(Number(botao.dataset.id));});}
Compare com a versão da Aula 08: os oito ouvintes são idênticos, estado.termo, estado.categoria e estado.ordenacao continuam com os mesmos nomes, e a delegação segue apontando para os mesmos seletores. As três linhas novas são carregarCardapio() no lugar do par preencherFiltroDeCategorias() + render() e o ouvinte do botão "Tentar de novo".
E a chamada final do arquivo, escrita na Aula 07, continua exatamente como estava:
Note o que não aconteceu: a delegação de cliques foi registrada uma vez, em iniciarCardapio, e vale para cards que ainda nem existiam quando o ouvinte nasceu. É por isso que trocar todos os cards por Promises não quebrou o carrinho — o ouvinte está no contêiner, não nos cards. Essa decisão da Aula 07 é o que torna a refatoração de hoje tão pequena.
Clique com o botão direito em cardapio.html no VS Code e escolha Open with Live Server. A URL precisa começar com http://127.0.0.1, não com file://.
Recarregue a página algumas vezes. Em cerca de três de cada quatro vezes você deve ver: os seis retângulos cinza pulsando por ~1,2 s, depois os dez cards reais, o <select> de categoria com cinco opções ("Todas" mais as quatro) e a mensagem de status sumindo.
Nas outras vezes, aparece "Não foi possível carregar o cardápio" em vermelho, os esqueletos somem e o botão Tentar de novo recebe o foco. Clique nele: o ciclo recomeça e o <select> não fica com categorias duplicadas.
Digite caf na busca: sobra um card, o Frappê de Café. Agora zzz: nenhum card, o aviso #cardapio-vazio aparece e o resumo diz "Nenhum item corresponde à sua busca." Esse é o estado vazio — texto neutro, sem botão de recuperação —, visivelmente diferente do estado de erro do passo anterior.
Clique duas vezes em "Adicionar ao pedido" no Coado da Casa e uma vez na Torta de Frango: o pedido mostra "Total: R$ 30,00" e o contador 3. O carrinho da Aula 08 continua inteiro — se ele quebrou, alguma função foi apagada em vez de refatorada.
Abra contato.html e envie o formulário vazio: as mensagens de erro da Aula 07 continuam aparecendo e o foco vai para o primeiro campo inválido. A validação também sobreviveu.
No console, confira a linha Cardápio carregado em N ms. Ela deve ficar perto de 1200 ms, não de 2000 ms: as duas buscas rodaram em paralelo. Para provar, troque temporariamente o Promise.all por dois await em sequência e recarregue — o número pula para cerca de 2000 ms.
Na aba Network do DevTools, mude a velocidade de "No throttling" para Slow 4G e recarregue. Os arquivos demoram mais para chegar, mas a página continua rolável durante toda a espera — a prova de que o await não bloqueia nada.
Resultado esperado: o cardápio nunca aparece "do nada"; ele sempre passa por um estado visível de carregamento, e qualquer falha resulta em mensagem clara com caminho de recuperação — sem tela branca e sem console silencioso. Busca, filtro, ordenação, carrinho e validação continuam funcionando como na Aula 08.
Terminal
gitadd.
gitcommit-m"refactor: cardapio carregado por Promises com estados de carregando, erro e vazio"
gitpush
A3. Este trecho tem um erro que o navegador recusa antes de rodar. Qual é a mensagem exata do console e como se conserta sem transformar a função em async?
A5. Em carregarCardapio(), o Promise.all recebe [buscarProdutos(), buscarCategorias()] — com os parênteses. Se alguém trocar por [buscarProdutos, buscarCategorias] (sem parênteses), o que acontece com o valor de produtos e categorias? A tela quebra ou apenas fica errada? Justifique.
A6.buscarProdutos() demora 1200 ms e buscarCategorias() demora 800 ms. Quanto tempo, aproximadamente, leva cada uma das três funções abaixo?
B1. Escreva esperar(ms), uma função que devolve uma Promise que resolve (sem valor) depois de ms milissegundos, e use-a para imprimir três mensagens espaçadas de 1 segundo dentro de uma função async, sem nenhum setTimeout aninhado.
Resultado esperado: no console, "um", "dois" e "três" aparecem com um segundo de intervalo, e o código tem uma linha await esperar(1000); entre eles.
Dica
O executor da Promise pode ignorar o reject: new Promise((resolve) => setTimeout(resolve, ms)). Como não há valor, você não precisa de variável para receber o await.
B2. No js/dados.js, acrescente buscarAvaliacoes(idProduto), que resolve depois de 500 ms com um array de objetos { autor, nota, comentario } (mínimo três avaliações para o produto 1) e rejeita com Error para qualquer outro id. Em app.js, crie mostrarAvaliacoes(id) que trate os dois casos.
Resultado esperado: mostrarAvaliacoes(1) imprime as avaliações; mostrarAvaliacoes(4) imprime uma mensagem amigável de erro no console — e nenhuma linha Uncaught (in promise) aparece.
Dica
Copie a estrutura de buscarProdutoPorId. O try/catch de mostrarAvaliacoes é o que impede o Uncaught (in promise).
B3. Converta a cadeia abaixo para async/await com try/catch/finally, preservando exatamente o mesmo comportamento (inclusive a ordem das mensagens):
Resultado esperado: a versão nova roda com a mesma sequência de mensagens no caminho de sucesso e no de falha; force os dois cenários subindo CHANCE_DE_FALHA para 1 e baixando para 0.
Dica
Cada .then vira uma linha depois do await; o .catch vira o bloco catch (erro); o .finally vira o bloco finally. Não esqueça o async na declaração.
B4. Implemente o estado vazio de verdade no seu projeto autoral: quando a busca não encontrar nada, além da mensagem, mostre um botão "Limpar filtros" que zera estado.termo, estado.categoria e os campos do formulário, e re-renderiza a lista completa.
Resultado esperado: buscar por zzz mostra a mensagem e o botão; clicar no botão devolve todos os itens e esconde o botão de novo.
Dica
O botão pode ficar no HTML com hidden, como o "Tentar de novo". Ao limpar, lembre-se de atualizar também document.querySelector("#busca").value e o value do #filtro-categoria — o estado do objeto e o estado dos campos precisam andar juntos.
B5. Meça e documente. Crie no app.js uma função medirCarregamento() que rode a versão sequencial e a versão paralela do carregamento (cinco vezes cada) e imprima a média de cada uma com performance.now().
Resultado esperado: duas linhas no console, algo como sequencial: 2013 ms (média de 5) e paralelo: 1207 ms (média de 5), com a diferença aproximada de 800 ms — o tempo de buscarCategorias.
Dica
Zere CHANCE_DE_FALHA antes de medir, senão uma rejeição derruba a rodada. Um laço for com await dentro acumula os tempos; divida pelo número de repetições no final.
C1. Nem toda falha é permanente: uma rede instável costuma funcionar na segunda tentativa. Implemente em js/dados.js a função comTentativas(criarPromessa, tentativas, esperaInicial) que executa criarPromessa(), e em caso de rejeição tenta de novo, dobrando a espera a cada falha (500 ms, 1000 ms, 2000 ms) — o padrão conhecido como exponential backoff. Depois, envolva a chamada com um limite de tempo total usando Promise.race, de modo que a tela nunca fique presa em "Carregando…" por mais de 8 segundos.
Resultado esperado: com CHANCE_DE_FALHA = 0.6, o cardápio carrega na maioria das recargas (o console mostra as tentativas com console.warn); com CHANCE_DE_FALHA = 1, depois de três tentativas a tela mostra a mensagem de erro e o botão "Tentar de novo" em no máximo 8 segundos.
Dica
O parâmetro precisa ser uma função que cria a Promise (() => buscarProdutos()), não a Promise pronta — uma Promise já rejeitada não pode ser "executada de novo". Um laço for com try/catch dentro de uma função async resolve: no catch, se ainda houver tentativas, await esperar(espera) e espera *= 2; se acabaram, throw. O limite total é o comLimiteDeTempo da seção 7.2 aplicado sobre o resultado de comTentativas.
Você trocou dois await em sequência por um Promise.all e alguém disse que ficou mais rápido. Mas "mais rápido" é opinião até virar número. Meça: quanto tempo, exatamente, o seu cardápio leva para aparecer nas duas versões? E o que acontece com esses números quando a conexão é ruim de verdade?
Critérios de pronto
Uma função medir(rotulo, tarefa) no app.js imprime rotulo e o tempo em milissegundos usando performance.now(), arredondado para inteiro.
O README.md do seu projeto autoral ganha uma tabela com três linhas — sequencial, paralelo e a diferença — para dois cenários da aba Network: "No throttling" e "Slow 4G".
A tabela registra a média de cinco medições por cenário, não uma medição única, e o texto explica em duas linhas por que uma medição só não serve.
Uma frase responde: quando a conexão piora, a vantagem do paralelo aumenta, diminui ou fica igual? Por quê?
Pistas
performance.now() devolve milissegundos com casas decimais desde o carregamento da página — muito mais preciso que Date.now() para medir trechos curtos.
A aba Network tem um seletor de velocidade ("No throttling", "Slow 4G", "Fast 4G"). Ele também afeta o download dos seus próprios arquivos .js, então recarregue a página inteira entre as medições.
console.table() recebe um array de objetos e imprime uma tabela formatada no console — copiar de lá para o README é mais rápido que anotar à mão.
Para a última pergunta, pense no que é somado no caso sequencial: dois tempos de espera, cada um com a latência da rede embutida.
Ative "Slow 4G" na aba Network, digite a, depois ca, depois café na busca do cardápio — rápido, como qualquer pessoa digita. De vez em quando a tela mostra o resultado de a embora o campo diga café. O bug não está na sua lógica de filtro: está no fato de que respostas de rede não chegam na ordem em que foram pedidas. Reproduza a falha de forma confiável, entenda por que ela acontece e conserte.
Para reproduzir, troque a busca local por uma busca "no servidor": acrescente ao dados.js uma função buscarProdutosPorTermo(termo) que espera um tempo aleatório entre 200 ms e 2000 ms antes de resolver com os produtos filtrados.
Critérios de pronto
Um comentário no topo da função registra a sequência de teclas que reproduz o bug e o que aparece na tela quando ele ocorre.
Depois da correção, digitar qualquer sequência rápida sempre termina exibindo o resultado do último termo digitado, mesmo com atrasos aleatórios.
A correção descarta respostas obsoletas explicitamente (nada de "resolver" escondendo o problema com um setTimeout maior).
Há também um debounce: enquanto a pessoa digita, a busca só dispara depois de 300 ms de pausa — e o README explica em três linhas a diferença entre o que o debounce resolve e o que o descarte de resposta obsoleta resolve.
Pistas
Math.random() * 1800 + 200 dá o atraso aleatório da faixa pedida.
A seção 6.2 mostra o padrão do contador de requisições. A variável do contador precisa viver fora da função, senão ela reinicia a cada chamada.
Debounce é clearTimeout seguido de setTimeout: cada tecla cancela o temporizador anterior. Ele reduz o número de requisições, mas não garante a ordem das que sobraram.
Para provar que o conserto funciona, imprima no console o termo de cada resposta que chega e marque com um prefixo as que foram descartadas.
Feche os olhos e navegue no seu cardápio usando só o teclado e o leitor de tela do sistema (NVDA no Windows, Orca no Linux, VoiceOver no macOS). Enquanto os dados carregam, você provavelmente ouve silêncio — e quando eles chegam, também. Uma pessoa cega não sabe se a página travou, se está carregando ou se não há nada. Conserte isso no seu projeto autoral.
Critérios de pronto
A região de status tem role="status" e aria-live="polite", existe no HTML desde o carregamento inicial (não é criada por JavaScript) e anuncia as quatro situações: carregando, quantidade de itens carregados, erro e resultado vazio.
O contêiner da lista alterna aria-busy entre "true" e "false" nos momentos corretos, inclusive no caminho de erro.
Os retângulos de esqueleto não são anunciados pelo leitor de tela (eles são decoração, não conteúdo).
O botão "Tentar de novo" recebe o foco quando aparece, para que quem navega por teclado não precise procurar por ele.
Um roteiro de teste de 6 passos no README.md descreve o que foi ouvido em cada etapa, com o nome do leitor de tela usado.
Pistas
Um elemento decorativo se esconde do leitor de tela com aria-hidden="true".
elemento.focus() move o foco; para que faça sentido, o elemento precisa estar visível — mude o hidden antes de chamar o foco.
Anunciar "10 itens carregados" é mais útil que "carregado": diga o que mudou, não que algo mudou.
Se o mesmo texto for atribuído duas vezes seguidas ao elemento com aria-live, o leitor não repete o anúncio. Se você precisar reanunciar, limpe o texto antes.
Promise.all com 30 operações dispara as 30 ao mesmo tempo. Em um navegador real isso esbarra no limite de conexões simultâneas por domínio (historicamente 6 no HTTP/1.1) e, em um servidor de verdade, é um jeito rápido de derrubar a própria API. Empresas resolvem isso com uma fila com limite de concorrência: no máximo N tarefas rodando, e assim que uma termina, a próxima entra. Implemente a sua.
Critérios de pronto
Uma função executarComLimite(tarefas, limite) recebe um array de funções que devolvem Promise e um número máximo de execuções simultâneas, e devolve uma Promise que resolve com o array de resultados na ordem original das tarefas, não na ordem de conclusão.
Uma falha isolada não derruba o lote: o resultado de cada posição é { status, valor } ou { status, erro }, no espírito do Promise.allSettled.
Um teste no console cria 30 tarefas com atrasos aleatórios entre 100 ms e 900 ms, roda com limite = 4, e imprime uma linha por tarefa registrando quando começou e quando terminou.
Um contador global prova que nunca houve mais de 4 tarefas rodando ao mesmo tempo: o pico registrado é exatamente 4.
O README.md compara o tempo total com limite = 1, limite = 4 e limite = 30, e explica em três linhas por que o ganho para de crescer a partir de certo ponto.
Pistas
O array de entrada precisa ser de funções (() => buscarProduto(3)), não de Promises já criadas — uma Promise criada já começou a rodar, e aí não há o que limitar.
Um padrão que funciona: mantenha um índice compartilhado e crie exatamente limite "trabalhadores", cada um um laço whileasync que pega o próximo índice disponível, executa e guarda o resultado na posição certa do array de saída. Promise.all dos trabalhadores resolve quando todos esvaziarem a fila.
Para o contador de pico: incremente antes do await da tarefa, decremente no finally, e guarde o máximo já visto.
Cuidado com o índice: const meuIndice = proximo++ dentro do trabalhador garante que dois trabalhadores nunca peguem a mesma tarefa, porque o incremento é síncrono.
No seu projeto autoral (não no Café Cerrado), aplique tudo o que fizemos hoje:
Crie js/dados.js com uma fonte simulada do seu domínio: buscarItens() e buscarCategorias(), ambas devolvendo Promises com atraso entre 1 e 2 segundos via setTimeout.
Faça as duas falharem aleatoriamente em cerca de 30% das chamadas, rejeitando com um objeto Error cuja mensagem diga o que falhou.
Refatore a inicialização da sua lista para uma função async com o padrão completo: mensagem de carregando (com role="status" e aria-live="polite"), try/catch/finally, mensagem de erro amigável e botão "Tentar de novo".
Carregue as duas fontes em paralelo com Promise.all e registre no console o tempo total com performance.now().
Garanta os quatro estados visuais: carregando, sucesso, erro e vazio (busca sem resultados). Cada um com um texto diferente.
Recarregue a página dez vezes e confirme que a interface se comporta bem nos dois cenários — nenhuma tela branca, nenhum Uncaught (in promise) no console.
Critério de pronto: a lista do seu projeto autoral nunca aparece instantaneamente; ela sempre passa por "Carregando…", e uma falha simulada produz mensagem visível na tela com caminho de recuperação. O console fica limpo de erros não tratados.
Guarde no seu repositório: commit + push.
Leitura dirigida (se você tem acesso a uma biblioteca virtual pela sua instituição): QUEIRÓS & PORTELA, capítulo de JavaScript assíncrono; MDN, Usando Promises e a página da Fetch API — o fetch é o protagonista da próxima aula.
QUEIRÓS, R.; PORTELA, F. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — seção de JavaScript assíncrono.
LOUDON, K. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — fluxos assíncronos em aplicações grandes.
PUREWAL, S. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — capítulo sobre interação com o servidor.
Na próxima aula a simulação sai de cena. O setTimeout do js/dados.js dá lugar ao fetch: você vai buscar JSON de um arquivo do próprio projeto e de uma API pública na internet, enviar dados com POST e transformar o Café Cerrado em uma SPA — uma única página em que a navegação acontece sem recarregar nada. É também a aula que fecha a Unidade 2 e traz o Marco 2.
Nível 2Unidade 2 · Web dinâmica client-side3 aulas de 50 min + 1 h EADFecha a unidade · Marco 2
Aula 10 — AJAX, JSON e Single Page Application
Nível 2 — Desenvolvimento Web · WebLab
Na aula passada você aprendeu a esperar por um valor que ainda não chegou. Só que o valor não chegava de lugar nenhum: era o seu próprio setTimeout fingindo ser um servidor. Hoje a simulação acaba. O fetch entra em cena, os produtos do Café Cerrado passam a nascer de um arquivo JSON, o formulário de contato ganha destino de verdade e a navegação entre as páginas deixa de recarregar o navegador. No fim da aula você não terá mais um site: terá uma aplicação. E esta é a aula que fecha a Unidade 2.
[ ] Repositório cafe-cerrado com js/dados.js e js/app.js da Aula 09 funcionando, incluindo os estados carregando/sucesso/erro/vazio.
[ ] Bootstrap 5.3 por CDN e css/estilo.css como na Unidade 1, com skip link e foco visível (Aula 06).
[ ] Formulário de contato com validação nativa e mensagens em JavaScript (Aulas 03 e 07).
[ ] Live Server instalado — hoje o http:// deixa de ser recomendação e passa a ser obrigatório.
[ ] Internet funcionando na máquina: vamos chamar duas APIs públicas na internet.
Na aula passada o cardápio passou a chegar "depois": buscarProdutos() devolvia uma Promise, o Promise.all disparava duas buscas em paralelo e a interface aprendeu a mostrar carregando, sucesso, erro e vazio. Hoje trocamos o setTimeout por rede de verdade — e tudo que você escreveu continua valendo, porque o fetch também devolve Promises. Depois disso, juntamos as três páginas do site em uma só e implementamos a navegação sem recarga que define uma SPA.
Até o começo dos anos 2000, toda interação com um site significava uma volta completa ao servidor: você clicava, o navegador pedia uma página HTML inteira, o servidor a montava e devolvia, e a tela piscava em branco antes de redesenhar tudo. Marcar um e-mail como lido? Página nova. Ir para a página 2 de resultados? Página nova. Corrigir um campo do formulário? Página nova — e adeus aos outros campos que você já tinha preenchido.
Isso limitava o que dava para construir na Web. Aplicações "de verdade" eram programas instalados; o navegador servia para ler documentos.
AJAX é a sigla de Asynchronous JavaScript And XML. A ideia: o JavaScript faz requisições HTTP por conta própria, em segundo plano, recebe só os dados de que precisa e atualiza apenas o pedaço do DOM que mudou. A página não recarrega.
É o que acontece quando um e-mail novo aparece na sua caixa sem você fazer nada, quando o feed carrega mais posts ao chegar no fim da rolagem, quando a busca sugere resultados a cada tecla, quando o "curtir" muda de cor antes de qualquer navegação. Todos esses são pedaços de tela sendo atualizados com dados que chegaram por AJAX.
A ferramenta original era o objeto XMLHttpRequest. Ele funciona até hoje, mas é verboso e baseado em callbacks e eventos:
JavaScript
// Como era com XMLHttpRequest — mostrado só para você reconhecer em código antigo.constrequisicao=newXMLHttpRequest();requisicao.open("GET","data/produtos.json");requisicao.onload=function(){if(requisicao.status>=200&&requisicao.status<300){constprodutos=JSON.parse(requisicao.responseText);console.log(produtos.length,"produtos");}else{console.error("Erro HTTP",requisicao.status);}};requisicao.onerror=function(){console.error("Falha de rede");};requisicao.send();
O padrão moderno é a Fetch API, baseada em Promises — exatamente o que você dominou na Aula 09. É o que usaremos daqui em diante.
🧠 Você sabia?
A tecnologia veio antes do nome. A Microsoft criou o componente XMLHTTP por volta de 1999 para o Outlook Web Access — queriam que o webmail parecesse o Outlook instalado. O Mozilla copiou a ideia como XMLHttpRequest, e o Google a levou ao limite no Gmail (2004) e no Google Maps (2005), que arrastava o mapa sem recarregar nada. O nome "AJAX" só apareceu em fevereiro de 2005, num artigo do designer Jesse James Garrett. E a ironia ficou: o "X" era de XML, mas quem venceu como formato de dados foi o JSON — a sigla, porém, já estava na boca do mundo e ninguém mais trocou.
JSON (JavaScript Object Notation) é texto puro com a aparência de um objeto JavaScript. Foi criado por Douglas Crockford no início dos anos 2000 como alternativa enxuta ao XML, e hoje é o formato do tráfego navegador ↔ servidor em praticamente toda API do mundo. Python, Java, PHP, C#, Go: todas as linguagens leem e escrevem JSON.
{"id":1,"nome":"Espresso do Cerrado","preco":6,"disponivel":true,"observacao":null,"tamanhos":["pequeno","médio","grande"],"fornecedor":{"cidade":"Sinop","uf":"MT"}}
Os tipos permitidos são apenas seis: string (sempre com aspas duplas), número, booleano, null, array e objeto. Nada mais.
Parece, mas não é. As diferenças pegam todo mundo pelo menos uma vez:
Em JavaScript é válido
Em JSON é proibido
Consequência
{ nome: "Café" }
chave sem aspas
erro de parse
{ 'nome': 'Café' }
aspas simples
erro de parse
{ "a": 1, }
vírgula sobrando no fim
erro de parse
// comentário
qualquer comentário
erro de parse
{ "f": function () {} }
funções
erro de parse
{ "x": undefined }
undefined
erro de parse
Não existe tipo data em JSON: datas viajam como string (normalmente no formato ISO, "2030-03-15T19:00:00.000Z") e são convertidas para Date no destino, se precisar.
constproduto={id:1,nome:"Espresso do Cerrado",preco:6};consttexto=JSON.stringify(produto);console.log(texto);console.log(typeoftexto);// "string" — pronto para ENVIAR pela redeconstobjeto=JSON.parse(texto);console.log(objeto.nome);// "Espresso do Cerrado"console.log(typeofobjeto);// "object" — pronto para USAR no código
Duas regras para não confundir nunca mais:
stringify = enviar. Vai no body de um POST, ou no localStorage.
parse = receber. Transforma texto em objeto quando os dados chegam como texto.
O stringify aceita um terceiro parâmetro de indentação, muito útil para depurar:
A saída é {"nome":"Café","quando":"2030-03-15T19:00:00.000Z"}. A função e o undefinedsomem sem aviso, e a data virou string. Não é bug: é a especificação. Mas é motivo frequente de "meus dados chegaram incompletos no servidor".
🔬 Investigue
Abra o console e rode JSON.parse('{ "a": 1, }'). Leia a mensagem de erro inteira, com atenção ao número da posição. Agora rode JSON.parse("{ 'a': 1 }") e depois JSON.parse('{ a: 1 }'). Anote as três mensagens: elas são exatamente as que você vai ver quando escrever um .json à mão e esquecer uma vírgula ou uma aspa. Saber traduzir "position 10" para "a décima primeira letra do arquivo" economiza muito tempo.
fetch(url) devolve uma Promise. Ela resolve com um objeto Response — que representa a resposta, não os dados. Extrair os dados é uma segunda operação assíncrona:
JavaScript
asyncfunctionbuscarProdutos(){constresposta=awaitfetch("data/produtos.json");// 1º await: chegaram os cabeçalhosconstprodutos=awaitresposta.json();// 2º await: chegou e foi lido o corporeturnprodutos;}
Por que dois await? Porque uma resposta HTTP chega em duas etapas: primeiro a linha de status e os cabeçalhos (o servidor já pode dizer "200 OK, tipo JSON"), depois o corpo, que pode ser grande e demorar. O fetch te devolve o controle assim que os cabeçalhos chegam, para que você possa decidir se vale a pena ler o corpo.
O objeto Response tem, entre outras, estas propriedades e métodos:
Leia com atenção, porque isso derruba metade de quem está aprendendo:
⚠️ Atenção
A Promise do fetchsó rejeita quando a requisição não acontece: sem internet, DNS que não resolve, CORS bloqueado, URL malformada. Um 404 Not Found ou um 500 Internal Server Error são respostas bem-sucedidas do ponto de vista do fetch — ele conseguiu falar com o servidor, e o servidor respondeu. A Promise resolve normalmente. Se você não testar resposta.ok, seu código vai tentar processar uma página de erro como se fossem dados.
O sintoma clássico dessa falta é uma mensagem que parece não ter nada a ver:
Texto
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Traduzindo: o servidor devolveu uma página HTML de erro 404, e o .json() tentou interpretar <!DOCTYPE html> como JSON. A causa real está três linhas acima, no fetch sem verificação.
A forma correta:
JavaScript
asyncfunctionbuscarProdutos(){constresposta=awaitfetch("data/produtos.json");if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} ao buscar os produtos`);}returnresposta.json();}
A distinção 4xx × 5xx é prática: 4xx é culpa sua (URL errada, dados inválidos, sem permissão) e você conserta no código; 5xx é culpa do servidor e a única saída do cliente é avisar o usuário e oferecer nova tentativa.
🔎 Por baixo do capô
Abra a aba Network do DevTools, recarregue qualquer página e clique em uma requisição. Em "Headers" você vê exatamente o que foi trocado: o método (GET), a URL, os cabeçalhos de requisição (Accept, User-Agent) e os de resposta (Content-Type, Cache-Control). É o mesmo protocolo HTTP da Aula 01 (§3), só que agora você é quem escreve as requisições. Tudo o que o fetch faz aparece ali — inclusive as que falharam.
Escrever if (!resposta.ok) throw em cada chamada é repetitivo e fácil de esquecer. Concentre em um lugar só:
JavaScript
asyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} (${resposta.statusText}) em ${url}`);}returnresposta.json();}
Toda chamada do projeto passa por aqui. Se amanhã você precisar acrescentar um cabeçalho de autenticação em todas as requisições — e vai precisar, na Aula 14 —, muda-se uma função só.
Na Aula 09 você viu que Promise.race com um temporizador para de esperar, mas não cancela nada. Com fetch dá para cancelar de verdade:
JavaScript
asyncfunctionpegarJsonComPrazo(url,milissegundos=8000){constcontrolador=newAbortController();consttemporizador=setTimeout(()=>controlador.abort(),milissegundos);try{constresposta=awaitfetch(url,{signal:controlador.signal});if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} em ${url}`);}returnawaitresposta.json();}catch(erro){if(erro.name==="AbortError"){thrownewError(`A resposta demorou mais de ${milissegundos} ms e a requisição foi cancelada`);}throwerro;}finally{clearTimeout(temporizador);}}
O signal é a ponte: quando abort() é chamado, o navegador interrompe a conexão e a Promise do fetch rejeita com um erro cujo name é "AbortError".
O passo mais natural depois da simulação da Aula 09: tirar o array de dentro do JavaScript e colocá-lo em data/produtos.json. Vantagens imediatas: quem edita o cardápio não precisa mexer em código, e o formato é exatamente o que uma API devolveria — quando você construir a sua, na Unidade 3, o front-end nem vai notar a troca.
Abra index.html com dois cliques, direto do gerenciador de arquivos, e o console mostra:
Texto
Access to fetch at 'file:///home/aluno/cafe-cerrado/data/produtos.json' from origin 'null'
has been blocked by CORS policy: Cross origin requests are only supported for
protocol schemes: http, https, ...
A origem de uma página é a combinação de protocolo + domínio + porta (http://127.0.0.1:5500). Páginas abertas como arquivo têm origem null, e o navegador recusa requisições a partir dela por segurança — senão qualquer HTML baixado poderia ler os seus arquivos locais.
A solução é servir o projeto por HTTP: Live Server no VS Code (http://127.0.0.1:5500). Isso vale também para os módulos ES da seção 5.
CORS (Cross-Origin Resource Sharing) é a regra que decide se uma página de uma origem pode ler a resposta de outra origem. Quem decide é o servidor de destino: se ele mandar o cabeçalho Access-Control-Allow-Origin, o navegador libera; se não mandar, o navegador bloqueia a leitura mesmo que a requisição tenha chegado ao destino.
Duas consequências que você vai encontrar hoje:
JSONPlaceholder e ViaCEP mandam esse cabeçalho de propósito, para permitir uso em qualquer página. Por isso funcionam.
Muitos sites não mandam. Tentar fetch("https://www.wikipedia.org") do seu projeto resulta em bloqueio — e não há nada que você possa fazer do lado do cliente. A solução, quando você tiver um servidor próprio, é pedir do back-end.
⚠️ Atenção
Erro de CORS não é um 404 nem um erro do seu código: é o navegador recusando entregar a resposta ao JavaScript. Na aba Network a requisição costuma aparecer como enviada, e mesmo assim o fetch rejeita com TypeError: Failed to fetch. Sempre leia o console inteiro: a mensagem de CORS é longa e explica exatamente qual cabeçalho faltou.
JSONPlaceholder (https://jsonplaceholder.typicode.com) é uma API falsa de treino: devolve posts, comentários, usuários e fotos, e aceita POST, PUT e DELETE fingindo que salvou. Nada é gravado de verdade, o que é perfeito para aprender sem estragar nada.
Detalhe importante do ViaCEP: quando o CEP não existe, ele responde 200 OK com { "erro": "true" }. Ou seja, resposta.ok é true e ainda assim não há endereço. Sempre leia a documentação de cada API — o contrato de erro não é padronizado entre elas.
5. Módulos ES: o código deixa de ser um arquivo só¶
Até a Aula 09, js/dados.js e js/app.js eram carregados com defer e conversavam por variáveis globais. Funciona para dois arquivos; começa a doer no terceiro. Hoje o projeto ganha três arquivos com papéis distintos, e é o momento certo de usar módulos ES.
HTML
<scripttype="module"src="js/app.js"></script>
O que type="module" muda:
Cada arquivo tem escopo próprio: nada vaza para window. Para compartilhar algo, você exporta e importa explicitamente.
A execução é adiada por padrão (comporta-se como defer), então a posição da tag não importa.
import e export passam a funcionar, e a ordem de carregamento é resolvida pelo navegador a partir das dependências.
O modo estrito ("use strict") é sempre ligado.
Só funciona por HTTP — mais um motivo para o Live Server.
// arquivo js/app.js — importa (repare no './' e no '.js' obrigatórios)import{pegarJson,BASE}from"./api.js";
💡 Dica
Ao contrário do Node.js, o navegador exige o caminho completo e relativo: "./api.js", com o ponto inicial e a extensão. Escrever import { pegarJson } from "api.js" resulta em erro, porque sem ./ o navegador acha que é o nome de um pacote.
Juntando fetch + DOM + eventos, chega-se ao padrão que define as aplicações web modernas: carrega-se um HTML; a partir daí o JavaScript intercepta a navegação, busca dados por AJAX e troca o conteúdo da tela. A página nunca recarrega.
Aspecto
Site multipágina (MPA)
SPA
Navegação
cada clique traz um HTML novo
o JS troca o conteúdo
Dados
já embutidos no HTML
buscados por AJAX (JSON)
Primeira carga
rápida
mais pesada (todo o JS)
Sensação
"site"
"aplicativo"
Nenhum dos dois é melhor sempre. Um blog ou um portal institucional vive bem como MPA e ainda ganha em SEO e em tempo de primeira carga. Um painel, um webmail, um sistema com muita interação ganha como SPA.
Frameworks como Vue e React automatizam a SPA (você verá isso no Nível 3), mas o núcleo cabe em uma função. Duas peças:
Telas: seções no HTML, apenas uma visível por vez.
Roteador: uma função que lê a URL, decide qual tela mostrar e dispara o carregamento de dados daquela tela.
HTML
<nav><ahref="#/inicio">Início</a><ahref="#/cardapio">Cardápio</a><ahref="#/contato">Contato</a></nav><main><sectiondata-rota="/inicio">Conteúdo da tela inicial</section><sectiondata-rota="/cardapio"hidden>Conteúdo do cardápio</section><sectiondata-rota="/contato"hidden>Conteúdo do contato</section></main>
JavaScript
functionnavegar(){constrota=location.hash.slice(1)||"/inicio";document.querySelectorAll("[data-rota]").forEach((tela)=>{tela.hidden=tela.dataset.rota!==rota;});}window.addEventListener("hashchange",navegar);// o usuário navegounavegar();// primeira carga: respeita a URL atual
O location.hash de http://site.com/#/cardapio é a string "#/cardapio"; o .slice(1) remove o # e sobra "/cardapio".
Existe outra forma de fazer roteamento sem recarga, com history.pushState(), que produz URLs limpas (/cardapio em vez de /#/cardapio). Ela é a usada em produção pelos frameworks — mas exige uma configuração no servidor: qualquer caminho precisa devolver o mesmo index.html, senão recarregar a página em /cardapio resulta em 404.
O GitHub Pages, onde o Café Cerrado está publicado, não permite essa configuração. Por isso usamos o hash: ele funciona em qualquer hospedagem estática, sem servidor nenhum, porque o navegador nunca envia a parte depois do # na requisição HTTP.
E o hash preserva de graça três coisas que uma SPA malfeita quebra:
Voltar e avançar no navegador funcionam, porque mudar o hash cria uma entrada no histórico.
Favoritar e compartilhar um link direto para uma tela funciona.
Recarregar com F5 mantém você na mesma tela.
6.4 O que a SPA quebra na acessibilidade (e como consertar)¶
Quando o navegador carrega uma página nova, ele faz três coisas automaticamente que uma SPA precisa refazer à mão:
Anuncia o título da página. Atualize document.title a cada troca de tela.
Move o foco para o topo do documento. Sem isso, quem navega por teclado continua com o foco no link clicado e o leitor de tela não anuncia nada. Solução: dar tabindex="-1" ao título da tela e chamar .focus() nele.
Sinaliza onde você está. No menu, marque o link ativo com aria-current="page" — o mesmo atributo da Aula 03, agora atualizado por JavaScript.
📌 Vale gravar
Três perguntas recorrentes: (1) por que fetch não rejeita em um 404; (2) qual a diferença entre JSON.parse e response.json() — o segundo lê o corpo e faz o parse, devolvendo uma Promise; (3) por que a navegação por hash preserva o histórico do navegador enquanto trocar classes CSS "na mão" não preserva.
Vamos transformar o site em uma aplicação de página única, com dados vindos de arquivo JSON e de APIs reais. O repositório termina a aula com esta estrutura:
Os arquivos cardapio.html, contato.html e js/dados.js deixam de existir: o conteúdo deles migra para o index.html e para o data/. Apague-os ao final — o histórico do Git guarda tudo, nada se perde.
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
Salve e abra http://127.0.0.1:5500/data/produtos.json no navegador. Se aparecer o JSON formatado, o caminho está certo; se aparecer 404, confira o nome da pasta antes de seguir.
Passo 2 — js/api.js: todo acesso a dados em um lugar¶
JavaScript
// cafe-cerrado/js/api.js// Camada de acesso a dados do Café Cerrado.// Nenhuma outra parte do projeto chama fetch diretamente.constBASE_TESTE="https://jsonplaceholder.typicode.com";constBASE_CEP="https://viacep.com.br/ws";/** * Faz a requisição, verifica o status e devolve o corpo já convertido de JSON. * Toda chamada do projeto passa por aqui. */exportasyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} (${resposta.statusText}) em ${url}`);}returnresposta.json();}exportfunctionbuscarProdutos(){returnpegarJson("data/produtos.json");}exportfunctionbuscarCategorias(){returnpegarJson("data/categorias.json");}/** Depoimentos de clientes — por enquanto, comentários falsos da JSONPlaceholder. */exportfunctionbuscarDepoimentos(){returnpegarJson(`${BASE_TESTE}/comments?postId=1&_limit=4`);}/** Envia a mensagem do formulário de contato. A JSONPlaceholder responde 201 com um id. */exportfunctionenviarMensagem(mensagem){returnpegarJson(`${BASE_TESTE}/posts`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(mensagem),});}/** Consulta o endereço de um CEP brasileiro no ViaCEP. */exportasyncfunctionbuscarEnderecoPorCep(cep){constdigitos=cep.replace(/\D/g,"");if(digitos.length!==8){thrownewError("O CEP precisa ter 8 dígitos.");}constendereco=awaitpegarJson(`${BASE_CEP}/${digitos}/json/`);// O ViaCEP responde 200 OK com { "erro": "true" } quando o CEP não existe.if(endereco.erro){thrownewError("CEP não encontrado.");}returnendereco;}
O <head> continua exatamente como na Aula 06 (Bootstrap 5.3 por CDN com integrity, css/estilo.css, meta viewport). O que muda é o menu, o <main> e a linha do <script>.
O menu passa a apontar para rotas com #:
HTML
<!-- cafe-cerrado/index.html — navegação --><aclass="pular-para-conteudo"href="#conteudo">Pular para o conteúdo</a><header><navclass="navbar navbar-expand-md navbar-light"aria-label="Navegação principal"><divclass="container"><aclass="navbar-brand"href="#/inicio">Café Cerrado</a><ulclass="navbar-nav flex-row gap-3"><liclass="nav-item"><aclass="nav-link"href="#/inicio"data-link="/inicio">Início</a></li><liclass="nav-item"><aclass="nav-link"href="#/cardapio"data-link="/cardapio">Cardápio</a></li><liclass="nav-item"><aclass="nav-link"href="#/contato"data-link="/contato">Contato</a></li></ul></div></nav></header>
O <main> guarda as três telas. Só uma fica visível por vez — o roteador cuida disso com o atributo hidden:
HTML
<!-- cafe-cerrado/index.html — as três telas --><mainid="conteudo"class="container my-4"><sectionclass="tela"data-rota="/inicio"><h2tabindex="-1">Café Cerrado</h2><p>Cafeteria de Sinop/MT. Grãos do cerrado, torra da semana, sem pressa.</p><h3>O que dizem nossos clientes</h3><pid="status-depoimentos"class="status"role="status"aria-live="polite"></p><ulid="depoimentos"class="list-unstyled"aria-busy="false"></ul></section><sectionclass="tela"data-rota="/cardapio"hidden><h2tabindex="-1">Cardápio</h2><divclass="filtros row g-2 align-items-end mb-3"><divclass="col-12 col-md-5"><labelclass="form-label"for="busca">Buscar</label><inputclass="form-control"type="search"id="busca"placeholder="café, açaí, pão…"></div><divclass="col-6 col-md-4"><labelclass="form-label"for="filtro-categoria">Categoria</label><selectclass="form-select"id="filtro-categoria"><optionvalue="">Todas as categorias</option></select></div><divclass="col-6 col-md-3"><labelclass="form-label"for="ordenacao">Ordenar por</label><selectclass="form-select"id="ordenacao"><optionvalue="nome">Nome (A–Z)</option><optionvalue="preco-asc">Menor preço</option><optionvalue="preco-desc">Maior preço</option></select></div></div><pid="status-cardapio"class="status"role="status"aria-live="polite"></p><buttontype="button"id="tentar-de-novo"class="btn btn-outline-dark mb-3"hidden>Tentar de novo</button><divid="cards"class="row g-3"aria-busy="false"></div><pid="resumo"class="resumo"></p></section><sectionclass="tela"data-rota="/contato"hidden><h2tabindex="-1">Fale com a gente</h2><formid="form-contato"class="row g-3"novalidate><divclass="col-12 col-md-6"><labelclass="form-label"for="nome">Nome</label><inputclass="form-control"type="text"id="nome"name="nome"requiredminlength="3"></div><divclass="col-12 col-md-6"><labelclass="form-label"for="email">E-mail</label><inputclass="form-control"type="email"id="email"name="email"required></div><divclass="col-6 col-md-3"><labelclass="form-label"for="cep">CEP</label><inputclass="form-control"type="text"id="cep"name="cep"inputmode="numeric"maxlength="9"></div><divclass="col-6 col-md-4"><labelclass="form-label"for="cidade">Cidade/UF</label><inputclass="form-control"type="text"id="cidade"name="cidade"readonly></div><divclass="col-12"><labelclass="form-label"for="mensagem">Mensagem</label><textareaclass="form-control"id="mensagem"name="mensagem"rows="4"requiredminlength="10"></textarea></div><divclass="col-12"><buttonclass="btn btn-dark"type="submit"id="enviar">Enviar mensagem</button></div></form><pid="status-contato"class="status"role="status"aria-live="polite"></p></section></main>
E, antes do </body>, uma única linha de script — sem defer, porque módulos já são adiados:
HTML
<!-- cafe-cerrado/index.html — antes do </body> --><scripttype="module"src="js/app.js"></script>
// cafe-cerrado/js/roteador.js// Roteador por hash: lê a URL, mostra a tela certa e avisa quem quiser saber.constROTA_PADRAO="/inicio";constacoes=newMap();/** Registra o que deve acontecer ao entrar em uma rota (ex.: carregar dados). */exportfunctionregistrarRota(caminho,aoEntrar){acoes.set(caminho,aoEntrar);}functionlerRota(){constbruta=location.hash.slice(1);returnacoes.has(bruta)?bruta:ROTA_PADRAO;}functionmostrarTela(rota){document.querySelectorAll("[data-rota]").forEach((tela)=>{tela.hidden=tela.dataset.rota!==rota;});document.querySelectorAll("[data-link]").forEach((link)=>{constativo=link.dataset.link===rota;link.classList.toggle("active",ativo);if(ativo){link.setAttribute("aria-current","page");}else{link.removeAttribute("aria-current");}});}functionajustarTituloEFoco(rota,moverFoco){consttela=document.querySelector(`[data-rota="${rota}"]`);consttitulo=tela.querySelector("h2");document.title=`${titulo.textContent} — Café Cerrado`;if(moverFoco){titulo.focus();}}asyncfunctionnavegar(moverFoco){constrota=lerRota();mostrarTela(rota);ajustarTituloEFoco(rota,moverFoco);constaoEntrar=acoes.get(rota);if(aoEntrar){awaitaoEntrar();}}exportfunctioniniciarRoteador(){window.addEventListener("hashchange",()=>{navegar(true).catch((erro)=>console.error("Falha ao navegar:",erro));});// Primeira carga: respeita a URL que veio no link, mas não rouba o foco de ninguém.navegar(false).catch((erro)=>console.error("Falha ao navegar:",erro));}
Repare no .catch das duas chamadas de navegar. Sem ele, uma falha dentro da função de entrada da rota viraria um Uncaught (in promise) silencioso — exatamente o que a Aula 09 ensinou a evitar.
// cafe-cerrado/js/app.js// Café Cerrado — SPA com dados vindos de arquivo JSON e de APIs públicas.import{buscarProdutos,buscarCategorias,buscarDepoimentos,enviarMensagem,buscarEnderecoPorCep,}from"./api.js";import{registrarRota,iniciarRoteador}from"./roteador.js";constestado={produtos:[],categorias:[],termo:"",categoria:"",ordem:"nome",cardapioCarregado:false,depoimentosCarregados:false,};constelementos={cards:document.querySelector("#cards"),statusCardapio:document.querySelector("#status-cardapio"),resumo:document.querySelector("#resumo"),busca:document.querySelector("#busca"),filtroCategoria:document.querySelector("#filtro-categoria"),ordenacao:document.querySelector("#ordenacao"),tentarDeNovo:document.querySelector("#tentar-de-novo"),depoimentos:document.querySelector("#depoimentos"),statusDepoimentos:document.querySelector("#status-depoimentos"),formulario:document.querySelector("#form-contato"),statusContato:document.querySelector("#status-contato"),botaoEnviar:document.querySelector("#enviar"),cep:document.querySelector("#cep"),cidade:document.querySelector("#cidade"),};functionformatarPreco(valor){returnvalor.toLocaleString("pt-BR",{style:"currency",currency:"BRL"});}functionmostrarStatus(elemento,mensagem,modificador){elemento.textContent=mensagem;elemento.className=modificador?`status ${modificador}`:"status";}functionrenderizarEsqueleto(quantidade){elementos.cards.innerHTML="";elementos.cards.setAttribute("aria-busy","true");for(leti=0;i<quantidade;i+=1){constcoluna=document.createElement("div");coluna.className="col-12 col-sm-6 col-lg-4";constcaixa=document.createElement("div");caixa.className="esqueleto";caixa.setAttribute("aria-hidden","true");coluna.appendChild(caixa);elementos.cards.appendChild(coluna);}}functioncriarCard(produto){constcoluna=document.createElement("div");coluna.className="col-12 col-sm-6 col-lg-4";constcard=document.createElement("article");card.className="card h-100";constimagem=document.createElement("img");imagem.src=produto.imagem;imagem.alt=`Foto de ${produto.nome}`;imagem.className="card-img-top";imagem.loading="lazy";constcorpo=document.createElement("div");corpo.className="card-body";consttitulo=document.createElement("h3");titulo.className="card-title h5";titulo.textContent=produto.nome;constdescricao=document.createElement("p");descricao.className="card-text";descricao.textContent=produto.descricao;constpreco=document.createElement("p");preco.className="preco fw-bold";preco.textContent=formatarPreco(produto.preco);corpo.append(titulo,descricao,preco);card.append(imagem,corpo);coluna.appendChild(card);returncoluna;}functionrenderizarCards(lista){elementos.cards.innerHTML="";lista.forEach((produto)=>elementos.cards.appendChild(criarCard(produto)));elementos.cards.setAttribute("aria-busy","false");}functionatualizarResumo(lista){if(lista.length===0){elementos.resumo.textContent="";return;}consttotal=lista.reduce((acumulado,produto)=>acumulado+produto.preco,0);elementos.resumo.textContent=`${lista.length} item(ns) — soma dos preços: ${formatarPreco(total)}`;}functionpreencherFiltroCategorias(categorias){elementos.filtroCategoria.length=1;// mantém só a opção "Todas as categorias"categorias.forEach((categoria)=>{constopcao=document.createElement("option");opcao.value=categoria.id;opcao.textContent=categoria.nome;elementos.filtroCategoria.appendChild(opcao);});}functionordenar(lista,criterio){constcopia=[...lista];if(criterio==="preco-asc"){returncopia.sort((a,b)=>a.preco-b.preco);}if(criterio==="preco-desc"){returncopia.sort((a,b)=>b.preco-a.preco);}returncopia.sort((a,b)=>a.nome.localeCompare(b.nome,"pt-BR"));}functionaplicarFiltros(){consttermo=estado.termo.trim().toLowerCase();constfiltrados=estado.produtos.filter((produto)=>produto.nome.toLowerCase().includes(termo)).filter((produto)=>estado.categoria===""||produto.categoria===estado.categoria);constlista=ordenar(filtrados,estado.ordem);renderizarCards(lista);atualizarResumo(lista);if(lista.length===0){mostrarStatus(elementos.statusCardapio,"Nenhum item combina com esses filtros.","status--vazio");}else{mostrarStatus(elementos.statusCardapio,"");}}asyncfunctioncarregarCardapio(){if(estado.cardapioCarregado){return;// já buscamos uma vez: não repete a requisição}elementos.tentarDeNovo.hidden=true;mostrarStatus(elementos.statusCardapio,"Carregando o cardápio…");renderizarEsqueleto(6);try{const[produtos,categorias]=awaitPromise.all([buscarProdutos(),buscarCategorias()]);estado.produtos=produtos;estado.categorias=categorias;estado.cardapioCarregado=true;preencherFiltroCategorias(categorias);aplicarFiltros();}catch(erro){console.error(erro);elementos.cards.innerHTML="";elementos.cards.setAttribute("aria-busy","false");elementos.resumo.textContent="";mostrarStatus(elementos.statusCardapio,"Não foi possível carregar o cardápio.","status--erro");elementos.tentarDeNovo.hidden=false;elementos.tentarDeNovo.focus();}}asyncfunctioncarregarDepoimentos(){if(estado.depoimentosCarregados){return;}mostrarStatus(elementos.statusDepoimentos,"Carregando depoimentos…");elementos.depoimentos.setAttribute("aria-busy","true");try{constcomentarios=awaitbuscarDepoimentos();elementos.depoimentos.innerHTML="";comentarios.forEach((comentario)=>{constitem=document.createElement("li");item.className="depoimento";consttexto=document.createElement("p");texto.textContent=`“${comentario.body}”`;constautor=document.createElement("p");autor.className="autor";autor.textContent=comentario.name;item.append(texto,autor);elementos.depoimentos.appendChild(item);});estado.depoimentosCarregados=true;mostrarStatus(elementos.statusDepoimentos,`${comentarios.length} depoimentos carregados.`);}catch(erro){console.error(erro);mostrarStatus(elementos.statusDepoimentos,"Não foi possível carregar os depoimentos.","status--erro");}finally{elementos.depoimentos.setAttribute("aria-busy","false");}}asyncfunctionpreencherCidadePeloCep(){constvalor=elementos.cep.value.trim();if(valor===""){elementos.cidade.value="";return;}try{constendereco=awaitbuscarEnderecoPorCep(valor);elementos.cidade.value=`${endereco.localidade}/${endereco.uf}`;mostrarStatus(elementos.statusContato,"");}catch(erro){elementos.cidade.value="";mostrarStatus(elementos.statusContato,erro.message,"status--erro");}}asyncfunctionenviarFormulario(evento){evento.preventDefault();if(!elementos.formulario.checkValidity()){elementos.formulario.reportValidity();mostrarStatus(elementos.statusContato,"Confira os campos destacados.","status--erro");return;}constdados=newFormData(elementos.formulario);constmensagem={title:`Contato de ${dados.get("nome")}`,body:dados.get("mensagem"),email:dados.get("email"),userId:1,};elementos.botaoEnviar.disabled=true;mostrarStatus(elementos.statusContato,"Enviando…");try{constcriado=awaitenviarMensagem(mensagem);mostrarStatus(elementos.statusContato,`Mensagem enviada! Protocolo ${criado.id}.`);elementos.formulario.reset();elementos.cidade.value="";}catch(erro){console.error(erro);mostrarStatus(elementos.statusContato,"Não foi possível enviar. Tente novamente.","status--erro");}finally{elementos.botaoEnviar.disabled=false;}}functionligarEventos(){elementos.busca.addEventListener("input",(evento)=>{estado.termo=evento.target.value;aplicarFiltros();});elementos.filtroCategoria.addEventListener("change",(evento)=>{estado.categoria=evento.target.value;aplicarFiltros();});elementos.ordenacao.addEventListener("change",(evento)=>{estado.ordem=evento.target.value;aplicarFiltros();});elementos.tentarDeNovo.addEventListener("click",()=>{carregarCardapio().catch((erro)=>console.error(erro));});elementos.cep.addEventListener("change",()=>{preencherCidadePeloCep().catch((erro)=>console.error(erro));});elementos.formulario.addEventListener("submit",(evento)=>{enviarFormulario(evento).catch((erro)=>console.error(erro));});}registrarRota("/inicio",carregarDepoimentos);registrarRota("/cardapio",carregarCardapio);registrarRota("/contato",async()=>{});ligarEventos();iniciarRoteador();
Abra index.html pelo Live Server. A URL deve virar http://127.0.0.1:5500/index.html e, logo em seguida, mostrar a tela de início com os depoimentos carregando.
Clique em Cardápio. A URL vira http://127.0.0.1:5500/index.html#/cardapio e a página não recarrega — confira que o indicador de carregamento da aba do navegador não pisca.
Aperte F5 nessa URL. Você continua no cardápio: o link direto funciona.
Clique em Contato, depois no botão voltar do navegador. Você volta ao cardápio, e o menu marca o link certo.
Na aba Network, filtre por Fetch/XHR e navegue entre as telas. Você deve ver produtos.json, categorias.json e comments?postId=1&_limit=4 — cada um uma única vez, graças aos sinalizadores cardapioCarregado e depoimentosCarregados.
No formulário, digite o CEP 78550000 e saia do campo. O campo Cidade/UF deve preencher com Sinop/MT. Agora digite 00000000: o status mostra "CEP não encontrado."
Preencha nome, e-mail e mensagem e envie. Deve aparecer "Mensagem enviada! Protocolo 101." — a JSONPlaceholder sempre devolve o id 101, porque não grava nada de verdade.
Em Network, clique na requisição posts e veja a aba Payload: lá está o JSON que o seu JSON.stringify produziu. Na aba Response, o que o servidor devolveu.
Renomeie data/produtos.json para data/produtos-x.json e recarregue no cardápio. Deve aparecer a mensagem de erro e o botão "Tentar de novo" — e no console, Erro HTTP 404 (Not Found) em data/produtos.json. Volte o nome depois.
Resultado esperado: um único HTML, três telas navegáveis com histórico funcionando, dados vindos de arquivo e de duas APIs reais, formulário enviando por POST com feedback, e nenhum erro não tratado no console.
A Promise do fetch rejeitou? Justifique em uma linha.
A3. Qual a diferença entre JSON.parse(texto) e resposta.json()? As duas devolvem a mesma coisa? Responda citando o tipo de retorno de cada uma.
A4. Complete o objeto de opções para que o fetch abaixo envie um novo produto como JSON:
JavaScript
constnovo={nome:"Chá de hibisco",preco:6.5};constresposta=awaitfetch("https://jsonplaceholder.typicode.com/posts",{method:"POST",});
A5. A URL do navegador é http://127.0.0.1:5500/index.html#/contato. Qual o valor de location.hash? E o de location.hash.slice(1)? Se o usuário apagar o #/contato da barra de endereços e apertar Enter, qual rota o roteador escolhe e por quê?
A6. Em carregarCardapio() existe a guarda if (estado.cardapioCarregado) return;. Descreva o que passa a acontecer na aba Network se alguém apagar essa linha e o usuário alternar cinco vezes entre Início e Cardápio.
B1. Acrescente ao js/api.js a função buscarProdutoPorId(id), que busca data/produtos.json e devolve apenas o produto com aquele id, lançando Error com mensagem clara se não existir. Teste no console do navegador com um id válido e um inválido.
Resultado esperado: buscarProdutoPorId(3) devolve o objeto do Cappuccino Sinop; buscarProdutoPorId(99) cai no catch com a mensagem "Produto 99 não encontrado", e o console não mostra Uncaught (in promise).
Dica
Reaproveite buscarProdutos() em vez de repetir o fetch. Depois é só um find — e um throw quando o resultado for undefined.
B2. Adicione uma quarta tela ao Café Cerrado: #/sobre, com a história da cafeteria e um mapa em texto do endereço. Registre a rota, acrescente o link no menu e confirme que voltar/avançar continuam funcionando.
Resultado esperado: quatro links no menu, quatro seções com data-rota, document.title mudando em cada uma e aria-current="page" sempre no link correto.
Dica
A rota só aparece se você registrá-la: registrarRota("/sobre", async () => {}). Sem isso, lerRota() não a reconhece e cai na rota padrão.
B3. Troque a fonte dos depoimentos: em vez de comments, use https://jsonplaceholder.typicode.com/users e monte uma lista de "parceiros" com nome, cidade (address.city) e site (website) de cada usuário.
Resultado esperado: dez itens na lista, cada um com nome, cidade e site; o site é um link clicável que abre em nova aba com rel="noopener".
Dica
Inspecione um objeto de users no console antes de escrever o código: console.log(usuarios[0]). A cidade está aninhada em address, então o acesso é usuario.address.city.
B4. Trate a falta de internet. Antes de qualquer fetch, verifique navigator.onLine; se estiver false, mostre "Você está sem conexão" em vez de deixar o fetch falhar. Escute também os eventos online e offline do window para atualizar a mensagem sozinho.
Resultado esperado: com o modo avião ligado (ou o throttling "Offline" da aba Network), a mensagem aparece imediatamente; ao voltar a conexão, a mensagem some e o cardápio carrega sem recarregar a página.
Dica
navigator.onLine é confiável para dizer "não há rede nenhuma", mas não garante que a internet funcione — por isso ele complementa o try/catch, não o substitui.
B5. Persista os filtros na URL: ao digitar na busca ou trocar a categoria, atualize o hash para #/cardapio?busca=cafe&categoria=doces; ao carregar a página com esse hash, aplique os filtros automaticamente.
Resultado esperado: copiar a URL e abrir em outra aba reproduz exatamente a mesma tela filtrada.
Dica
Separe o hash em duas partes com split("?"): a primeira é a rota, a segunda alimenta new URLSearchParams(...). Para escrever sem criar entrada nova no histórico a cada tecla, use history.replaceState(null, "", novoHash).
C1. Implemente a rota de detalhe do produto: #/produto/3 mostra uma tela com a foto grande, o nome, a descrição, o preço e um botão "Voltar ao cardápio". Cada card do cardápio vira um link para a rota do seu produto. Se o id não existir, a tela mostra "Produto não encontrado" com link para o cardápio — sem quebrar a aplicação.
Resultado esperado: clicar em um card leva ao detalhe sem recarga; o botão voltar do navegador retorna ao cardápio com os filtros como estavam; abrir #/produto/99 direto na barra de endereços mostra a mensagem de não encontrado; abrir #/produto/3 direto funciona.
Dica
O roteador atual compara a rota inteira com as chaves do Map. Para rotas com parâmetro, quebre o caminho: const [, secao, parametro] = rota.split("/") transforma "/produto/3" em secao = "produto" e parametro = "3". Registre a ação pela seção e passe o parâmetro para a função de entrada. Lembre-se de que o parâmetro chega sempre como string: compare com Number(parametro) ou converta o id antes.
Um colega "simplificou" o pegarJson do projeto e agora a tela do cardápio mostra "Carregando…" para sempre quando o arquivo não existe — em vez da mensagem de erro. Pior: o console mostra um SyntaxError falando de <!DOCTYPE, que não parece ter nada a ver com o problema. Este é o trecho alterado:
JavaScript
// cafe-cerrado/js/api.js — versão com o bug plantadoexportasyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);returnresposta.json();}
Reproduza o erro renomeando data/produtos.json, leia a mensagem inteira, explique a ligação entre o <!DOCTYPE e o arquivo que sumiu — e conserte.
Critérios de pronto
Um comentário de três linhas no api.js explica por que o fetchnão rejeitou diante de um 404 e de onde veio o <!DOCTYPE da mensagem.
Com o arquivo renomeado, a tela mostra a mensagem de erro amigável e o botão "Tentar de novo" em vez de ficar carregando.
A mensagem lançada inclui o status numérico e a URL que falhou, para facilitar a depuração.
Um print da aba Network mostrando a linha vermelha do 404 está no README.md, ao lado de uma frase explicando o que a coluna "Status" informa.
Pistas
Releia a seção 3.2: a Promise do fetch só rejeita quando a requisição não acontece.
Peça resposta.text() em vez de resposta.json() e imprima o resultado: você vai ver a página de erro do servidor, com na primeira linha.
O statusText ("Not Found") deixa a mensagem mais legível do que só o número.
Depois de consertar, teste também o caminho feliz — é fácil deixar um throw disparando quando não deveria.
Cada vez que alguém entra na tela do cardápio, você busca produtos.json de novo. Hoje isso é rápido porque o arquivo é seu e pequeno. Amanhã será uma API na internet, e a mesma resposta viajará dezenas de vezes por sessão. Construa um cache com validade: a primeira chamada busca na rede, as seguintes respondem da memória — até o dado envelhecer.
Critérios de pronto
Uma função comCache(chave, buscar, validadeEmSegundos) guarda o resultado e o instante da busca, e só chama buscar() de novo depois que a validade expirar.
O cardápio e as categorias passam a usá-la com validade de 60 segundos; os depoimentos, de 5 minutos.
Uma medição no README.md compara, com performance.now(), o tempo da primeira chamada e o da segunda (dentro da validade), com a diferença em milissegundos.
Um teste documentado prova que, passada a validade, a requisição volta a aparecer na aba Network.
O cache sobrevive a um F5 — e o README explica em duas linhas qual armazenamento você escolheu para isso e por quê.
Pistas
Um Map na memória basta para a primeira versão: a chave é a URL, o valor é { dados, salvoEm }.
Validade expirada é Date.now() - salvoEm > validadeEmSegundos * 1000.
Para sobreviver ao F5, sessionStorage guarda strings — o que significa JSON.stringify ao salvar e JSON.parse ao ler, exatamente como na seção 2.3.
Cuidado com duas chamadas simultâneas antes de a primeira responder: guardar a Promise no cache, e não só o resultado, resolve isso de graça.
O roteador da aula é um Map de caminhos fixos. Frameworks reais aceitam parâmetros (/produto/:id), rota coringa para 404, e proteção de rota (entrar em /admin só se estiver logado). Escreva o seu, genérico o suficiente para ser copiado para qualquer projeto — e prove que ele funciona.
Critérios de pronto
registrarRota("/produto/:id", aoEntrar) funciona: o aoEntrar recebe um objeto de parâmetros ({ id: "3" }) extraído da URL.
Existe uma rota coringa "*" que responde por qualquer caminho não registrado, exibindo uma tela 404 própria — e a URL não é alterada, para o usuário poder corrigi-la.
Um antesDeEntrar(rota, parametros) opcional pode bloquear a navegação devolvendo false ou redirecionar devolvendo outra rota; demonstre com uma tela fictícia de administração.
O roteador continua atualizando document.title, aria-current e o foco no título — e nada disso vive dentro do app.js.
Um arquivo docs/roteador.md documenta a API pública em uma tabela e traz um exemplo mínimo de uso em 15 linhas.
Pistas
Guarde cada rota registrada como um padrão em partes: "/produto/:id".split("/") dá ["", "produto", ":id"]. Comparar parte a parte com a URL atual, tratando as que começam com : como curinga, resolve a extração de parâmetros.
Ordem importa: teste as rotas exatas antes das paramétricas, e a coringa por último.
Para o antesDeEntrar assíncrono, await o resultado antes de decidir; retornar uma string significa redirecionar (location.hash = novaRota).
Cuidado com o laço infinito: se antesDeEntrar redirecionar para uma rota que também é bloqueada, a navegação nunca termina. Um contador de redirecionamentos por navegação evita o problema.
🔥
🔥 Boss — Pedido do Café Cerrado, do cardápio à confirmação¶
spafetchjsonprojeto
Este é o Boss da Unidade 2: um fluxo completo de pedido, em quatro telas, usando tudo o que você aprendeu desde a Aula 07 — DOM e eventos, map/filter/reduce, assíncrono com estados, fetch de arquivo e de API, POST, formulário validado e roteamento SPA. É também o melhor treino possível para o Marco 2: quem faz o Boss no projeto autoral já tem o marco praticamente pronto.
O fluxo: a pessoa navega o cardápio, adiciona itens ao carrinho, revisa o pedido, preenche a entrega com busca de CEP e confirma. O pedido é enviado por POST e a tela final mostra o número do protocolo.
Critérios de pronto
Quatro rotas funcionando com histórico e link direto: #/cardapio, #/carrinho, #/entrega e #/confirmacao.
O carrinho é um array no estado, com quantidade por item; adicionar o mesmo produto duas vezes soma a quantidade em vez de duplicar a linha.
O total é calculado com reduce e reexibido a cada mudança; o contador de itens aparece no menu, em todas as telas.
O carrinho sobrevive a um F5 (persistido com JSON.stringify / JSON.parse), e um botão "Esvaziar carrinho" pede confirmação antes de apagar.
Entrar em #/entrega com o carrinho vazio redireciona para #/cardapio com uma mensagem explicando o motivo.
A tela de entrega valida nome, telefone e endereço, e preenche cidade e rua pelo CEP (ViaCEP), tratando CEP inexistente.
Confirmar envia o pedido inteiro por POST para a JSONPlaceholder, com o botão desabilitado durante o envio, e mostra o protocolo devolvido na tela de confirmação.
Todas as quatro telas tratam carregando, erro e vazio, com role="status", aria-live e foco movido para o título a cada troca de tela.
O README.md traz um roteiro de teste numerado de 10 passos que qualquer pessoa consegue seguir para verificar o fluxo inteiro.
Pistas
Um único objeto estado com produtos, carrinho e entrega é mais fácil de depurar do que três variáveis soltas — e é exatamente a ideia que o Pinia formaliza no Nível 3.
Para somar quantidades, procure o item antes de inserir: const existente = carrinho.find((i) => i.id === produto.id).
Salve o carrinho a cada alteração em uma função só (salvarCarrinho()), chamada no fim de toda operação — assim você nunca esquece de persistir.
O redirecionamento com carrinho vazio cabe na função de entrada da rota: se carrinho.length === 0, location.hash = "#/cardapio" e mostre a mensagem.
Para o protocolo, a JSONPlaceholder devolve sempre id: 101 no POST. Guarde-o no estado antes de navegar para a confirmação, senão a tela abre sem dado nenhum.
Deixe o POST por último. Faça as telas e o carrinho funcionarem primeiro; envio é a parte mais curta do desafio.
Access to fetch at 'file:///…/data/produtos.json' from origin 'null' has been blocked by CORS policy
Página aberta com duplo clique, não por HTTP
Abrir pelo Live Server (http://127.0.0.1:5500)
Uncaught SyntaxError: Cannot use import statement outside a module
Faltou type="module" na tag <script>
Trocar <script src="js/app.js" defer> por <script type="module" src="js/app.js">
GET http://127.0.0.1:5500/js/api.js net::ERR_ABORTED 404
import sem ./ ou sem a extensão .js
Usar sempre o caminho relativo completo: from "./api.js"
SyntaxError: Unexpected token '<', "<!DOCTYPE "… is not valid JSON
.json() chamado sobre uma página de erro HTML
Testar resposta.ok antes de ler o corpo
SyntaxError: Expected double-quoted property name in JSON at position 42
Vírgula sobrando ou aspas simples no .json
Corrigir a sintaxe; validar o arquivo abrindo-o no navegador
TypeError: Failed to fetch
Sem internet, URL inválida ou CORS bloqueado pelo servidor de destino
Ler o console inteiro: a mensagem de CORS aparece logo abaixo
Access to fetch at 'https://exemplo.com/api' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
O servidor de destino não libera outras origens
Usar uma API que permita CORS; a definitiva é pedir pelo back-end (Unidade 3)
A página recarrega inteira ao enviar o formulário
Faltou evento.preventDefault() no submit
Chamar preventDefault() na primeira linha do handler
O menu troca a tela, mas voltar no navegador não funciona
A troca foi feita só por classe CSS, sem mexer no hash
Navegar sempre por href="#a10-/rota"; deixar o hashchange decidir
Uncaught (in promise) TypeError: Cannot read properties of null (reading 'addEventListener')
O elemento não existe na tela atual (querySelector devolveu null)
Conferir o id no HTML; em SPA, o elemento precisa existir no index.html
O POST chega ao servidor sem corpo
Faltou JSON.stringify no body ou o cabeçalho Content-Type
Enviar body: JSON.stringify(objeto) com headers: { "Content-Type": "application/json" }
Mova os dados do seu domínio para data/<recurso>.json e substitua a fonte simulada da Aula 09 por um fetch real, mantendo o padrão carregando/sucesso/erro/vazio.
Reorganize o JavaScript em módulos ES: js/api.js (todo o acesso a dados), js/roteador.js (a navegação) e js/app.js (a aplicação), com <script type="module">.
Implemente a navegação SPA por hash com no mínimo três telas, com histórico funcionando, document.title atualizado, aria-current="page" no menu e foco movido para o título a cada troca.
Consuma ao menos uma API pública de verdade (JSONPlaceholder, ViaCEP ou outra à sua escolha que permita CORS) em alguma tela.
Faça o formulário enviar por POST sem recarregar a página, com o botão desabilitado durante o envio e mensagem de sucesso ou de erro.
Confira o console: zero erros não tratados, zero Uncaught (in promise).
Atualize o README.md com o que o projeto faz, como rodar (Live Server) e quais APIs ele consome.
Critério de pronto: o projeto autoral roda inteiro a partir de um único index.html, os dados vêm de arquivos JSON e de pelo menos uma API, e a navegação entre as telas não recarrega o navegador em momento nenhum.
Guarde no seu repositório: commit + push. Esta atividade é também a base do Marco 2 — veja as instruções abaixo.
Leitura dirigida (se você tem acesso a uma biblioteca virtual pela sua instituição): QUEIRÓS & PORTELA, capítulos sobre AJAX, JSON e comunicação com o servidor, e a introdução ao back-end com Node.js — a Unidade 3 começa na próxima aula.
Escopo. A evolução do seu projeto autoral (não o Café Cerrado, que é o projeto de exemplo construído ao longo das aulas) para uma aplicação client-side dinâmica: validação de formulários, DOM e eventos, programação assíncrona e SPA com AJAX/JSON. É o resultado das Aulas 07 a 10 sobre a base do Marco 1.
Requisitos:
Validação de formulário com pelo menos quatro campos: validação nativa do HTML (required, type, minlength, pattern) somada a mensagens em JavaScript por campo, anunciadas em região com aria-live.
DOM e eventos: uma lista de itens do seu domínio renderizada dinamicamente a partir de dados (nada de cards escritos à mão no HTML), com busca por texto, um filtro e uma ordenação funcionando juntos.
Operações em vetores: map, filter e reduce usados de verdade — inclusive um resumo calculado (total, média, contagem) que se atualiza a cada filtragem.
Programação assíncrona: carregamento de dados com async/await e tratamento explícito dos quatro estados — carregando, sucesso, erro e vazio — com caminho de recuperação no erro.
AJAX e JSON: dados vindos de pelo menos um arquivo .json do próprio repositório e de uma API pública real, sempre com verificação de resposta.ok.
Envio por POST de um formulário, com Content-Type: application/json, botão desabilitado durante o envio e feedback de sucesso e de falha, sem recarregar a página.
SPA com no mínimo três telas em um único HTML, roteamento por hash, histórico do navegador funcionando, link direto para cada tela e aria-current="page" no menu.
Acessibilidade mantida da Unidade 1: skip link, foco visível, contraste, alt nas imagens, foco movido para o título a cada troca de tela.
Repositório organizado: data/, js/ em módulos ES, css/, README.md explicando o projeto, como rodar e quais APIs consome; commits com mensagens descritivas ao longo do desenvolvimento.
Checklist de qualidade
Validação de formulário completa: nativa + JS, com mensagens acessíveis por campo.
DOM e eventos: render dinâmico, busca, filtro e ordenação funcionando juntos, não isolados.
Programação assíncrona: os quatro estados (carregando, sucesso, erro, vazio) tratados de verdade, com caminho de recuperação no erro — não só o caminho feliz.
AJAX e JSON: fetch de arquivo e de API, sempre checando resposta.ok, com POST funcionando de ponta a ponta.
SPA: roteamento por hash, histórico do navegador, link direto para cada tela e acessibilidade mantida na troca de tela.
Organização do repositório: README.md completo e histórico de commits que mostra o projeto evoluindo, não um único commit final.
Um projeto "pela metade" costuma ter um dos itens acima funcionando só no caminho feliz (sem tratar erro, sem lidar com lista vazia) — teste sempre os casos de borda antes de considerar algo pronto.
Sobre IA: use como apoio — explicar um erro, sugerir uma correção pontual, revisar um trecho —, não como atalho para gerar o projeto sem entender o que ele faz. O teste real: se alguém apontar para um await ou um filter do seu código e perguntar por que está ali, você precisa saber responder.
Abra a aba Network do DevTools ao alternar entre telas: nenhuma requisição repetida sem necessidade.
Force os quatro estados manualmente (desligue a rede, aponte para uma URL inexistente, filtre até a lista ficar vazia) e confirme que cada um tem uma tela própria, não uma tela em branco.
Navegue pelo histórico do navegador (voltar/avançar) dentro da SPA e confirme que cada tela tem link direto funcional.
Rode o teste de teclado e confira que o foco muda para o título a cada troca de tela.
Abra o repositório: README.md explica o projeto e como rodar, e o histórico de commits mostra evolução real ao longo das quatro aulas.
json.org — a especificação do JSON em português, em uma página com os diagramas de sintaxe.
QUEIRÓS, R.; PORTELA, F. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — AJAX, JSON e comunicação com o servidor.
LOUDON, K. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — arquitetura de aplicações ricas no cliente.
ALVES, W. P. Projetos de Sistemas Web. Érica, 2015 — integração entre camadas de uma aplicação web.
PUREWAL, S. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — AJAX e consumo de APIs.
Na próxima aula começa a Unidade 3, e o JavaScript atravessa a rede: com Node.js e Express você deixa de consumir a API dos outros e passa a construir a sua — num repositório novo, o cafe-cerrado-api, que a Aula 11 cria no primeiro passo. Aquele data/produtos.json que hoje é servido por acaso pelo Live Server vira o banco de dados de um servidor que você mesmo escreveu — e o fetch do front-end continuará exatamente igual, apontando para /api/produtos em vez de um arquivo.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 11 — Introdução ao Node.js e Express
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 10 o Café Cerrado virou uma SPA: um único index.html, navegação por hash, fetch buscando data/produtos.json e uma API pública para treinar POST. Todo esse código rodou dentro do navegador de quem visita o site. Hoje começa a Unidade 3, e você troca de lado: em vez de consumir a API dos outros, você escreve o programa que fica esperando requisições e devolvendo respostas. É o mesmo JavaScript — const, arrow functions, async/await, objetos e arrays funcionam igualzinho — em outro endereço.
Na aula passada você fechou o cliente: fetch real, JSON.parse implícito com resposta.json(), estados de carregando/erro e navegação SPA por hash. A Unidade 2 acabou ali. Hoje a máquina que responde àqueles fetch deixa de ser da JSONPlaceholder e passa a ser sua, escrita em JavaScript, rodando na sua máquina na porta 3000.
Antes de começar, confirme:
[ ] Node.js 22 LTS instalado (node --version deve imprimir algo como v22.11.0). Instalação feita na Aula 01.
[ ] npm disponível (npm --version, versão 10 ou superior — vem junto com o Node).
[ ] Git configurado com seu nome e e-mail, e conta no GitHub ativa.
[ ] O repositório cafe-cerrado (Unidades 1 e 2) atualizado, com index.html, css/, js/app.js, img/ e data/produtos.json.
[ ] VS Code com a extensão REST Client instalada (procure por humao.rest-client). Vamos usá-la a partir da próxima aula.
[ ] Terminal integrado do VS Code funcionando (Ctrl+').
Até 2009, JavaScript era uma linguagem de navegador e ponto. O servidor era PHP, Java, Python, Ruby, C#. Nesse ano, Ryan Dahl pegou o V8 — o motor JavaScript que o Google tinha escrito para o Chrome, absurdamente rápido — e o colocou para rodar sozinho, fora do navegador, acoplado a uma biblioteca de entrada e saída não bloqueante.
O detalhe importante é esse "não bloqueante". Os servidores da época costumavam dedicar uma thread do sistema operacional a cada conexão: mil usuários simultâneos, mil threads, e a memória acabava. O Node adotou o modelo que você já conhece do navegador — uma thread, um event loop, callbacks — e resolveu o problema por outro caminho: enquanto uma requisição espera o disco ou o banco de dados responder, a thread não fica parada, ela atende as outras.
Aquele diagrama da Aula 09, com a pilha, a fila de tarefas e o event loop, é o mesmo aqui. A diferença é o que fica pendurado esperando: lá era um fetch ou um setTimeout; aqui é uma leitura de arquivo, uma consulta ao banco, uma conexão de rede que chegou.
🧠 Você sabia?
A demonstração que apresentou o Node.js ao mundo, na JSConf EU de 2009, era sobre uma barra de progresso. Ryan Dahl mostrou que, para exibir corretamente o progresso do upload de um arquivo, os servidores da época precisavam de gambiarras — porque a requisição só era entregue ao seu código depois de completamente recebida. Ele escreveu, ao vivo, um servidor de poucas linhas que reagia a cada pedaço do arquivo conforme ele chegava. A plateia aplaudiu de pé. Quinze anos depois, Node é a base do Netflix, do PayPal, do LinkedIn e do próprio npm.
Esta é a tabela que evita a maior parte da confusão das próximas semanas:
Existe no navegador
Existe no Node.js
Existe nos dois
document, window, DOM
fs (arquivos), path, http, process
const, let, arrow functions
localStorage, alert
require, module.exports, __dirname
async/await, Promises, fetch
Eventos de clique, addEventListener do DOM
Portas de rede, variáveis de ambiente
JSON, Array, Object, Map
Ou seja: não existe página no servidor. Não há document.querySelector, não há <div>, não há CSS. O servidor recebe texto (uma requisição HTTP) e devolve texto (uma resposta HTTP). Quem transforma esse texto em pixels é o navegador, do outro lado.
1.3 Primeiro contato: o Node como executor de scripts¶
Abra um terminal em uma pasta qualquer e crie um arquivo:
olamundo.js
JavaScript
// Isto é JavaScript comum — só que sem navegador em volta.constcidade="Sinop";constcafes=["coado","cappuccino","expresso"];console.log(`JavaScript rodando no terminal, em ${cidade}!`);console.log(`Temos ${cafes.length} tipos de café:`,cafes.join(", "));// Aqui está a primeira coisa que o navegador nunca deixaria você fazer:console.log("Esta pasta é:",process.cwd());console.log("Versão do Node:",process.version);
Rode:
Terminal
nodeolamundo.js
Saída esperada (o caminho varia na sua máquina):
Texto
JavaScript rodando no terminal, em Sinop!
Temos 3 tipos de café: coado, cappuccino, expresso
Esta pasta é: /home/aluno/projetos
Versão do Node: v22.11.0
O objeto global process representa o processo do sistema operacional em que o seu código está rodando: diretório atual, versão, variáveis de ambiente, argumentos da linha de comando. Ele não existe no navegador porque lá não há processo seu — há uma aba.
Agora tente, no mesmo arquivo, acrescentar console.log(document.title); e rodar de novo:
Texto
ReferenceError: document is not defined
Guarde esse erro. Ele é o sinal número 1 de que você colou código de front-end em um arquivo de back-end (ou o contrário).
🔬 Investigue
Digite só node no terminal, sem nome de arquivo, e aperte Enter. Você entrou no REPL (Read–Eval–Print Loop), um console idêntico ao do DevTools. Experimente: 2 + 2, [1,2,3].map(n => n * 10), typeof window, Object.keys(process.env).length. Compare a última resposta com o que você vê ao rodar printenv | wc -l em outro terminal. Para sair do REPL, .exit ou Ctrl+D. Anote: typeof window devolveu o quê? E no console do Chrome, o que devolveria?
Um projeto Node é uma pasta com um arquivo package.json — a certidão de nascimento do projeto. Ele guarda nome, versão, scripts e, principalmente, a lista de bibliotecas de que o projeto depende.
O -y aceita todas as respostas padrão. O resultado:
package.json
JSON
{"name":"cafe-cerrado-api","version":"1.0.0","main":"index.js","scripts":{"test":"echo \"Error: no test specified\" && exit 1"},"keywords":[],"author":"","license":"ISC"}
2.3 O acento circunflexo e o versionamento semântico¶
"express": "^5.1.0" não quer dizer "exatamente a versão 5.1.0". Os pacotes npm seguem o versionamento semântico (SemVer), em que o número tem três partes:
Parte
Exemplo
Quando aumenta
MAIOR
5.1.0
Mudança que quebra código existente (Express 4 → 5)
MENOR
5.1.0
Recurso novo, compatível com o que já existia
CORREÇÃO
5.1.0
Correção de bug, sem recurso novo
O ^ significa "esta versão ou qualquer atualização compatível": aceita 5.1.1, 5.2.0, 5.9.3 — mas nunca 6.0.0, porque a versão MAIOR pode quebrar tudo. É por isso que este curso usa Express 5 enquanto a maioria dos tutoriais da internet ainda mostra Express 4: são versões MAIORES diferentes, com diferenças reais de sintaxe (voltaremos a isso na seção 6).
O package-lock.json, por outro lado, guarda a versão exata que foi instalada hoje, na sua máquina. Ele existe para que quem clonar o projeto amanhã receba exatamente as mesmas versões, e não uma correção nova que apareceu no meio do caminho. Por isso: package-lock.json vai para o Git, sim.
node_modules/ pode ter dezenas de milhares de arquivos. Não é seu código — é código baixado, reconstruível a qualquer momento. Antes do primeiro commit, crie:
.gitignore
Texto
# dependências instaladas pelo npm — reconstruídas com "npm install"
node_modules/
# arquivos de ambiente com segredos — usaremos a partir da Aula 14
.env
# lixo de sistema operacional e de editor
.DS_Store
Thumbs.db
Quem clonar o repositório roda npm install e o npm reconstrói node_modules/ inteiro a partir do package.json e do package-lock.json.
⚠️ Atenção
Se você já commitou node_modules/ por engano, o .gitignore sozinho não resolve — ele só ignora arquivos ainda não rastreados. Rode git rm -r --cached node_modules e commite a remoção. O histórico continuará pesado, mas o próximo commit já fica limpo.
🔬 Investigue
Com o Express instalado, rode no terminal do projeto: find node_modules -type f | wc -l (Linux/macOS) ou (Get-ChildItem node_modules -Recurse -File).Count (PowerShell). Depois, du -sh node_modules para o tamanho total. Quantos arquivos o npm baixou para instalar um pacote? Agora rode npm ls express e depois npm ls --all | head -40: quem são as dezenas de dependências que vieram junto? Escreva em uma linha por que isso justifica a existência do .gitignore.
No front-end da Unidade 2 você usou módulos ES (import / export com type="module"). No servidor, o Node suporta os dois sistemas, e o padrão histórico — usado pelo Express, pela maior parte dos tutoriais e por este curso na Unidade 3 — é o CommonJS:
exemplos/produtos-de-teste.js
JavaScript
// Um módulo CommonJS: um arquivo que exporta algo.constprodutos=[{id:1,nome:"Espresso do Cerrado",preco:6},{id:2,nome:"Coado da Casa",preco:8.5},];module.exports=produtos;// isto é o que quem der require() vai receber
exemplos/uso.js
JavaScript
// Quem consome: o caminho relativo começa com ./ e a extensão é opcional.constprodutos=require("./produtos-de-teste");console.log("Produtos carregados:",produtos.length);console.log("Mais barato:",produtos.reduce((a,b)=>(a.preco<b.preco?a:b)).nome);
Comparando os dois sistemas:
CommonJS (servidor, este curso)
Módulos ES (front-end, Unidade 2)
const express = require("express")
import express from "express"
module.exports = router
export default router
exports.listar = fn
export function listar() {}
Para usar import/export no servidor, você acrescentaria "type": "module" ao package.json. Não faça isso agora: as aulas 12 a 16 usam require, e misturar os dois estilos no mesmo projeto é uma das fontes de erro mais irritantes do ecossistema Node.
⚠️ Atenção
Se escrever import express from "express" em um projeto CommonJS, o Node responde:
SyntaxError: Cannot use import statement outside a module. A correção é trocar por require — não é adicionar "type": "module" no meio do semestre.
Dá para escrever um servidor web usando só o que vem no Node, sem instalar nada. Vale ver uma vez, para você saber o que o Express está fazendo por baixo:
exemplos/servidor-sem-express.js
JavaScript
consthttp=require("node:http");constprodutos=[{id:1,nome:"Espresso do Cerrado",preco:6},{id:2,nome:"Coado da Casa",preco:8.5},];constservidor=http.createServer((req,res)=>{// Cada requisição cai aqui. Nós é que precisamos decidir tudo, na unha.if(req.method==="GET"&&req.url==="/api/produtos"){res.writeHead(200,{"Content-Type":"application/json; charset=utf-8"});res.end(JSON.stringify(produtos));return;}if(req.method==="GET"&&req.url==="/"){res.writeHead(200,{"Content-Type":"text/html; charset=utf-8"});res.end("<h1>Café Cerrado</h1>");return;}res.writeHead(404,{"Content-Type":"text/plain; charset=utf-8"});res.end("Nao encontrado");});servidor.listen(3000,()=>{console.log("Servidor sem framework em http://localhost:3000");});
Funciona. Mas repare no trabalho manual: comparar req.method e req.url com if, escrever o cabeçalho Content-Type na mão, serializar o JSON na mão, tratar o 404 na mão. Com dez rotas isso vira uma escada de if ilegível. É exatamente esse desconforto que o Express elimina.
constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;app.get("/",(req,res)=>{res.send("<h1>Meu primeiro servidor Express!</h1>");});app.listen(PORTA,()=>{console.log(`Servidor rodando em http://localhost:${PORTA}`);});
Terminal
nodeserver.js
Abra http://localhost:3000 no navegador. Você acabou de atender uma requisição HTTP igual às que estudou na Aula 01 — só que do lado de dentro.
process.env.PORT lê uma variável de ambiente. Localmente ela não existe, então o || entrega 3000. Quando você publicar o projeto (trilha Deploy), o serviço de hospedagem define PORT e o seu código se adapta sem precisar de alteração. Duas linhas hoje, uma dor de cabeça a menos depois.
Leia assim: quando chegar uma requisição com o método GET no caminho /api/produtos, execute esta função.
app.get, app.post, app.put, app.delete — o método HTTP vira o nome da função. (No Express 5, app.del() foi removido: escreva app.delete().)
O primeiro argumento é o caminho (path), sempre começando com /.
O segundo é a função manipuladora (handler), que recebe req e res.
Reconheça o padrão: é o mesmo modelo de callback dos eventos do DOM. Lá, botao.addEventListener("click", (evento) => {}); aqui, app.get("/rota", (req, res) => {}). O "evento" agora é chegou uma requisição neste caminho.
Os pedaços variáveis do caminho: /produtos/:id → req.params.id
req.query
A query string: ?categoria=cafes&q=pao → req.query.categoria
req.headers
Os cabeçalhos HTTP enviados pelo navegador
res (response) é o que você vai devolver:
Método
O que faz
res.send(algo)
Devolve texto ou HTML, adivinhando o Content-Type
res.json(objeto)
Serializa para JSON e define Content-Type: application/json
res.status(404)
Define o código de status; encadeável: res.status(404).json({})
res.sendFile(caminho)
Devolve um arquivo do disco (caminho absoluto)
⚠️ Atenção
No Express 5, req.query é somente leitura. Tentar req.query.q = "cafe" derruba o servidor com TypeError: Cannot set property query of #<IncomingMessage> which has only a getter. Se precisar de uma versão modificada, copie para uma variável: const filtros = { ...req.query }.
📌 Vale gravar
Duas trocas do Express 4 para o 5 costumam confundir: res.json(obj, 201) virou res.status(201).json(obj), e res.redirect('/rota', 302) virou res.redirect(302, '/rota'). A regra mental é "status primeiro".
Alterou o server.js? O processo Node continua rodando o código antigo, carregado na memória quando você digitou node server.js. É preciso parar (Ctrl+C) e rodar de novo. Fazer isso quarenta vezes por aula é insuportável — e é a causa número 1 de "mas eu já corrigi e não mudou nada".
O Node 22 resolve isso nativamente:
Terminal
node--watchserver.js
Com o script dev do package.json, fica só:
Terminal
npmrundev
Saída esperada a cada salvamento:
Texto
Servidor rodando em http://localhost:3000
Restarting 'server.js'
Servidor rodando em http://localhost:3000
💡 Dica
Tutoriais mais antigos mandam instalar o pacote nodemon para isso. Ele continua funcionando, mas desde o Node 18/22 a flag --watch faz o mesmo sem dependência nenhuma. Uma dependência a menos é sempre uma boa notícia.
🔎 Por baixo do capô
O que acontece entre você digitar localhost:3000 e a página aparecer? (1) O navegador resolve localhost para o endereço 127.0.0.1 — a sua própria máquina — sem consultar DNS na internet. (2) Abre uma conexão TCP na porta 3000. (3) Envia um texto: GET / HTTP/1.1, mais os cabeçalhos. (4) O Node aceita a conexão, monta os objetos req e res e chama o handler que casou com método e caminho. (5) O seu res.send escreve de volta HTTP/1.1 200 OK, os cabeçalhos e o corpo. (6) O navegador lê o Content-Type, decide que é HTML e renderiza. Tudo isso em menos de um milissegundo, porque nada saiu da sua máquina.
Até agora você abria o site com o Live Server, um servidorzinho que a extensão do VS Code sobe para você. A partir de hoje o servidor é o seu — e ele também sabe entregar HTML, CSS, imagens e JavaScript.
Coloque o site inteiro em uma pasta public/ e acrescente uma linha:
server.js
JavaScript
constpath=require("node:path");constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;// Entrega qualquer arquivo que exista dentro de public/app.use(express.static(path.join(__dirname,"public")));app.listen(PORTA,()=>{console.log(`Servidor rodando em http://localhost:${PORTA}`);});
Pronto: http://localhost:3000 devolve public/index.html, http://localhost:3000/css/estilo.css devolve o CSS, http://localhost:3000/img/logo.svg devolve a imagem. Nenhuma rota precisou ser escrita.
__dirname é uma variável que o CommonJS injeta em todo módulo: a pasta onde este arquivo está. Você poderia escrever express.static("public") e funcionaria — mas apenas se o terminal estivesse aberto exatamente na pasta do projeto, porque um caminho relativo é resolvido a partir do diretório de trabalho, não do arquivo. path.join(__dirname, "public") funciona sempre.
O express.static é registrado com app.use, e tudo que se registra com app.use entra em uma fila que é percorrida na ordem de registro. O express.static procura o arquivo pedido; se encontra, responde e encerra; se não encontra, passa a requisição adiante para as rotas seguintes.
JavaScript
app.use(express.static(path.join(__dirname,"public")));// Esta rota só é alcançada se NÃO existir um arquivo public/sobre.htmlapp.get("/sobre",(req,res)=>{res.send("<h1>Sobre o Café Cerrado</h1>");});
Se existir public/sobre.html, ele ganha — o arquivo estático é encontrado antes. Essa disputa entre arquivo e rota é fonte garantida de confusão; na próxima aula, ao estudar middlewares, a mecânica ficará explícita.
Tudo que está em public/ pode ser baixado por qualquer pessoa que saiba (ou adivinhe) o nome do arquivo. Isso é ótimo para CSS e imagens e péssimo para dados que você quer controlar.
Hoje o data/produtos.json do Café Cerrado está dentro do site: qualquer visitante pode abrir /data/produtos.json e ver o arquivo cru. Na nossa nova arquitetura ele sai de lá e passa a ficar fora de public/, acessível só pelo servidor. O visitante continua vendo os produtos — mas através do endpoint /api/produtos, que é código seu, e onde amanhã você poderá filtrar, esconder campos, exigir login ou registrar quem consultou.
⚠️ Atençãoexpress.static ignora dotfiles por padrão: um arquivo chamado .env dentro de public/ não é servido. Isso é uma rede de proteção, não uma permissão — segredo nenhum deve ficar em public/, ponto final.
Na Aula 10 você consumiu a JSONPlaceholder. Agora você é a JSONPlaceholder:
server.js (trecho)
JavaScript
constprodutos=[{id:1,nome:"Espresso do Cerrado",categoria:"cafes",preco:6},{id:2,nome:"Pão de Queijo Mineiro",categoria:"salgados",preco:7},];// GET /api/produtos → a lista completaapp.get("/api/produtos",(req,res)=>{res.json(produtos);// serializa para JSON e define o Content-Type});
res.json(produtos) faz três coisas: chama JSON.stringify no objeto, define o cabeçalho Content-Type: application/json; charset=utf-8 e envia. Sem ele, você teria que fazer os três passos na mão, como no exemplo com o módulo http.
Por que o caminho começa com /api? Convenção. Ela separa, na URL, o que é página para humanos (/, /cardapio.html) do que é dado para programas (/api/produtos). Quem lê a URL já sabe o que esperar.
// GET /api/produtos/2 → só o produto de id 2app.get("/api/produtos/:id",(req,res)=>{constid=Number(req.params.id);// params SEMPRE chegam como stringconstproduto=produtos.find((p)=>p.id===id);if(!produto){returnres.status(404).json({erro:"Produto não encontrado"});}res.json(produto);});
Três detalhes que valem cada um um bug evitado:
:id cria um parâmetro nomeado. Qualquer valor naquela posição casa: /api/produtos/2, /api/produtos/abc, /api/produtos/999.
req.params.id é string."2" === 2 é falso. Sem o Number(), o find nunca acha nada e a sua API responde 404 para tudo.
return antes do res.status(404). Sem ele, o código continua e tenta chamar res.json(produto) com produto valendo undefined — o Express reclama com Error: Cannot set headers after they are sent to the client.
O recurso pedido não existe (ou a rota não existe)
500 Internal Server Error
Algo quebrou dentro do seu servidor
Na próxima aula entram 201, 204, 400 e, na Aula 14, 401 e 403. Por hoje, três bastam — e a regra é sempre a mesma: o status é para a máquina, a mensagem no corpo é para a pessoa. Um fetch no front-end verifica resposta.ok (que é true só para status 200–299) e mostra a mensagem do corpo ao usuário.
⚠️ Atençãofetchnão rejeita a Promise quando o servidor responde 404 ou 500 — do ponto de vista da rede, a requisição foi um sucesso. Foi por isso que na Aula 09 você escreveu if (!resposta.ok) throw new Error(...). Aquele if continua obrigatório agora que o servidor é seu.
Manter a lista dentro do server.js funciona para dois produtos e trava na hora de crescer. Vamos ler de um arquivo JSON, com o módulo fs/promises — a versão do módulo de arquivos que devolve Promises, exatamente o que você aprendeu a manipular na Aula 09.
data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"}]
server.js (trecho)
JavaScript
constfs=require("node:fs/promises");constCAMINHO_DADOS=path.join(__dirname,"data","produtos.json");// Lê o arquivo e devolve um array de objetos JavaScript.asyncfunctionlerProdutos(){constconteudo=awaitfs.readFile(CAMINHO_DADOS,"utf-8");returnJSON.parse(conteudo);}app.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();res.json(produtos);});
Repare no async antes de (req, res). Ele é necessário porque temos um await dentro do handler — e o Express aceita handlers assíncronos sem qualquer configuração extra.
🔎 Por baixo do capô
Se data/produtos.json estiver corrompido, JSON.parse lança um erro. No Express 4, um throw dentro de um handler async não era capturado pelo framework: a Promise rejeitava em silêncio, a requisição ficava pendurada até o navegador desistir, e o processo Node inteiro corria risco de morrer com UnhandledPromiseRejection. Era por isso que todo tutorial mandava embrulhar tudo em try/catch ou instalar o pacote express-async-handler. No Express 5, o framework embrulha cada handler async internamente: qualquer exceção lançada — síncrona ou dentro de um await — é capturada e encaminhada ao tratador de erros. Você ganha um 500 limpo em vez de um servidor travado. Na próxima aula vamos escrever esse tratador.
O navegador só sabe fazer GET. Para os outros métodos (e para ver o status e os cabeçalhos), use o curl:
Terminal
# lista completa; -i mostra os cabeçalhos junto com o corpo
curl-ihttp://localhost:3000/api/produtos
# um item existente
curl-ihttp://localhost:3000/api/produtos/1
# um item inexistente: deve responder 404
curl-ihttp://localhost:3000/api/produtos/999
Saída esperada da última chamada (o curl mostra ainda outros cabeçalhos; estes são os que importam):
Texto
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Content-Length: 34
{"erro":"Produto não encontrado"}
Na próxima aula você troca o curl por um arquivo testes.http versionado junto com o projeto — muito mais confortável.
💻 Mão na massa — o Café Cerrado ganha um servidor¶
Vamos criar o repositório da Unidade 3 inteira. Ele nasce hoje e será o mesmo até a Aula 16.
Se aparecer express@4.x, você está com um package.json antigo ou com cache estranho: apague node_modules/ e package-lock.json e rode npm install express@5 de novo.
# dependências instaladas pelo npm — reconstruídas com "npm install"
node_modules/
# arquivos de ambiente com segredos — usaremos a partir da Aula 14
.env
# lixo de sistema operacional e de editor
.DS_Store
Thumbs.db
Confira que funcionou:
Terminal
gitstatus--short
node_modules/não pode aparecer na lista. Se aparecer, o .gitignore está no lugar errado (tem que estar na raiz do projeto) ou com o nome errado (é .gitignore, com ponto na frente).
{"name":"cafe-cerrado-api","version":"1.0.0","description":"API e site do Café Cerrado — Sinop/MT","main":"server.js","scripts":{"dev":"node --watch server.js","start":"node server.js"},"keywords":["express","api","cafeteria"],"author":"Seu Nome","license":"ISC","dependencies":{"express":"^5.1.0"}}
No Windows, use o Explorador de Arquivos: copie index.html, css/, js/ e img/ para dentro de public/, e data/produtos.json para uma pasta data/ na raiz do projeto (fora de public/).
Vale saber o que veio em cada peça, porque nenhuma delas é descartável:
Arquivo copiado
O que é
O que acontece com ele hoje
index.html
A página única da SPA, com as três <section data-rota>
Continua igual; o Express passa a servi-lo em /
css/estilo.css
Todo o CSS das Aulas 02 a 06
Continua igual
js/api.js
A camada de acesso a dados da Aula 10
Duas linhas mudam no Passo 7: as URLs
js/roteador.js
O roteador por hash da Aula 10
Continua igual — é ele que mostra e esconde as telas
js/app.js
A aplicação (filtros, cards, carrinho, contato)
Continua igual, sem uma vírgula de diferença
img/
As fotos dos dez produtos
Continua igual
data/produtos.json
O cardápio
Sai de public/ e vai para a raiz: agora é dado do servidor
data/categorias.json
A lista de categorias da Aula 10
Não é copiado: a partir de hoje quem responde categorias é a API
A estrutura final:
Texto
cafe-cerrado-api/
├── data/
│ └── produtos.json # dados: FORA de public/, só o servidor lê
├── public/ # o site das Unidades 1 e 2
│ ├── css/
│ │ └── estilo.css
│ ├── img/
│ ├── js/
│ │ ├── api.js
│ │ ├── app.js
│ │ └── roteador.js
│ └── index.html
├── .gitignore
├── package.json
├── package-lock.json
└── server.js
O arquivo que você copiou no passo anterior já é o cardápio certo — são os mesmos dez produtos das Aulas 03 a 10, com os mesmos ids, preços e categorias. Não reescreva nada: o data/produtos.json do cafe-cerrado-api tem de ser byte por byte igual ao do cafe-cerrado. Confira abaixo se o seu bate.
data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
⚠️ Atenção
Este arquivo é o contrato entre o seu back-end e o seu front-end. O js/app.js que veio da Aula 10 filtra por produto.categoria === "geladas", mostra produto.imagem e formata produto.preco — se você renomear uma categoria ou apagar um campo aqui, a SPA quebra sem dar erro no servidor. Da Aula 12 até a 16 esses dez objetos continuam sendo a base; o que muda é quem os serve.
constpath=require("node:path");constfs=require("node:fs/promises");constexpress=require("express");constapp=express();constPORTA=process.env.PORT||3000;constCAMINHO_DADOS=path.join(__dirname,"data","produtos.json");// As quatro categorias do cardápio, na ordem em que aparecem no site.// Este array substitui o data/categorias.json da Aula 10: a partir de hoje,// quem publica a lista de categorias é a API, não um arquivo do front-end.constCATEGORIAS=[{id:"cafes",nome:"Cafés"},{id:"geladas",nome:"Bebidas geladas"},{id:"salgados",nome:"Salgados"},{id:"doces",nome:"Doces"},];// ---------------------------------------------------------------// Arquivos estáticos: o site das Unidades 1 e 2, servido por nós.// ---------------------------------------------------------------app.use(express.static(path.join(__dirname,"public")));// ---------------------------------------------------------------// Acesso aos dados// ---------------------------------------------------------------asyncfunctionlerProdutos(){constconteudo=awaitfs.readFile(CAMINHO_DADOS,"utf-8");returnJSON.parse(conteudo);}// ---------------------------------------------------------------// API// ---------------------------------------------------------------// GET /api/produtos → lista completa do cardápioapp.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();res.json(produtos);});// GET /api/produtos/:id → um produto, ou 404app.get("/api/produtos/:id",async(req,res)=>{constid=Number(req.params.id);constprodutos=awaitlerProdutos();constproduto=produtos.find((p)=>p.id===id);if(!produto){returnres.status(404).json({erro:`Produto ${req.params.id} não encontrado`});}res.json(produto);});// GET /api/categorias → [{ id, nome }], só as categorias que têm produto hoje.// O formato é o MESMO do antigo data/categorias.json, porque o front-end da// Aula 10 monta o <select> lendo categoria.id e categoria.nome.app.get("/api/categorias",async(req,res)=>{constprodutos=awaitlerProdutos();constusadas=newSet(produtos.map((p)=>p.categoria));res.json(CATEGORIAS.filter((categoria)=>usadas.has(categoria.id)));});// ---------------------------------------------------------------app.listen(PORTA,()=>{console.log(`Café Cerrado no ar em http://localhost:${PORTA}`);});
Rode:
Terminal
npmrundev
Texto
Café Cerrado no ar em http://localhost:3000
Repare na decisão do /api/categorias: ele não devolve ["cafes", "doces", "geladas", "salgados"]. Devolveria, se fosse só um [...new Set(…)] — e o <select> do cardápio ficaria com quatro opções escritas [object Object] ou, pior, com os ids técnicos na cara do usuário. O front-end da Aula 10 espera objetos com id (o valor da <option>) e nome (o texto). Um endpoint só é "pronto" quando devolve o formato que o consumidor precisa; a API existe para servir o cliente, não o contrário.
Aqui está a recompensa de ter concentrado todo o fetch em um arquivo só na Aula 10. O front-end inteiro — o roteador por hash, o index.html com as três <section data-rota>, os ids #cards, #status-cardapio, #resumo, #filtro-categoria, o formulário de contato — fica exatamente como está. Você vai editar duas linhas, as duas dentro de public/js/api.js:
public/js/api.js (trecho — o resto do arquivo não muda)
É isso. Salve, recarregue http://localhost:3000 e o cardápio aparece — agora vindo do seu servidor.
Três detalhes valem o comentário:
A barra inicial importa."/api/produtos" é um caminho absoluto na origem: sempre resolve para http://localhost:3000/api/produtos, esteja você em /, em /index.html#/cardapio ou em qualquer rota futura. Sem a barra, o navegador resolveria em relação à página atual e um dia buscaria /alguma/pasta/api/produtos.
Mesma origem, zero CORS. O site e a API saem do mesmo http://localhost:3000, então nenhum cabeçalho de CORS precisa ser configurado. Aquele erro de Access-Control-Allow-Origin da Aula 10 simplesmente não existe aqui — e é por isso que servir o front pelo próprio Express é o caminho mais curto até uma aplicação que funciona.
data/categorias.json foi aposentado. Ele não existe mais no cafe-cerrado-api (o cp do Passo 4 copiou só o produtos.json), e a lista de categorias passa a nascer no server.js. Se você ainda tiver o arquivo no repositório antigo, deixe-o lá como histórico — mas nada mais o lê.
⚠️ Atenção
Não apague o js/roteador.js nem troque o <script type="module"> por um script clássico. A SPA da Aula 10 depende dos dois: sem o roteador, as <section data-rota> continuam com hidden e a tela do cardápio nunca aparece, mesmo com a API respondendo 200. Se o cardápio "sumiu" depois desta aula, o primeiro lugar para olhar é o console — um erro de import derruba o módulo inteiro em silêncio visual.
Com npm run dev rodando, abra http://localhost:3000. O site aparece sem Live Server, e o link Cardápio do menu leva a http://localhost:3000/#/cardapio com os dez cards.
Abra o DevTools na aba Network, recarregue e clique na requisição produtos. Confira: Status200, Request URLhttp://localhost:3000/api/produtos, aba Response com o array JSON de dez objetos.
Confira o <select> de categoria: cinco opções ("Todas" mais as quatro), com os textos "Cafés", "Bebidas geladas", "Salgados" e "Doces" — não com os ids técnicos.
No terminal, curl -i http://localhost:3000/api/produtos/3 deve devolver o Cappuccino Sinop com status 200.
curl -i http://localhost:3000/api/produtos/999 deve devolver 404 e {"erro":"Produto 999 não encontrado"}.
curl http://localhost:3000/api/categorias deve devolver [{"id":"cafes","nome":"Cafés"},{"id":"geladas","nome":"Bebidas geladas"},{"id":"salgados","nome":"Salgados"},{"id":"doces","nome":"Doces"}].
Edite data/produtos.json, mude um preço, salve e recarregue a página do navegador. O preço novo aparece sem reiniciar nada: o arquivo é lido a cada requisição.
Renomeie data/produtos.json para data/produtos-x.json e recarregue o cardápio. O front-end mostra a mensagem de erro e o botão "Tentar de novo" da Aula 09 — a camada de erro que você escreveu continua valendo com a API nova. Volte o nome depois.
Commit:
Terminal
gitadd.
gitcommit-m"Servidor Express servindo o site e a API de produtos"
gitbranch-Mmain
gitremoteaddoriginhttps://github.com/SEU-USUARIO/cafe-cerrado-api.git
gitpush-uoriginmain
A1. Sem rodar, diga o que cada comando abaixo imprime (ou que erro dá). Depois rode e confira.
Terminal
node-e"console.log(typeof window)"
node-e"console.log(typeof process)"
node-e"console.log([1,2,3].map(n => n * 2))"
node-e"console.log(document.title)"
A2. O package.json de um projeto tem "express": "^5.1.0". Quais destas versões o npm install aceitaria instalar: 5.1.4, 5.3.0, 6.0.0, 5.0.9? Justifique cada uma em meia linha.
A3. Dado o server.js do Passo 6, o que acontece com uma requisição para http://localhost:3000/api/produtos/dois? Qual o status e qual o corpo da resposta? Explique o papel do Number() nesse resultado.
A4. Existe um arquivo public/api/produtos (sem extensão) com o texto oi. Qual das duas respostas o servidor devolve para GET /api/produtos: o arquivo ou a rota? Aponte a linha do server.js que decide isso.
A5. Complete o código para que a rota devolva apenas os produtos da categoria pedida, ignorando maiúsculas. Se a query string não vier, devolva a lista inteira.
JavaScript
app.get("/api/produtos",async(req,res)=>{constprodutos=awaitlerProdutos();constcategoria=req.query.categoria;// Escreva aqui: filtre por categoria quando ela existir.res.json(produtos);});
A6. Um colega jura que corrigiu o bug, salvou o arquivo, e o navegador continua mostrando a resposta antiga. Ele está rodando node server.js. Cite duas causas possíveis e como distinguir uma da outra em dez segundos.
B1. Endpoint de busca. Acrescente ao server.js a rota GET /api/produtos/busca/:termo, que devolve os produtos cujo nome contenha o termo, sem diferenciar maiúsculas de minúsculas.
Resultado esperado: curl http://localhost:3000/api/produtos/busca/cafe devolve os dois cafés; curl http://localhost:3000/api/produtos/busca/xyz devolve [] com status 200 (lista vazia não é erro).
Dica
Registre esta rota antes de /api/produtos/:id, senão busca vira o valor de :id e o Number("busca") produz NaN. String.prototype.includes combinado com toLowerCase() nos dois lados resolve a comparação.
B2. Estatísticas do cardápio. Crie GET /api/estatisticas, que devolve um objeto com total (quantidade de produtos), precoMedio (arredondado para duas casas) e maisCaro (o nome do produto de maior preço).
Resultado esperado: com o cardápio de hoje, {"total":10,"precoMedio":11.2,"maisCaro":"Frappê de Café"}.
Dica
reduce para somar os preços (Aula 08), Number(x.toFixed(2)) para arredondar mantendo o tipo número, e outro reduce (ou sort seguido de at(-1)) para o mais caro.
B3. A página que o servidor monta. Crie a rota GET /cardapio-texto que devolve uma resposta em texto puro (não JSON) listando os produtos, um por linha, no formato Nome — R$ 7,50.
Resultado esperado: abrir http://localhost:3000/cardapio-texto no navegador mostra seis linhas de texto sem formatação, e curl -i confirma Content-Type: text/html ou text/plain.
Dica
res.type("text/plain") antes de res.send(...) define o cabeçalho. Para juntar as linhas, produtos.map(...).join("\n").
B4. Servidor sem framework. Reescreva só o endpoint GET /api/produtos em um arquivo exemplos/servidor-sem-express.js, usando apenas o módulo node:http, e rode na porta 3001.
Resultado esperado: curl -i http://localhost:3001/api/produtos devolve o mesmo JSON e o mesmo Content-Type que a versão Express. Escreva em duas linhas, no topo do arquivo, o que o Express fez por você.
Dica
Você vai precisar escrever res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }) e res.end(JSON.stringify(produtos)) na mão — é justamente esse o ponto do exercício.
C1. Paginação no servidor. Faça GET /api/produtos aceitar ?pagina=2&limite=2 e devolver um objeto — não mais um array cru — no formato { "dados": [...], "pagina": 2, "limite": 2, "total": 6, "totalPaginas": 3 }. Sem query string, o comportamento padrão deve ser pagina=1 e limite=10. Depois, ajuste o public/js/app.js para continuar funcionando com o novo formato e acrescente dois botões, "Anterior" e "Próxima", que ficam desabilitados nos extremos.
Resultado esperado: curl "http://localhost:3000/api/produtos?pagina=3&limite=2" devolve os dois últimos produtos e "totalPaginas":3; na tela, os botões navegam e o botão "Anterior" nasce desabilitado.
Dica
slice((pagina - 1) * limite, pagina * limite) faz o recorte. Valide os dois parâmetros: Number("abc") é NaN, e NaN em um slice devolve resultados estranhos — use Number.isInteger(x) && x > 0 antes de confiar. No front, guarde a página atual em uma variável de módulo e chame carregarCardapio() de novo a cada clique.
O front-end do Café Cerrado precisa montar os botões de filtro por categoria, mas hoje ele só sabe pedir a lista inteira de produtos e deduzir as categorias no navegador — o que significa baixar seis produtos completos (com descrição e imagem) só para descobrir quatro palavras. O server.js desta aula já tem um GET /api/categorias que resolve metade do problema: ele devolve ["cafes","doces","geladas","salgados"], mas o front precisa também de quantos itens há em cada uma, para mostrar "Cafés (2)". Faça a API entregar exatamente o que a tela precisa, e nem um byte a mais.
Critérios de pronto
GET /api/categorias devolve um array de objetos com nome e quantidade, ordenado por nome.
A soma dos quantidade é igual ao total de produtos do arquivo (confira com /api/produtos).
Acrescentar um produto de categoria nova em data/produtos.json faz a nova categoria aparecer na resposta sem alterar uma linha de código.
O front-end monta os botões de filtro a partir desse endpoint, exibindo o rótulo no formato Cafés (2).
Um comentário de duas linhas no server.js compara o tamanho da resposta de /api/categorias com o de /api/produtos (use curl -s ... | wc -c).
Pistas
O reduce da Aula 08 monta um objeto contador: produtos.reduce((acc, p) => { acc[p.categoria] = (acc[p.categoria] ?? 0) + 1; return acc; }, {}).
Object.entries(contagem) devolve pares [nome, quantidade] — de onde sai o map para o formato final.
Para ordenar textos em português (com acento), array.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR")).
Rótulos bonitos ("Cafés" em vez de "cafes") são um problema de apresentação: resolva no front, com um objeto de tradução, e não polua a API.
Abra data/produtos.json, apague uma vírgula qualquer no meio e salve. Agora recarregue o site. O que você vê? Provavelmente a página em branco, um erro feio no terminal e um usuário que nunca vai saber o que aconteceu. O arquivo é a única fonte de dados da sua API — e ele pode estar corrompido, sumido ou sem permissão de leitura. Descubra exatamente o que o Express 5 faz nesse caso, prove com evidência, e transforme o desastre em uma resposta honesta.
Critérios de pronto
Um arquivo docs/investigacao-erro.md registra: a mensagem literal do terminal, o status HTTP devolvido ao cliente e o corpo da resposta, nos três cenários (JSON inválido, arquivo apagado, arquivo com permissão negada).
Com o JSON inválido, a API responde 500 com um corpo JSON { "erro": "..." } — nunca uma página HTML de erro, nunca o caminho do arquivo no seu computador.
Com o arquivo apagado, a resposta é 500 e o terminal mostra um log com a mensagem completa do erro (incluindo o código ENOENT).
O site mostra a mensagem de falha do catch em vez de ficar em branco.
Uma frase no docs/investigacao-erro.md explica por que expor error.stack ao cliente é um risco de segurança.
Pistas
Rode curl -i http://localhost:3000/api/produtos com o JSON quebrado antes de escrever qualquer código: o Express 5 já responde algo. O quê, exatamente?
Para simular permissão negada no Linux/macOS: chmod 000 data/produtos.json (e chmod 644 depois para desfazer).
try { ... } catch (erro) { ... } dentro de lerProdutos permite distinguir os casos: erro.code === "ENOENT" é arquivo ausente; um SyntaxError é JSON inválido.
A resposta ao cliente deve ser genérica; o detalhe vai para o console.error. Esse par — log detalhado no servidor, mensagem genérica ao cliente — é a regra que vamos formalizar em um middleware na próxima aula.
"Por que instalar um framework se o Node já tem um servidor embutido?" É uma pergunta legítima, e a resposta "porque todo mundo usa" não vale nada em uma prova ou em uma entrevista. Reimplemente a sua API inteira — as três rotas mais os arquivos estáticos — usando apenasnode:http, node:fs/promises e node:path, e depois defenda uma das duas versões com evidência, não com opinião.
Critérios de pronto
exemplos/servidor-sem-express.js responde, na porta 3001, exatamente às mesmas quatro coisas que a versão Express: /api/produtos, /api/produtos/:id, /api/categorias e os arquivos de public/.
Os Content-Type batem com os da versão Express para HTML, CSS, JavaScript, imagem e JSON.
Uma tabela no README.md compara as duas versões em três colunas: linhas de código, o que foi difícil, o que o Express faz de graça.
O curl -i de /api/produtos/999 devolve 404 com o mesmo corpo nas duas versões.
Uma nota no README.md explica o que aconteceu ao tentar servir os arquivos estáticos na mão (esse é o ponto onde o Express ganha por muitos comprimentos).
Pistas
new URL(req.url, "http://localhost") separa caminho e query string de graça — o req.url cru traz os dois grudados.
Parâmetros de rota, sem framework, viram manipulação de string: caminho.split("/") e comparação de posições.
Para os estáticos: leia o arquivo com fs.readFile, escolha o Content-Type por extensão (path.extname) e devolva 404 quando o readFile lançar ENOENT.
Cuidado com a travessia de diretório: uma requisição para /../data/produtos.json não pode devolver nada. Compare o caminho resolvido com path.resolve antes de abrir o arquivo — é justamente esse tipo de detalhe que o express.static já trata.
Abra o DevTools na aba Network, recarregue o site e olhe a coluna Size do estilo.css: em vez do tamanho em kB, aparece (disk cache) ou o status 304. O Express fez isso sozinho para os arquivos estáticos — mas a sua rota /api/produtos baixa o JSON inteiro toda vez, mesmo quando nada mudou no cardápio. Em uma cafeteria com trezentos produtos e mil visitas por dia, isso é tráfego jogado fora. Descubra como o navegador e o servidor combinam "não mudou nada desde a última vez" e aplique o mesmo mecanismo à sua API.
Critérios de pronto
Um documento docs/cache.md explica, com as requisições capturadas no DevTools, o que são ETag, If-None-Match, Last-Modified e If-Modified-Since, e qual deles o express.static usou no seu CSS.
GET /api/produtos passa a devolver um cabeçalho ETag calculado a partir do conteúdo do arquivo.
Uma segunda requisição enviando If-None-Match com o mesmo valor recebe 304 Not Modifiedsem corpo.
Alterar data/produtos.json muda o ETag e a requisição seguinte volta a receber 200 com o corpo completo.
Uma tabela no docs/cache.md compara os bytes transferidos antes e depois, medidos com curl -s -w '%{size_download}\n' -o /dev/null.
Uma frase justifica por que um Cache-Control: max-age=3600 puro seria a escolha errada para um cardápio que o dono edita durante o dia.
Pistas
Comece medindo: curl -i http://localhost:3000/css/estilo.css duas vezes, a segunda com -H "If-None-Match: <valor que veio>".
Um ETag é só uma impressão digital do conteúdo. require("node:crypto").createHash("sha1").update(conteudo).digest("hex") serve muito bem.
O cabeçalho enviado pelo cliente chega em req.headers["if-none-match"] — sempre em minúsculas, sempre string.
res.status(304).end() encerra sem corpo. Enviar corpo junto com 304 é violação da especificação HTTP e alguns clientes engasgam.
Para o caso do cardápio editado durante o dia, investigue a diretiva no-cache — que, apesar do nome, não significa "não guarde", e sim "guarde, mas confirme antes de usar".
No seu projeto autoral, crie o repositório da Unidade 3 e repita a arquitetura de hoje com os seus dados:
Crie o repositório <seu-projeto>-api no GitHub, com npm init -y, npm install express e .gitignore contendo node_modules/.
Mova o site das Unidades 1 e 2 para public/ e sirva-o com express.static. O Live Server deve deixar de ser necessário.
Tire o arquivo de dados de dentro de public/ e coloque-o em data/<seu-recurso>.json, com no mínimo seis itens e os mesmos seis campos do modelo (id, nome, categoria, preco, descricao, imagem — adapte os nomes ao seu domínio).
Implemente GET /api/<seu-recurso> e GET /api/<seu-recurso>/:id, com 404 correto para id inexistente.
Aponte o fetch do seu front-end para a sua própria API e confirme os cards renderizando.
Acrescente ao README.md uma seção "Como rodar" com os três comandos necessários (npm install, npm run dev, endereço).
Critério de pronto: clonando o repositório em uma pasta vazia e rodando npm install && npm run dev, o site abre em http://localhost:3000 com os dados vindos da API, e curl -i http://localhost:3000/api/<seu-recurso>/999 devolve 404.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA — capítulo sobre a camada de back-end com Node.js.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec — introdução ao Node.js e ao ecossistema npm.
Na próxima aula o server.js de arquivo único chega ao seu limite: as rotas ganham arquivo próprio com express.Router, o servidor aprende a receber dados de um POST com express.json(), e você conhece a peça que sustenta o Express inteiro — o middleware — junto com o tratamento centralizado de erros e o 404 da API.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 12 — Express estruturado e middlewares
Nível 2 — Desenvolvimento Web · WebLab
O server.js da Aula 11 funciona: serve o site, devolve o cardápio em JSON e responde 404 para um id inexistente. Mas ele é um arquivo só, e um arquivo só não escala. Imagine quarenta rotas, validações, regras de negócio e log de acesso empilhados ali dentro. Hoje aplicamos ao servidor exatamente o princípio que você já usa no front desde a Unidade 1 — separação de responsabilidades —, só que entre arquivos. E, para isso, você precisa conhecer a peça que sustenta o Express inteiro: o middleware.
Na aula passada você criou o repositório cafe-cerrado-api, subiu um servidor Express 5, serviu o site pela pasta public/ e escreveu os endpoints GET /api/produtos, GET /api/produtos/:id e GET /api/categorias. Tudo isso mora em um único server.js. Hoje esse arquivo é quebrado em peças, ganha a capacidade de receber dados (não só devolver) e aprende a falhar de forma organizada. Os três endpoints continuam existindo do começo ao fim — eles apenas mudam de casa, cada recurso no seu Router.
Antes de começar, confirme:
[ ] cafe-cerrado-api clonado ou aberto, com npm install já rodado.
[ ] npm run dev sobe o servidor e http://localhost:3000 mostra o site.
[ ] curl -i http://localhost:3000/api/produtos devolve 200 e o array de produtos.
[ ] Extensão REST Client instalada no VS Code (humao.rest-client).
[ ] data/produtos.json com pelo menos seis itens, cada um com id, nome, categoria, preco, descricao e imagem.
Um middleware é uma função que recebe (req, res, next) e roda entre a chegada da requisição e a resposta final. Ela pode fazer três coisas:
Examinar ou modificarreq e res (por exemplo, acrescentar req.usuario depois de conferir um token).
Responder e encerrar a requisição ali mesmo (res.json(...)), sem chamar next().
Passar adiante, chamando next(), para que o próximo middleware da fila decida o que fazer.
A analogia: uma linha de montagem com estações de inspeção. A peça (a requisição) entra por um lado e passa estação por estação. Cada estação pode carimbar a peça, rejeitá-la ali mesmo ou deixá-la seguir. Uma rota — app.get("/api/produtos", ...) — é só a última estação da linha, a que costuma produzir a resposta.
JavaScript
// Middleware de log: roda para TODA requisição que chega ao servidor.app.use((req,res,next)=>{console.log(`${newDate().toISOString()}${req.method}${req.originalUrl}`);next();// sem next(), a requisição "trava" aqui e nunca chega às rotas});
Middlewares são registrados com app.use(...) (ou com app.get, app.post, etc.) e executados na ordem em que aparecem no arquivo. Não é uma sugestão: é o mecanismo. Trocar duas linhas de lugar muda o comportamento do servidor.
JavaScript
app.use(express.json());// 1º: transforma o corpo JSON em objetoapp.use(registrarRequisicao);// 2º: escreve no console o que chegouapp.use(express.static("public"));// 3º: tenta achar um arquivo em public/app.use("/api/produtos",produtosRouter);// 4º: tenta casar com uma rota de produtoapp.use("/api",naoEncontradoApi);// 5º: só chega aqui quem não casou acimaapp.use(tratadorDeErros);// 6º: só roda quando algo lançou erro
Uma requisição para GET /css/estilo.css percorre 1, 2, 3 — e para no 3, porque o arquivo existe. Uma requisição para GET /api/produtos percorre 1, 2, 3 (não achou arquivo), e para no 4. Uma requisição para GET /api/pedidos percorre 1, 2, 3, 4 (nenhuma rota de produto casou) e para no 5.
express.static("public") é um middleware: ele procura o arquivo pedido; se acha, responde e encerra; se não acha, chama next() e a requisição segue.
As próprias rotas são middlewares — só que com uma condição extra: só rodam se o método e o caminho casarem.
Ou seja, app.get("/api/produtos", handler) é açúcar sintático para "registre este middleware, mas só execute quando o método for GET e o caminho for /api/produtos".
// BUG: este middleware nunca deixa a requisição seguir.app.use((req,res,next)=>{console.log("Chegou:",req.originalUrl);// faltou o next() aqui});
O servidor não quebra. Não há erro no terminal. Simplesmente nada acontece: o navegador fica com a abinha girando até desistir, e na aba Network o status da requisição é (pending) — pendente — para sempre. É um bug silencioso e assustador na primeira vez.
A regra que resolve: todo middleware precisa terminar chamando next() ou enviando uma resposta. Nunca as duas coisas, nunca nenhuma das duas.
🔬 Investigue
No seu server.js, acrescente logo no começo um middleware de log e não chame next(). Rode npm run dev, abra http://localhost:3000 e observe: a aba Network do DevTools mostra a requisição em (pending); o terminal mostra a linha de log. Agora abra outra aba e peça o CSS: a requisição também fica pendurada, mas o log aparece — prova de que o middleware rodou e a cadeia parou nele. Meça quanto tempo o Chrome espera antes de desistir. Depois acrescente o next() e confirme que tudo volta ao normal. Anote a diferença entre "servidor travado" e "requisição travada": o processo Node continua atendendo outras conexões o tempo todo.
Na Aula 10 o seu front-end enviou JSON.stringify(dados) no corpo de um POST. Do lado do servidor, esse corpo chega como uma sequência de bytes crua — o Express não a interpreta sozinho, por decisão de projeto (nem toda API recebe JSON). É preciso ligar o parser:
JavaScript
app.use(express.json());// SEM esta linha, req.body é undefined
Com ele registrado, o Express olha o cabeçalho Content-Type da requisição; se for application/json, lê o corpo, converte com JSON.parse e deixa o resultado em req.body.
JavaScript
app.post("/api/produtos",(req,res)=>{const{nome,preco}=req.body;// o objeto enviado pelo fetchif(!nome||typeofpreco!=="number"){returnres.status(400).json({erro:"Os campos nome e preco são obrigatórios."});}constnovo={id:proximoId++,nome,preco};produtos.push(novo);res.status(201).json(novo);// 201 Created + o recurso criado});
⚠️ Atenção
No Express 5, req.body é undefined quando nenhum parser rodou — e não um objeto vazio. Destruturar undefined derruba a requisição com TypeError: Cannot destructure property 'nome' of 'req.body' as it is undefined. Se a mensagem aparecer, a causa é quase sempre uma destas três: falta app.use(express.json()); o parser foi registrado depois da rota; ou o cliente esqueceu o cabeçalho Content-Type: application/json.
💡 Dica
Tutoriais de Express 4 mandam rodar npm install body-parser e usar bodyParser.json(). Não instale nada: desde o Express 4.16 os parsers express.json() e express.urlencoded() são nativos, e no Express 5 continuam sendo. Uma dependência a menos.
2.2 express.urlencoded(): o formulário HTML clássico¶
Um <form method="post"> sem JavaScript não envia JSON — envia application/x-www-form-urlencoded, o formato nome=Cafe&preco=7.5. Para lê-lo:
JavaScript
app.use(express.urlencoded({extended:true}));
Os dois parsers podem conviver: cada um olha o Content-Type e só age no formato que entende. O Café Cerrado usa JSON (o front envia por fetch), mas registrar os dois é barato e evita surpresa quando alguém testa com um formulário puro.
Lembre das camadas: na Aula 03 o HTML validou com required e type="email"; na Aula 07 o JavaScript validou com mensagens por campo. E, mesmo assim, o servidor valida de novo. Por quê?
Porque qualquer pessoa pode mandar um POST direto para a sua API — com curl, com o REST Client, com um script de dez linhas — pulando o seu front-end inteiro. As validações do navegador servem para dar feedback rápido e gentil a quem está de boa-fé. A única validação em que se pode confiar é a que roda no servidor, porque é a única que o cliente não controla.
E validação de servidor não é opcional nem "nível avançado": é o mínimo. Sem ela, um POST com {"preco": "de graça"} entra na sua base e quebra a página de todo mundo na próxima renderização.
Sucesso em consulta ou alteração, com corpo na resposta
201 Created
Recurso criado com sucesso — resposta típica do POST
204 No Content
Sucesso sem corpo — resposta típica do DELETE
400 Bad Request
Os dados enviados pelo cliente são inválidos
404 Not Found
O recurso pedido, ou a própria rota, não existe
500 Internal Server Error
Algo quebrou dentro do seu servidor
401 (não autenticado) e 403 (autenticado, mas sem permissão) entram na Aula 14, quando o login Google chegar.
📌 Vale gravar
A diferença entre 400 e 500 é de culpa. 4xx diz "o problema está no que você mandou"; 5xx diz "o problema está aqui dentro, desculpe". Devolver 500 para um campo faltando é mentir para o cliente: ele vai ficar tentando de novo, achando que é instabilidade do servidor, quando bastava corrigir o corpo da requisição.
🧠 Você sabia?
O Express foi criado em 2010 por TJ Holowaychuk, inspirado no Sinatra, um microframework de Ruby. A versão 4.0 saiu em 2014 — e a 5.0 só chegou dez anos depois, em 2024. Uma década com uma única versão maior é raríssimo em JavaScript, e explica um efeito colateral que você vai sentir a semana inteira: praticamente todo tutorial, resposta de fórum e trecho gerado por IA que você encontrar por aí foi escrito para o Express 4. É por isso que a ESPECIFICAÇÃO deste curso tem uma seção só de armadilhas de sintaxe. Hoje o projeto é mantido pela OpenJS Foundation, a mesma fundação que abriga o Node.js.
// Registra no console o método, o caminho, o status e a duração de cada requisição.functionregistrarRequisicao(req,res,next){constinicio=Date.now();// O evento "finish" dispara quando a resposta terminou de ser enviada ao cliente.res.on("finish",()=>{constduracao=Date.now()-inicio;console.log(`${req.method}${req.originalUrl} → ${res.statusCode} (${duracao}ms)`);});next();}module.exports=registrarRequisicao;
Repare no truque: o middleware roda antes da rota, quando o status ainda não existe. Para saber o resultado, ele se inscreve no evento finish de res — o mesmo padrão de addEventListener que você usa no DOM — e só imprime quando a resposta já foi embora.
Saída típica no terminal:
Texto
GET / → 200 (4ms)
GET /css/estilo.css → 200 (1ms)
GET /api/produtos → 200 (2ms)
GET /api/produtos/999 → 404 (1ms)
POST /api/produtos → 201 (3ms)
Quando app.use recebe um caminho como primeiro argumento, o middleware só roda para requisições que começam com aquele caminho:
JavaScript
// Só roda para /api/qualquer-coisaapp.use("/api",(req,res,next)=>{res.setHeader("X-API-Versao","1.0");next();});
Confira com curl -i http://localhost:3000/api/produtos: o cabeçalho X-API-Versao: 1.0 aparece na resposta. Peça curl -i http://localhost:3000/ e ele não aparece.
⚠️ Atenção
Dentro de um middleware montado em um prefixo, req.url vem sem o prefixo: em GET /api/produtos, o req.url vale /produtos. Isso é intencional (é o que permite ao Router ter caminhos relativos), mas estraga mensagens de log e de erro. Para o caminho completo, use sempre req.originalUrl.
Middlewares também podem ser passados como argumentos extras, antes do handler final:
JavaScript
// Confere se o :id é um número inteiro positivo antes de qualquer coisa.functionvalidarId(req,res,next){constid=Number(req.params.id);if(!Number.isInteger(id)||id<=0){returnres.status(400).json({erro:`O id "${req.params.id}" não é um número válido.`});}req.idProduto=id;// deixa pronto para o handler, já convertidonext();}// O middleware roda primeiro; o handler só é chamado se ele der next().router.get("/:id",validarId,(req,res)=>{constproduto=produtos.find((p)=>p.id===req.idProduto);if(!produto)returnres.status(404).json({erro:"Produto não encontrado."});res.json(produto);});
Duas coisas boas aconteceram aí. A conversão e a validação do id saíram de dentro do handler — que agora só cuida da regra dele. E req.idProduto mostra o padrão mais comum de comunicação entre middlewares: pendurar informação no req para quem vier depois. É exatamente assim que o middleware de autenticação da Aula 14 vai entregar req.usuario às rotas.
🔎 Por baixo do capô
O next() não é mágica: o Express guarda a lista de middlewares que casaram com a requisição e mantém um índice interno apontando para o atual. Chamar next() incrementa esse índice e invoca o próximo da lista. Chamar next()duas vezes no mesmo middleware avança duas casas e costuma produzir Error: Cannot set headers after they are sent to the client, porque dois handlers tentam responder. E chamar next(erro) — com um argumento — faz o Express pular todos os middlewares normais restantes e ir direto para o primeiro middleware de erro registrado à frente.
Cinco rotas em um server.js são confortáveis. Quinze já obrigam a rolar a tela para achar coisa. Quarenta, com validações e regras no meio, é um arquivo que ninguém quer abrir — e em que duas pessoas nunca conseguem trabalhar ao mesmo tempo sem conflito no Git.
express.Router() cria um objeto que se comporta como um app em miniatura: aceita .get, .post, .use, middlewares — tudo igual. A diferença é que ele não escuta em porta nenhuma; ele é montado dentro do app principal, sob um prefixo.
routes/produtos.js
JavaScript
constexpress=require("express");constrouter=express.Router();constprodutos=require("../data/produtos.json");// Os caminhos aqui são RELATIVOS ao prefixo onde o router for montado.// Montado em "/api/produtos", este "/" atende GET /api/produtos.router.get("/",(req,res)=>{res.json(produtos);});router.get("/:id",(req,res)=>{constproduto=produtos.find((p)=>p.id===Number(req.params.id));if(!produto){returnres.status(404).json({erro:`Produto ${req.params.id} não encontrado.`});}res.json(produto);});module.exports=router;
constpath=require("node:path");constexpress=require("express");constprodutosRouter=require("./routes/produtos");constapp=express();constPORTA=process.env.PORT||3000;app.use(express.json());app.use(express.static(path.join(__dirname,"public")));app.use("/api/produtos",produtosRouter);// prefixo + routerapp.listen(PORTA,()=>{console.log(`Café Cerrado no ar em http://localhost:${PORTA}`);});
Leia esse arquivo em voz alta: "use o parser de JSON, sirva a pasta public, monte as rotas de produtos em /api/produtos, escute na porta". Ele virou um índice do projeto. Recurso novo — categorias, pedidos, avaliações — é um arquivo novo em routes/ e uma linha aqui.
Repare na linha const produtos = require("../data/produtos.json");. Ela é diferente do que fizemos na Aula 11:
Aula 11
Aula 12
await fs.readFile(...) a cada requisição
require(...) uma vez, na inicialização
Editar o JSON reflete na próxima requisição
O array vive em memória enquanto o servidor roda
Não dá para escrever nada
O POST consegue acrescentar itens ao array
Por que a troca? Porque hoje a API precisa receber um produto, e o array em memória é o lugar mais simples para colocá-lo. A consequência é honesta e precisa ser dita: o que você criar via POST desaparece quando o servidor reinicia. Na próxima aula, com fs/promises e persistência de verdade, isso é resolvido — e é justamente por resolver isso que aquela aula existe.
💡 Dica
O require de um arquivo .json lê e converte o arquivo de forma síncrona, e guarda o resultado em cache: pedir o mesmo arquivo em dois módulos diferentes devolve exatamente o mesmo array na memória, não duas cópias. Isso é ótimo aqui (todo mundo vê a mesma lista) e é uma armadilha clássica quando você espera cópias independentes.
cafe-cerrado-api/
├── data/
│ └── produtos.json # os dados
├── middlewares/
│ ├── erros.js # 404 da API + tratador de erros
│ └── registro.js # log de requisições
├── public/ # o site das Unidades 1 e 2
│ ├── css/
│ ├── img/
│ ├── js/
│ │ └── app.js
│ └── index.html
├── routes/
│ └── produtos.js # todas as rotas de /api/produtos
├── .gitignore
├── package.json
├── package-lock.json
├── server.js # só configuração e montagem
└── testes.http # requisições de teste, versionadas
Compare com a estrutura da Aula 11: as pastas não são enfeite. Cada nome responde a uma pergunta ("onde estão as rotas?", "onde está o log?") sem que ninguém precise abrir arquivo nenhum.
Hoje, se alguém pedir GET /api/pedidos — uma rota que não existe —, o Express responde com uma página HTML dizendo Cannot GET /api/pedidos. Para um navegador, tudo bem. Para um fetch esperando JSON, é péssimo: resposta.json() lança SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON, e o erro que aparece no console do usuário não tem nada a ver com o problema real.
A solução é um middleware registrado depois de todas as rotas de API:
middlewares/erros.js (primeira metade)
JavaScript
// Só chega aqui quem pediu algo em /api e não casou com nenhuma rota.functionnaoEncontradoApi(req,res){res.status(404).json({erro:`A rota ${req.method}${req.originalUrl} não existe nesta API.`,});}
JavaScript
// server.js — DEPOIS de app.use("/api/produtos", produtosRouter)app.use("/api",naoEncontradoApi);
E os caminhos que não começam com /api? Como o Café Cerrado é uma SPA (Aula 10), a resposta certa é devolver o index.html e deixar o roteador do front decidir o que mostrar. Para casar com "qualquer caminho", o Express 5 exige um curinga nomeado:
JavaScript
// server.js — depois do 404 da APIapp.get("/{*splat}",(req,res)=>{res.sendFile(path.join(__dirname,"public","index.html"));});
As chaves em /{*splat} tornam o trecho opcional, de modo que o padrão casa também com a raiz /. O nome splat é livre — poderia ser caminho — e o valor capturado fica em req.params.splat, como um array de segmentos.
⚠️ Atenção
No Express 4 escrevia-se app.get("*", handler). No Express 5 isso derruba o servidor na inicialização com TypeError: Missing parameter name at 1. A mudança veio da biblioteca de rotas (path-to-regexp), que passou a exigir nome em todo curinga. Se você copiar um app.get("*") de um tutorial, é este o erro que vai aparecer — e ele acontece antes mesmo de o servidor subir, o que ao menos é fácil de perceber.
O Express reconhece um middleware de erro pela quantidade de parâmetros: quatro, começando por err. Não é pelo nome nem pela posição — é pela aridade da função.
middlewares/erros.js (segunda metade)
JavaScript
// QUATRO parâmetros = middleware de erro. O "next" precisa existir mesmo sem uso.functiontratadorDeErros(err,req,res,next){// Log completo para o desenvolvedor: aparece no terminal do servidor.console.error(`[erro] ${req.method}${req.originalUrl}`);console.error(err);// JSON malformado no corpo: o próprio express.json() marca o erro com status 400.if(err.status===400&&err.type==="entity.parse.failed"){returnres.status(400).json({erro:"O corpo da requisição não é um JSON válido."});}// Resposta genérica para o cliente: nunca exponha detalhes internos.res.status(500).json({erro:"Erro interno do servidor."});}module.exports={naoEncontradoApi,tratadorDeErros};
JavaScript
// server.js — a ÚLTIMA linha antes do app.listenapp.use(tratadorDeErros);
O parâmetro next fica ali sem ser usado, e isso incomoda todo mundo na primeira vez. Ele é obrigatório: retire-o e a função passa a ter três parâmetros, o Express deixa de reconhecê-la como tratador de erros e passa a tratá-la como middleware comum — que nunca vai rodar, porque está registrada depois de todas as rotas.
O Express percorre a fila na ordem de registro. Quando algo lança um erro, ele pula todos os middlewares comuns restantes e procura o próximo middleware de erro à frente na fila — nunca para trás. Registrar o tratador antes das rotas o tornaria inalcançável.
Texto
requisição
│
▼
express.json() ─────► ok, next()
│
▼
registrarRequisicao ► ok, next()
│
▼
produtosRouter ─────► lançou um erro
│ │
│ o Express pula os middlewares comuns
│ e procura o próximo DE ERRO à frente
▼ ▼
naoEncontradoApi tratadorDeErros
(não roda) (roda: responde 500)
A ordem — rotas, depois 404, depois tratador de erros — não é questão de estilo. É a única ordem em que os três cumprem o papel deles.
No Express 5, um throw dentro de um handler async é capturado automaticamente e encaminhado ao tratador de erros. Você viu isso na Aula 11; agora dá para provar, porque o tratador existe:
JavaScript
// Rota de teste: derruba de propósito, para ver o tratador funcionando.// Acrescente-a TEMPORARIAMENTE ao routes/produtos.js, rode o curl abaixo e// apague-a em seguida — ela não entra no arquivo final do Mão na massa.router.get("/teste/erro",async(req,res)=>{thrownewError("Explosão proposital para testar o tratador de erros");});
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
{"erro":"Erro interno do servidor."}
E o terminal mostra a mensagem completa com a pilha de chamadas. Esse par — log detalhado no servidor, mensagem genérica ao cliente — é uma regra de segurança, não de organização. Um stack trace devolvido ao navegador entrega o caminho absoluto das suas pastas, os nomes dos seus arquivos e, muitas vezes, o trecho da consulta ao banco. É presente para quem estiver procurando uma brecha.
📌 Vale gravar
Três perguntas que valem a pena revisar sobre esta seção: (1) O que identifica um middleware de erro? Ter quatro parâmetros. (2) Por que ele precisa ser o último registrado? Porque o Express só procura tratadores de erro à frente na fila. (3) O que muda no Express 5 quanto a handlers async? Exceções e Promises rejeitadas passam a ser capturadas automaticamente, sem try/catch nem pacotes auxiliares.
🧩 Padrão de projeto em uso — Chain of Responsibility¶
A fila express.json() → registrarRequisicao → express.static → produtosRouter → naoEncontradoApi → tratadorDeErros é uma implementação do padrão comportamental Chain of Responsibility ("corrente de responsabilidade"): um pedido percorre uma corrente de manipuladores, e cada elo decide se trata o pedido e encerra, ou se o repassa ao próximo. Quem envia a requisição não sabe — e não precisa saber — qual elo vai atendê-la.
Os dois efeitos que você sente na prática vêm direto do padrão. Primeiro, a ordem é o desenho: cada elo só recebe o pedido se o anterior decidiu repassá-lo, e é por isso que trocar duas linhas de app.use muda o comportamento do servidor. Segundo, os elos são independentes: acrescentar autenticação na Aula 14 será encaixar mais um elo na corrente, sem tocar em nenhum dos outros.
Você já viu a mesma ideia no front-end, com outro nome: a propagação de eventos do DOM. Um clique sobe do <button> para o <div> e daí para o document, e qualquer listener no caminho pode tratar o evento e interromper a subida com stopPropagation(). next() e stopPropagation() resolvem o mesmo problema pelos dois lados: um manda seguir, o outro manda parar.
// Registra método, caminho, status e duração de cada requisição atendida.functionregistrarRequisicao(req,res,next){constinicio=Date.now();res.on("finish",()=>{constduracao=Date.now()-inicio;console.log(`${req.method}${req.originalUrl} → ${res.statusCode} (${duracao}ms)`);});next();}module.exports=registrarRequisicao;
// Middleware do 404 da API: registrado DEPOIS de todas as rotas de /api.functionnaoEncontradoApi(req,res){res.status(404).json({erro:`A rota ${req.method}${req.originalUrl} não existe nesta API.`,});}// Middleware de erro: reconhecido pelos QUATRO parâmetros. Vai por último.functiontratadorDeErros(err,req,res,next){console.error(`[erro] ${req.method}${req.originalUrl}`);console.error(err);if(err.status===400&&err.type==="entity.parse.failed"){returnres.status(400).json({erro:"O corpo da requisição não é um JSON válido."});}res.status(500).json({erro:"Erro interno do servidor."});}module.exports={naoEncontradoApi,tratadorDeErros};
constexpress=require("express");constrouter=express.Router();// Carregado uma vez, na inicialização. O array vive em memória enquanto o// servidor roda — a persistência em arquivo chega na próxima aula.constprodutos=require("../data/produtos.json");// Próximo id disponível: o maior id existente mais um.letproximoId=produtos.reduce((maior,p)=>Math.max(maior,p.id),0)+1;// Middleware de rota: valida e converte o :id antes de qualquer handler.functionvalidarId(req,res,next){constid=Number(req.params.id);if(!Number.isInteger(id)||id<=0){returnres.status(400).json({erro:`O id "${req.params.id}" não é um número válido.`});}req.idProduto=id;next();}// GET /api/produtos → lista completa, com filtro opcional por categoriarouter.get("/",(req,res)=>{const{categoria}=req.query;if(!categoria){returnres.json(produtos);}constfiltrados=produtos.filter((p)=>p.categoria.toLowerCase()===categoria.toLowerCase(),);res.json(filtrados);});// GET /api/produtos/:id → um produto, ou 404router.get("/:id",validarId,(req,res)=>{constproduto=produtos.find((p)=>p.id===req.idProduto);if(!produto){returnres.status(404).json({erro:`Produto ${req.idProduto} não encontrado.`});}res.json(produto);});// POST /api/produtos → cria um produto. Validação obrigatória no servidor.router.post("/",(req,res)=>{const{nome,categoria,preco,descricao,imagem}=req.body;constproblemas=[];if(typeofnome!=="string"||nome.trim().length<3){problemas.push("nome deve ser um texto com pelo menos 3 caracteres");}if(typeofcategoria!=="string"||categoria.trim()===""){problemas.push("categoria é obrigatória");}if(typeofpreco!=="number"||Number.isNaN(preco)||preco<=0){problemas.push("preco deve ser um número maior que zero");}if(problemas.length>0){returnres.status(400).json({erro:"Dados inválidos.",problemas});}constnovo={id:proximoId++,nome:nome.trim(),categoria:categoria.trim().toLowerCase(),preco,descricao:typeofdescricao==="string"?descricao.trim():"",imagem:typeofimagem==="string"?imagem.trim():"img/sem-foto.jpg",};produtos.push(novo);res.status(201).json(novo);});module.exports=router;
O GET /api/categorias da Aula 11 não pode sumir na refatoração: o <select> de categoria da SPA depende dele. Ele sai do server.js e vira o segundo Router do projeto — pequeno, mas com o mesmo formato dos demais.
routes/categorias.js
JavaScript
constexpress=require("express");constrouter=express.Router();// O MESMO array que routes/produtos.js carregou: o require guarda o módulo em// cache, então os dois arquivos enxergam o mesmo objeto na memória.constprodutos=require("../data/produtos.json");// As quatro categorias do cardápio, na ordem em que aparecem no site.// Esta lista estava no server.js da Aula 11; ela só mudou de arquivo.constCATEGORIAS=[{id:"cafes",nome:"Cafés"},{id:"geladas",nome:"Bebidas geladas"},{id:"salgados",nome:"Salgados"},{id:"doces",nome:"Doces"},];// GET /api/categorias → [{ id, nome }], só as categorias que têm produto hojerouter.get("/",(req,res)=>{constusadas=newSet(produtos.map((p)=>p.categoria));res.json(CATEGORIAS.filter((categoria)=>usadas.has(categoria.id)));});module.exports=router;
Doze linhas úteis, um recurso inteiro. É essa a promessa do express.Router: recurso novo é arquivo novo, e o server.js cresce uma linha.
constpath=require("node:path");constexpress=require("express");constprodutosRouter=require("./routes/produtos");constcategoriasRouter=require("./routes/categorias");constregistrarRequisicao=require("./middlewares/registro");const{naoEncontradoApi,tratadorDeErros}=require("./middlewares/erros");constapp=express();constPORTA=process.env.PORT||3000;// 1. Corpo JSON das requisições vira objeto em req.bodyapp.use(express.json());// 2. Log de tudo que chegaapp.use(registrarRequisicao);// 3. Arquivos estáticos: o site das Unidades 1 e 2app.use(express.static(path.join(__dirname,"public")));// 4. Rotas da API — um app.use por recursoapp.use("/api/produtos",produtosRouter);app.use("/api/categorias",categoriasRouter);// 5. Qualquer /api que não casou acima vira 404 em JSONapp.use("/api",naoEncontradoApi);// 6. Qualquer outro caminho devolve o index.html (a SPA cuida do resto)app.get("/{*splat}",(req,res)=>{res.sendFile(path.join(__dirname,"public","index.html"));});// 7. Tratador de erros: SEMPRE o últimoapp.use(tratadorDeErros);app.listen(PORTA,()=>{console.log(`Café Cerrado no ar em http://localhost:${PORTA}`);});
Sete blocos comentados, sete responsabilidades, nenhuma regra de negócio. Esse é o formato de server.js que o projeto vai manter até a Aula 16 — inclusive o fallback /{*splat}, que é o que mantém a SPA da Aula 10 funcionando quando alguém recarrega a página em /#/cardapio.
@base = http://localhost:3000### Listar todos os produtos (espera 200)GET {{base}}/api/produtos### Filtrar por categoria (espera 200 com só os cafés)GET {{base}}/api/produtos?categoria=cafes### Buscar um produto existente (espera 200)GET {{base}}/api/produtos/1### Buscar um produto inexistente (espera 404 em JSON)GET {{base}}/api/produtos/999### Id que não é número (espera 400)GET {{base}}/api/produtos/abacaxi### Listar as categorias (espera 200 com objetos {id, nome})GET {{base}}/api/categorias### Criar um produto válido (espera 201 + o recurso criado)POST {{base}}/api/produtosContent-Type: application/json{ "nome": "Suco de Cupuaçu", "categoria": "geladas", "preco": 10.5, "descricao": "Polpa batida na hora com água gelada e um fio de mel.", "imagem": "img/suco-cupuacu.jpg"}### Criar sem nome (espera 400 com a lista de problemas)POST {{base}}/api/produtosContent-Type: application/json{ "categoria": "doces", "preco": 10.5}### Preço como texto (espera 400)POST {{base}}/api/produtosContent-Type: application/json{ "nome": "Café gelado da casa", "categoria": "geladas", "preco": "dez reais"}### JSON malformado (espera 400 vindo do express.json)POST {{base}}/api/produtosContent-Type: application/json{ "nome": "Café",}### Rota de API que não existe (espera 404 em JSON)GET {{base}}/api/pedidos### Caminho fora da API (espera o index.html)GET {{base}}/qualquer-coisa
No VS Code, com o REST Client instalado, aparece um link Send Request acima de cada bloco ###. Clique e a resposta abre ao lado, com status, cabeçalhos e corpo.
💡 Dica@base = http://localhost:3000 é uma variável do REST Client. Quando o projeto for publicado (trilha Deploy), você troca uma linha para testar o servidor de produção. Alternativas ao REST Client: Postman e Insomnia, com interface gráfica. A vantagem de um testes.http é ele ser texto puro: entra no Git, aparece no diff, e quem clonar o repositório recebe a bateria de testes junto com o código.
npm run dev. O terminal mostra Café Cerrado no ar em http://localhost:3000.
Abra http://localhost:3000 e navegue pelo site. O terminal deve imprimir uma linha de log por arquivo carregado, com status e duração.
No testes.http, dispare os blocos de cima para baixo e confira os status: 200, 200, 200, 404, 400, 200, 201, 400, 400, 400, 404, 200.
Depois do POST que devolveu 201, dispare de novo o primeiro bloco: o "Suco de Cupuaçu" está na lista, com o id 11 — o seguinte ao do último produto do cardápio.
Pare o servidor (Ctrl+C), suba de novo e liste outra vez: o suco sumiu. É o comportamento esperado hoje — e o problema que a próxima aula resolve.
Peça curl -i http://localhost:3000/api/pedidos: a resposta é 404 com Content-Type: application/json, não uma página HTML.
Comente a linha app.use(express.json()); do server.js, salve e dispare o POST válido: veja o 500 e a mensagem TypeError: Cannot destructure property 'nome' of 'req.body' as it is undefined. no terminal. Descomente antes de seguir.
Commit:
Terminal
gitadd.
gitcommit-m"Rotas em Router, middlewares de log e erro, POST com validacao e testes.http"
gitpush
A1. Dada a cadeia abaixo, diga por quais middlewares passa cada uma destas três requisições: GET /css/estilo.css, GET /api/produtos/1, GET /api/avaliacoes.
A2. O que acontece com uma requisição que chega neste middleware? Descreva o que o usuário vê no navegador e o que aparece no terminal.
JavaScript
app.use((req,res,next)=>{console.log("passei por aqui");});
A3. Um colega registrou app.use(express.json())depois de app.use("/api/produtos", produtosRouter). O GET continua funcionando e o POST quebra. Explique por quê, em duas linhas.
A4. Qual destas quatro funções o Express trata como middleware de erro? Justifique.
A5. Dentro de um middleware montado com app.use("/api", fn), uma requisição para GET /api/produtos/3 chega com req.url valendo o quê? E req.originalUrl? Qual dos dois você usaria na mensagem do 404 e por quê?
A6. Complete o middleware para que ele bloqueie qualquer requisição cujo corpo seja maior que 10 campos, respondendo 400. Ele deve deixar passar todas as outras.
JavaScript
functionlimitarCampos(req,res,next){constquantidade=Object.keys(req.body??{}).length;// Escreva aqui a verificação e a resposta 400.next();}
B1. Produtos de uma categoria. Acrescente ao routes/categorias.js do Mão na massa a rota GET /api/categorias/:id/produtos, que devolve os produtos daquela categoria — sem tocar no server.js e sem duplicar o filtro que já existe em routes/produtos.js.
Resultado esperado: curl http://localhost:3000/api/categorias/cafes/produtos devolve os quatro cafés; curl http://localhost:3000/api/categorias/geladas/produtos devolve os dois itens gelados; uma categoria inexistente devolve [] com status 200.
Dica
O app.use("/api/categorias", categoriasRouter) já existe desde o Passo 5: dentro do router, o caminho novo é "/:id/produtos". O filtro é o mesmo produtos.filter((p) => p.categoria === req.params.id); se quiser evitar a duplicação de verdade, extraia a comparação para uma função em um arquivo compartilhado e chame-a nos dois routers.
B2. Middleware de autorização simulada. Escreva middlewares/chaveApi.js, que exige o cabeçalho X-Chave-Api: cafe-cerrado-2 em todas as requisições POST de /api. Sem o cabeçalho, responde 401 com { "erro": "..." }; com ele, chama next().
Resultado esperado: o POST do testes.http passa a devolver 401; acrescentando a linha X-Chave-Api: cafe-cerrado-2 ao bloco, volta a devolver 201. Os GET continuam funcionando sem o cabeçalho.
Dica
Cabeçalhos chegam sempre em minúsculas: req.headers["x-chave-api"]. Para agir só no POST, teste req.method !== "POST" e chame next() de imediato. Isto é um ensaio do middleware exigirLogin da Aula 14 — lá a chave vira um token de verdade.
B3. Validação extraída para middleware. Tire o bloco de validação de dentro do POST e transforme-o em um middleware validarProduto registrado como argumento da rota. O handler deve ficar com no máximo dez linhas.
Resultado esperado: os mesmos status de antes (201 e 400 com a lista de problemas), mas o handler do POST só monta o objeto e responde. O middleware fica em middlewares/validarProduto.js.
Dica
router.post("/", validarProduto, (req, res) => { ... }). O middleware pode até normalizar os dados (nome.trim()) e devolvê-los prontos em req.produtoValidado, poupando o handler.
B4. O testes.http completo. Amplie o arquivo para cobrir, além do que já existe, os casos: filtro por categoria inexistente, POST com preço negativo, POST com nome de dois caracteres e requisição a /api/produtos/0. Cada bloco deve ter, no título, o status esperado.
Resultado esperado: quatro blocos novos, todos disparados com sucesso e com o status previsto batendo com o obtido. Nenhum deles derruba o servidor.
Dica
/api/produtos/0 é o caso que separa Number.isInteger(id) de id > 0. Se a sua validação usar só isNaN, o zero passa — e passar zero significa procurar um produto que jamais existirá, gastando um 404 onde cabia um 400.
C1. Corrente completa com prioridade. Implemente um middleware medirLento que registre no console, em destaque, toda requisição que demore mais de 50 ms, e um middleware simularLatencia que — só quando a query string trouxer ?lento=1 — atrase a resposta em 300 ms antes de chamar next(). Depois, prove que a corrente funciona: mostre no terminal um GET /api/produtos normal e um GET /api/produtos?lento=1, e explique por que o simularLatencia precisa estar registrado antes das rotas e o medirLentoantes dele.
Resultado esperado: no terminal, GET /api/produtos → 200 (2ms) e GET /api/produtos?lento=1 → 200 (301ms) [LENTO]; a explicação da ordem em três linhas de comentário no topo do server.js.
Dica
Para atrasar sem travar o event loop, setTimeout(next, 300) — nunca um laço while contando tempo, que bloqueia o processo inteiro e faz o servidor parar de atender todo mundo. Para o medirLento, reaproveite o res.on("finish", ...) do middleware de log: a duração já está calculada ali. E lembre que req.query.lento chega como a string "1", não como número.
Duas pessoas abrem o cardápio ao mesmo tempo e o seu terminal cospe seis linhas de log embaralhadas: não dá para saber quais linhas pertencem a qual visita. Serviços de verdade resolvem isso dando um identificador único a cada requisição, que acompanha a requisição do começo ao fim e volta ao cliente em um cabeçalho — é assim que o suporte de uma empresa pede "me manda o id da requisição" e acha o problema em segundos. Dê um crachá a cada requisição do Café Cerrado.
Critérios de pronto
Um middleware registrado antes de todos os outros gera um identificador único e o guarda em req.id.
Todas as linhas de log daquela requisição — a de entrada e a de saída — começam com o mesmo identificador.
A resposta traz o identificador no cabeçalho X-Requisicao-Id, verificável com curl -i.
O tratador de erros inclui o identificador tanto no log do servidor quanto no JSON devolvido ao cliente.
Um parágrafo no README.md explica em que situação real esse identificador salva tempo.
Pistas
require("node:crypto").randomUUID() gera um identificador único sem instalar nada.
Um UUID inteiro polui o terminal; .slice(0, 8) é suficiente para distinguir requisições de uma aula.
res.setHeader("X-Requisicao-Id", req.id) precisa acontecer antes de qualquer res.json ou res.send — cabeçalhos não podem ser definidos depois do corpo enviado.
Devolver o identificador junto do erro é seguro (é um número aleatório, não revela nada) e é o que permite ao usuário reportar o problema de forma útil.
Um colega mexeu no server.js sem avisar e agora o site "não abre mais": a aba do navegador fica girando indefinidamente, o terminal não mostra erro nenhum e o processo Node continua vivo, sem consumir CPU. O curl também fica pendurado. Nenhum console.log que você acrescentar no handler da rota aparece. Este é o trecho alterado:
Antes de corrigir, diagnostique: por que o site abre e a API não? Ou o contrário? Prove o que está acontecendo antes de tocar no código.
Critérios de pronto
Um arquivo docs/diagnostico-pendente.md registra: o que a aba Network mostra (status e tempo), o que o curl -v mostra, e o que aparece (ou não) no terminal do servidor.
O diagnóstico explica, em três linhas, qual caminho de execução deixa a requisição pendurada e por quê.
A correção mantém o cabeçalho X-API-Versao nas rotas de API e devolve o comportamento normal a todas as requisições.
Um teste no testes.http prova que /api/produtos responde 200com o cabeçalho e que / responde 200 sem ele.
Uma frase no arquivo explica por que esse tipo de bug não gera nenhuma mensagem de erro.
Pistas
Leia o if com calma: em qual dos dois ramos o next() é chamado?
curl -v --max-time 5 http://localhost:3000/api/produtos desiste em cinco segundos e mostra exatamente onde travou.
Definir um cabeçalho não envia a resposta. Só res.json, res.send, res.end e companhia encerram o ciclo.
A correção certa não é acrescentar um res.end() no ramo do if — pense em qual das três coisas que um middleware pode fazer está faltando ali.
Hoje a validação do POST /api/produtos são vinte linhas de if dentro do handler. Na próxima aula chega o PUT, que precisa das mesmas regras. Na aula seguinte, o recurso de categorias. Copiar e colar esses if três vezes é a receita conhecida para o dia em que a regra mudar em dois lugares e ficar esquecida no terceiro. Escreva um middleware genérico que receba a descrição do que validar e devolva o middleware pronto — e prove que ele serve para recursos diferentes.
Critérios de pronto
middlewares/validar.js exporta uma função validar(regras) que retorna um middleware.
As regras de produto ficam declaradas em um objeto, fora do middleware: campo, tipo esperado, obrigatoriedade e uma condição extra opcional.
O POST /api/produtos usa validar(regrasProduto) e o handler fica com no máximo dez linhas.
Um 400 traz a lista de todos os problemas encontrados, não só o primeiro.
Um segundo recurso (categorias, avaliações, o que você preferir) usa o mesmo validar com outro objeto de regras, sem alterar uma linha do middleware.
Pistas
Uma função que devolve um middleware é o padrão de fábrica que o próprio Express usa: express.json() também é uma chamada que retorna a função (req, res, next).
Object.entries(regras) percorre campo por campo; acumule as mensagens em um array e responda uma vez só, no fim.
Isto é o padrão Strategy aplicado: o algoritmo (o conjunto de regras) é injetado de fora, e o middleware não sabe nada sobre produtos. É também a ideia por trás de bibliotecas como zod e Joi — depois de fazer o seu, vale ler a documentação de uma delas para comparar.
app.use(express.json()) parece uma linha mágica: o corpo cru vira objeto e ninguém pergunta como. Não é mágica — é um stream. O corpo de uma requisição HTTP chega em pedaços, e alguém precisa juntá-los, decidir quando acabou, verificar o Content-Type, converter e ainda impedir que uma requisição de 2 GB acabe com a memória do servidor. Escreva esse alguém, e depois compare o seu resultado com o original.
Critérios de pronto
middlewares/meuJson.js exporta uma função que, registrada no lugar de express.json(), faz o POST de produtos funcionar exatamente como antes.
O middleware só age quando o cabeçalho Content-Type é application/json; nos outros casos chama next() sem tocar em req.body.
Corpo com JSON inválido produz 400 com uma mensagem clara, sem derrubar o servidor.
Corpo maior que um limite configurável (por exemplo, 100 kB) é recusado com 413 Payload Too Large, e a conexão é encerrada antes de o restante ser lido.
Um documento docs/meu-json.md compara o seu middleware com o express.json() em pelo menos quatro aspectos, e explica o que o express.json() faz que o seu não faz.
Uma medição: curl enviando um corpo de 1 MB é recusado com 413, e o log mostra quantos bytes chegaram a ser lidos antes da recusa.
Pistas
req é um stream legível. O padrão é req.on("data", (pedaco) => {...}) acumulando em um array, req.on("end", () => {...}) para finalizar e req.on("error", next) para não engolir falhas de rede.
Acumule Buffer (não string) e junte no fim com Buffer.concat(pedacos).toString("utf-8") — concatenar strings pedaço a pedaço quebra caracteres acentuados que caíram na fronteira entre dois pedaços. Esse bug é sutil e vale muito descobri-lo na prática.
Some pedaco.length a cada evento data; ao passar do limite, responda 413 e chame req.destroy() para não continuar recebendo.
req.headers["content-type"] pode vir como application/json; charset=utf-8 — compare com .startsWith("application/json"), não com igualdade.
Para gerar 1 MB de corpo: node -e 'process.stdout.write(JSON.stringify({t:"x".repeat(1e6)}))' > grande.json e depois curl -X POST --data-binary @grande.json -H "Content-Type: application/json" http://localhost:3000/api/produtos.
No seu projeto autoral, aplique a mesma refatoração:
Crie routes/<seu-recurso>.js com um express.Router contendo todas as rotas do recurso, e deixe o server.js só com a montagem das peças.
Crie middlewares/registro.js com o log de método, caminho, status e duração.
Crie middlewares/erros.js com o 404 de API em JSON e o tratador de erros de quatro parâmetros, registrados na ordem certa.
Acrescente express.json() e implemente POST /api/<seu-recurso> com validação no servidor, devolvendo 201 no sucesso e 400 com a lista de problemas no erro.
Acrescente o curinga /{*splat} devolvendo o index.html, para o front continuar funcionando em qualquer caminho.
Crie o testes.http na raiz, cobrindo no mínimo: listagem, item existente, item inexistente (404), rota de API inexistente (404), POST válido (201) e POST inválido (400).
Critério de pronto: todos os blocos do testes.http disparam com o status esperado, o terminal mostra uma linha de log por requisição e o server.js tem menos de trinta linhas.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA — organização e estruturação da camada de back-end.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec — modularidade e código que precisa crescer sem virar espaguete.
ALVES, William P. Projetos de Sistemas Web. Érica — arquitetura de sistemas web em camadas.
Na próxima aula damos o último passo da arquitetura: a rota deixa de conter a lógica e passa a apenas apontar para um controlador. Com controllers/produtosController.js no lugar, o CRUD fica completo — PUT e DELETE se juntam ao GET e ao POST —, a busca por query string ganha corpo e, principalmente, o que você criar para de sumir: os dados passam a ser gravados de verdade em data/produtos.json com fs/promises.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 13 — Rotas e controladores
Nível 2 — Desenvolvimento Web · WebLab
Na Aula 12 as rotas do Café Cerrado saíram do server.js e ganharam arquivo próprio com express.Router. Mas a lógica continua morando dentro delas: a rota sabe o endereço e faz o serviço. Hoje damos o último passo da arquitetura do back-end — a rota vira um índice de duas palavras por linha, o controlador executa, e uma terceira camada guarda os dados em disco. No fim da aula a API do Café Cerrado tem CRUD completo, busca por query string, validação de verdade e produtos que sobrevivem ao reinício do servidor.
Na Aula 11 você criou o repositório cafe-cerrado-api, instalou o Express 5 e serviu o site da Unidade 2 pela pasta public/. Na Aula 12 o server.js virou uma montagem de peças: express.json(), middleware de log, express.static, o router de /api/produtos, o 404 da API e o tratador de erros. Hoje esvaziamos as rotas e distribuímos a lógica entre um controlador e um repositório.
Checklist antes de começar:
[ ] cafe-cerrado-api rodando com npm run dev e respondendo em http://localhost:3000.
[ ] GET http://localhost:3000/api/produtos devolvendo a lista de produtos em JSON.
[ ] POST /api/produtos com corpo JSON funcionando (mesmo que sem validação caprichada) — sinal de que express.json() está no lugar certo.
[ ] Extensão REST Client instalada no VS Code e o arquivo testes.http versionado no repositório.
Olhe o arquivo de rotas que você terminou na Aula 12. Ele provavelmente parece com isto:
JavaScript
// cafe-cerrado-api/routes/produtos.js — versão da Aula 12constexpress=require('express');constprodutos=require('../data/produtos.json');constrouter=express.Router();router.get('/',(req,res)=>{res.json(produtos);});router.get('/:id',(req,res)=>{constproduto=produtos.find((p)=>p.id===Number(req.params.id));if(!produto){returnres.status(404).json({erro:'Produto não encontrado.'});}res.json(produto);});module.exports=router;
Funciona. Com dois endpoints, funciona muito bem. O problema aparece quando o recurso cresce: some POST, PUT, DELETE, validação de cada campo, filtro por categoria, busca por nome, ordenação, e daqui a duas aulas a checagem de quem está logado. O arquivo de rotas passa de 20 para 200 linhas e você perde a resposta para uma pergunta simples: o que esta API oferece?
A separação que vamos fazer hoje responde a essa pergunta em cinco linhas. Ela tem nome — é o MVC aplicado ao servidor — e três camadas:
Camada
Arquivo
Responsabilidade
Rota
routes/produtos.js
Casar método + caminho com uma função. Nada além disso.
Controlador
controllers/produtosController.js
Ler a requisição, validar, decidir status e montar a resposta.
Repositório (dados)
data/repositorio.js
Ler e gravar os produtos. Não sabe que existe HTTP.
Três motivos práticos, em ordem de importância para você agora:
Navegabilidade. Abrir routes/produtos.js e ver cinco linhas é abrir o índice da API. Quem entra no projeto — inclusive você daqui a três semanas — descobre em dez segundos o que existe.
Testabilidade. Um controlador é uma função comum que recebe req e res. Dá para chamá-la sem subir servidor. Um repositório é uma função que devolve dados; dá para trocá-lo por outro sem tocar em nada acima.
Divisão de trabalho. Duas pessoas mexendo no mesmo arquivo de 200 linhas geram conflito de merge toda hora. Em arquivos separados por responsabilidade, cada uma trabalha no seu.
Há um quarto motivo, que só aparece mais tarde: quando você trocar o arquivo JSON por um banco de dados de verdade, só o repositório muda. Rotas, controladores e front-end continuam iguais. Essa é a promessa das camadas, e você vai comprová-la no Nível 3.
🔎 Por baixo do capô
O "M" do MVC (Model) é frequentemente confundido com "a tabela do banco". No desenho original, de Trygve Reenskaug nos anos 1970 para a linguagem Smalltalk, o Model é o domínio — os dados e as regras que valem sobre eles, independentemente de tela ou de protocolo. É por isso que o nosso repositorio.js não pode conter res.status(404): status HTTP é assunto do controlador, não do domínio. Se o repositório souber o que é HTTP, você não consegue reaproveitá-lo em um script de linha de comando, em um job agendado ou em um teste.
Todo controlador de API faz sempre as mesmas três coisas, nesta ordem:
Extrair o que veio na requisição: req.params, req.query, req.body, req.headers.
Decidir: os dados são válidos? O recurso existe? Quem pediu tem permissão (a partir da Aula 14)?
Responder: escolher o status, montar o corpo, devolver.
Quando um controlador seu passar de umas 25 linhas, quase sempre é porque o passo 2 cresceu — e o passo 2 é regra de negócio, que merece uma função à parte. Você vai fazer exatamente isso com a validação, na seção 5.
REST é o estilo arquitetural descrito por Roy Fielding em 2000 para explicar por que a web funciona em escala planetária. Na prática do dia a dia, ele virou um punhado de convenções sobre como URLs e métodos HTTP expressam operações. Segui-las não é frescura: é o que faz um desenvolvedor abrir sua API pela primeira vez e adivinhar corretamente como usá-la.
Operação
Método e caminho
Sucesso
Listar
GET /api/produtos
200 + array
Detalhar
GET /api/produtos/7
200 + objeto
Criar
POST /api/produtos
201 + objeto criado
Atualizar
PUT /api/produtos/7
200 + objeto atualizado
Excluir
DELETE /api/produtos/7
204 sem corpo
As regras que sustentam essa tabela:
Recursos são substantivos no plural: /produtos, /categorias, /pedidos. Nunca /criarProduto ou /listarProdutos — o verbo já está no método HTTP. Uma URL com verbo é o sintoma mais fácil de detectar de uma API que não é REST.
Parâmetro de rota identifica: /api/produtos/7 diz qual produto. É parte da identidade do recurso.
Query string refina: /api/produtos?categoria=doces&ordenar=preco diz como listar. Ela nunca muda qual recurso está sendo acessado, só a fatia devolvida.
Aninhamento expressa relação: GET /api/produtos/7/avaliacoes são as avaliações do produto 7. Use no máximo dois níveis; três já viram um labirinto.
O mesmo caminho serve a métodos diferentes: /api/produtos/7 responde a GET, PUT e DELETE. Isso é proposital — o recurso é um só; o que muda é a intenção.
2.1 Idempotência: a propriedade que quase ninguém explica¶
Um método é idempotente quando repeti-lo produz o mesmo estado final que executá-lo uma vez.
GET é idempotente e seguro (não muda nada). Peça mil vezes, o servidor continua igual.
PUT é idempotente: mandar o mesmo produto atualizado dez vezes deixa o produto no mesmo estado.
DELETE é idempotente quanto ao estado: depois da primeira exclusão o recurso não existe mais, e continuar excluindo não muda isso (a resposta, sim, muda para 404 — o estado, não).
POSTnão é idempotente: dez requisições criam dez produtos.
Isso não é trivia de prova. É a razão pela qual navegadores e proxies podem repetir automaticamente um GET que falhou por timeout, mas nunca repetem um POST — e é por isso que aquele aviso "não atualize a página, seu pedido está sendo processado" existe em site de compra.
📌 Vale gravar
Saiba dizer, com exemplo, a diferença entre método seguro (não altera estado: GET, HEAD, OPTIONS) e método idempotente (repetir não muda o resultado: os seguros mais PUT e DELETE). Todo método seguro é idempotente; o contrário não vale — DELETE é idempotente e não é seguro.
Pelo livro, PUTsubstitui o recurso inteiro: o que não vier no corpo deveria ser apagado. PATCH aplica uma alteração parcial: só os campos enviados mudam.
Na prática, uma quantidade enorme de APIs implementa PUT com semântica de PATCH, porque é mais cômodo para o front. Nós vamos fazer o mesmo — mas conscientemente, e o documento de contrato da API vai dizer isso com todas as letras. O desafio ⭐⭐ desta aula pede que você implemente os dois com a semântica correta e sinta a diferença.
🧠 Você sabia?
A tese de doutorado que definiu REST não descreve nenhuma API. Roy Fielding, um dos autores da especificação do HTTP/1.1, escreveu em 2000 um trabalho sobre estilos arquiteturais de software em rede, e o capítulo 5 — chamado "Representational State Transfer" — explica por que a própria web escala: recursos com identificadores únicos, comunicação sem estado, respostas cacheáveis, interface uniforme. O termo "API REST", que hoje aparece em toda vaga de emprego, é uma leitura bem mais estreita do que ele propôs. Fielding chegou a escrever, anos depois, um texto irritado dizendo que a maioria das "APIs REST" não é REST.
Uma API só é útil quando alguém consegue usá-la sem ler o seu código. Esse "alguém" pode ser um colega de equipe, um aplicativo móvel, ou você mesmo na Aula 15, escrevendo o front. O documento que torna isso possível chama-se contrato: para cada endpoint, qual método, qual caminho, o que vai no corpo, o que volta e com qual status.
Este é o contrato do recurso produto do Café Cerrado, que você vai implementar hoje e colar no README.md:
Método e caminho
Corpo da requisição
Resposta
GET /api/produtos
—
200 + array de produtos
GET /api/produtos/:id
—
200 + produto, ou 404
POST /api/produtos
produto sem id
201 + produto criado, ou 400
PUT /api/produtos/:id
campos a alterar
200 + produto atualizado, 400 ou 404
DELETE /api/produtos/:id
—
204 sem corpo, ou 404
Parâmetros de query string aceitos por GET /api/produtos:
Parâmetro
Exemplo
Efeito
q
?q=cafe
Busca no nome e na descrição, ignorando acento e caixa
categoria
?categoria=doces
Filtra por categoria exata
ordenar
?ordenar=preco
Ordena por preco, -preco (decrescente) ou nome
E o formato de um produto, que é o mesmo na entrada e na saída (menos o id, que o servidor gera):
JSON
{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"}
Escreva o contrato antes de programar, mesmo trabalhando sozinho. Ele obriga você a decidir os nomes e os status antes de estar com as mãos no código, e vira a fonte da verdade quando front e back discordam. Em projetos maiores, esse documento é gerado em um formato padronizado chamado OpenAPI — no Nível 3 você vai vê-lo funcionando com o Swagger.
Escolher o status certo é comunicação, não decoração. O front-end da Aula 15 vai tomar decisões olhando exclusivamente para o número: 201 limpa o formulário, 400 mostra os erros nos campos, 404 mostra "produto não encontrado", 401 manda fazer login.
Código
Nome
Quando devolver
200
OK
Consulta ou atualização bem-sucedida, com corpo.
201
Created
Recurso criado. Devolva o objeto criado e o cabeçalho Location.
204
No Content
Sucesso sem nada a dizer. O caso clássico é DELETE.
400
Bad Request
O cliente mandou dados inválidos ou incompletos.
404
Not Found
O recurso pedido não existe (ou a rota não existe).
409
Conflict
O pedido conflita com o estado atual (nome duplicado, por exemplo).
500
Internal Server Error
Deu errado do lado do servidor. Culpa sua, não do cliente.
A regra mnemônica das faixas: 2xx deu certo; 3xx procure em outro lugar; 4xx o cliente errou; 5xx o servidor errou. Um 500 em log de produção é sempre um bug esperando para ser corrigido — nunca use 500 para dizer "você digitou errado".
⚠️ Atenção
Devolver 200 com { "erro": "produto não encontrado" } no corpo é um erro de projeto comum e caro. O fetch do front só rejeita a Promise em falha de rede; para tudo mais é preciso olhar resposta.ok, que é calculado a partir do status. Se você mente no status, o front precisa abrir o corpo e adivinhar — e quando alguém consumir sua API com outra ferramenta, vai contar como sucesso o que foi falha.
🔬 Investigue
Com o servidor da Aula 12 rodando, execute no terminal: curl -i http://localhost:3000/api/produtos e depois curl -i http://localhost:3000/api/produtos/9999. A opção -i mostra a linha de status e os cabeçalhos antes do corpo. Anote três coisas: o status de cada resposta, o valor de Content-Type e o valor de Content-Length. Agora rode curl -I http://localhost:3000/api/produtos (-I faz um HEAD): o corpo some, mas o Content-Length continua lá. Por que o servidor calcularia o tamanho de um corpo que não vai enviar?
Na Unidade 2, a busca do cardápio do Café Cerrado filtrava um array já carregado no navegador com filter. Aquilo era correto para 12 produtos. Com 12 mil, baixar tudo para descartar 11.990 no cliente é desperdício de banda, de bateria e de tempo — e no celular do estudante que está no ônibus, isso se sente.
A partir de hoje o filtro mora no servidor, e o front só pede o que precisa:
JavaScript
// no front, na Aula 15constresposta=awaitfetch(`/api/produtos?q=${encodeURIComponent(termo)}`);
No Express, tudo que vem depois do ? chega pronto em req.query, já decodificado e transformado em objeto:
Três cuidados que separam uma listagem robusta de uma quebradiça:
Todo valor de query string é string.?limite=10 chega como '10', não como 10. Converta com Number() e valide antes de usar.
O parâmetro pode não vir.req.query.q é undefined quando ninguém buscou nada. Trate a ausência como "não filtre", nunca como "filtre por vazio".
O mesmo parâmetro pode vir repetido.?categoria=doces&categoria=cafes faz req.query.categoria virar um array. Se o seu código chama .toLowerCase() direto, ele quebra com TypeError: categoria.toLowerCase is not a function. Quem manda essa URL nem sempre é um usuário distraído; às vezes é alguém testando sua API.
⚠️ Atenção — mudou no Express 5
No Express 4 era possível escrever req.query.categoria = 'doces' dentro de um middleware para "normalizar" a entrada. No Express 5, req.query virou um getter: a propriedade é calculada na primeira leitura e não aceita atribuição. Se você tentar, o valor simplesmente não muda (ou estoura em modo estrito). Precisa de um valor tratado? Guarde em outra variável, ou pendure em req com outro nome: req.filtros = { categoria }.
O cliente digita cafe no celular, sem acento e sem maiúscula. O produto se chama Frappê de Café. Comparação literal não acha nada, e o usuário conclui que a cafeteria não vende café.
A solução é normalizar os dois lados da comparação com a mesma função:
JavaScript
functionnormalizar(texto){returnString(texto).normalize('NFD')// separa a letra do acento.replace(/[\u0300-\u036f]/g,'')// remove os acentos combinantes.toLowerCase().trim();}
normalize('NFD') decompõe é em e seguido de um acento combinante (um caractere invisível na faixa Unicode U+0300–U+036F). A expressão regular varre esses combinantes e os apaga, sobrando e. Mesma coisa para ç, ã, ü. É a mesma técnica que você usa para gerar slug de URL a partir de um título.
5. Validar no servidor: a única validação que conta¶
O required do HTML ajuda o usuário. O JavaScript do formulário ajuda mais ainda. Nenhum dos dois protege nada: qualquer pessoa abre o terminal e manda
sem nunca ter visto o seu formulário. Se a API aceitar, o produto entra no arquivo com preço negativo e o cardápio quebra para todo mundo.
Uma boa validação de API tem quatro propriedades:
Devolve 400, não 500. Dado ruim é culpa do cliente.
Diz qual campo está errado, para o front destacar o campo certo. Um {"erro": "dados inválidos"} genérico obriga o usuário a adivinhar.
Devolve todos os erros de uma vez, não um por requisição. Ninguém merece enviar o formulário cinco vezes.
Normaliza enquanto valida: corta espaços com trim(), converte "6.5" em 6.5, arredonda o preço para dois decimais. O que entra no arquivo já entra limpo.
O formato de resposta que vamos adotar em todo o projeto:
JSON
{"erro":"Dados inválidos.","detalhes":[{"campo":"nome","mensagem":"O nome precisa ter ao menos 3 caracteres."},{"campo":"preco","mensagem":"O preço precisa ser um número maior que zero."}]}
Em projetos maiores você usaria uma biblioteca de validação por esquema (Zod, Joi, express-validator). Escrever a validação à mão uma vez, como faremos hoje, é o que faz você entender o que essas bibliotecas automatizam.
💡 Dica
Valide e normalize no mesmo lugar, devolvendo um objeto novo com só os campos aceitos. Nunca faça produtos.push(req.body): isso deixa o cliente injetar qualquer campo no seu registro — inclusive um dono falso, que na Aula 16 vai virar problema de segurança de verdade.
6. Persistência: os dados precisam sobreviver ao Ctrl + C¶
Até a Aula 12, o routes/produtos.js carregava o data/produtos.json com um require e trabalhava sobre esse array em memória: o arquivo era lido uma vez, na inicialização, e o POST só empurrava o produto novo para dentro do array. Isso significa que reiniciar o servidor apaga tudo que foi criado — e o node --watch reinicia sozinho a cada arquivo salvo. Você cria três produtos, corrige uma vírgula, e eles somem.
A solução definitiva é um banco de dados; ela chega no Nível 3. Para o Café Cerrado, um arquivo JSON entrega o conceito de persistência com o que você já sabe: ler arquivo, converter texto em objeto, converter objeto em texto, gravar.
O módulo do Node para isso é o fs, e a versão que interessa é a de Promises:
JavaScript
constfs=require('node:fs/promises');
O prefixo node: explicita que o módulo é interno do Node, não um pacote do node_modules. É a forma recomendada desde o Node 18 e evita um ataque real chamado dependency confusion, em que alguém publica no npm um pacote com o nome de um módulo interno.
Duas armadilhas para tratar desde já:
O arquivo pode não existir na primeira execução. fs.readFile lança um erro com erro.code === 'ENOENT' (Error NO ENTry). Nesse caso, o certo é devolver uma lista vazia, não derrubar o servidor.
Gravar direto por cima é arriscado. Se o processo morrer no meio de um writeFile, o arquivo fica pela metade — e um JSON pela metade não é JSON. A técnica padrão é gravar em um arquivo temporário e depois renomeá-lo por cima do original: no mesmo sistema de arquivos, rename é atômico, ou seja, ou vale o conteúdo antigo inteiro ou o novo inteiro, nunca uma mistura.
🔎 Por baixo do capô
Por que rename é atômico e writeFile não? Porque writeFile copia bytes para dentro do arquivo, e uma queda no meio deixa metade nova e metade velha. Já rename só troca uma entrada de diretório: o nome produtos.json passa a apontar para outro conjunto de blocos que já está inteiro no disco. Essa é a mesma ideia que bancos de dados usam com o write-ahead log e que o Git usa ao gravar objetos. Um detalhe importante: isso só vale se o arquivo temporário estiver no mesmo sistema de arquivos — por isso criamos o .tmp ao lado do arquivo final, e não em /tmp.
O data/repositorio.js que você vai escrever no Mão na massa é uma implementação do padrão Repository, catalogado por Martin Fowler: uma camada que se comporta como uma coleção de objetos em memória e esconde completamente de onde os dados vêm.
O contrato do nosso repositório tem três funções: lerTodos(), salvarTodos(lista) e proximoId(lista). Nenhuma delas menciona arquivo, caminho, JSON ou fs. Isso é proposital — é o que permite, no futuro, trocar o corpo dessas funções por consultas SQL sem que o controlador perceba.
O ganho fica visível quando você faz a conta do que muda em cada cenário:
Mudança
O que precisa ser reescrito
Trocar JSON por MySQL
Só data/repositorio.js
Adicionar campo estoque ao produto
Validação no controlador
Trocar /api/produtos por /api/v2/produtos
Uma linha no server.js
O preço do padrão é um arquivo a mais e uma indireção a mais para ler. Em um projeto de duas telas isso pode ser exagero; em um projeto que vai crescer por um semestre inteiro, paga-se sozinho na terceira semana.
💻 Mão na massa — CRUD completo da API do Café Cerrado¶
Objetivo desta prática: sair de duas rotas de leitura e chegar a cinco endpoints com validação, busca e persistência em disco. Trabalhe dentro de cafe-cerrado-api, com npm run dev rodando em um terminal.
Passo 1 — Os dados saem do código e vão para o disco¶
O data/produtos.json existe desde a Aula 11 e não muda hoje: são os mesmos dez produtos do cardápio, com os mesmos ids, nomes, preços e categorias. O que muda é quem o lê e quem escreve nele — até a Aula 12 o arquivo era carregado uma vez por um require e o resto acontecia na memória; a partir de hoje toda leitura e toda escrita passam por um repositório.
Confira que o seu está exatamente assim. Note que ele é só dados: nenhum module.exports, nenhuma vírgula sobrando, aspas duplas em todas as chaves — é JSON, não JavaScript.
cafe-cerrado-api/data/produtos.json
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
Agora o repositório. Ele é a única parte do sistema que sabe que existe um arquivo.
cafe-cerrado-api/data/repositorio.js
JavaScript
// Camada de acesso a dados do Café Cerrado.// Só esta camada sabe que os produtos moram em um arquivo JSON.constfs=require('node:fs/promises');constpath=require('node:path');constARQUIVO=path.join(__dirname,'produtos.json');// Lê o arquivo inteiro e devolve um array de produtos.// Se o arquivo ainda não existe, começa vazio em vez de estourar.asyncfunctionlerTodos(){try{consttexto=awaitfs.readFile(ARQUIVO,'utf-8');returnJSON.parse(texto);}catch(erro){if(erro.code==='ENOENT'){return[];}throwerro;}}// Grava a lista inteira. Escreve primeiro em um arquivo temporário e// depois renomeia: assim nunca sobra um JSON pela metade no disco.asyncfunctionsalvarTodos(lista){consttemporario=`${ARQUIVO}.tmp`;awaitfs.writeFile(temporario,JSON.stringify(lista,null,2),'utf-8');awaitfs.rename(temporario,ARQUIVO);}// Próximo id disponível: o maior existente mais um.functionproximoId(lista){returnlista.reduce((maior,produto)=>Math.max(maior,produto.id),0)+1;}module.exports={lerTodos,salvarTodos,proximoId};
Repare no path.join(__dirname, 'produtos.json'). Se você escrevesse './data/produtos.json', o caminho seria resolvido a partir do diretório de onde você rodou o node, não de onde o arquivo está. Rodar npm run dev de dentro de outra pasta quebraria tudo. __dirname é a pasta do arquivo atual e resolve isso de vez.
constrepositorio=require('../data/repositorio');// A mesma lista branca de sempre: as quatro categorias do cardápio.constCATEGORIAS=['cafes','geladas','salgados','doces'];// Deixa o texto comparável: sem acento, sem maiúscula, sem espaço nas pontas.functionnormalizar(texto){returnString(texto).normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().trim();}// GET /api/produtos?q=cafe&categoria=cafes&ordenar=precoexports.listar=async(req,res)=>{const{q,categoria,ordenar}=req.query;letlista=awaitrepositorio.lerTodos();if(typeofcategoria==='string'&&categoria!==''){constalvo=normalizar(categoria);lista=lista.filter((produto)=>normalizar(produto.categoria)===alvo);}if(typeofq==='string'&&q!==''){consttermo=normalizar(q);lista=lista.filter((produto)=>normalizar(produto.nome).includes(termo)||normalizar(produto.descricao).includes(termo),);}if(ordenar==='preco'){lista=[...lista].sort((a,b)=>a.preco-b.preco);}elseif(ordenar==='-preco'){lista=[...lista].sort((a,b)=>b.preco-a.preco);}elseif(ordenar==='nome'){lista=[...lista].sort((a,b)=>a.nome.localeCompare(b.nome,'pt-BR'));}res.json(lista);};// Converte o :id da rota. Se não for um inteiro positivo, já responde 400// e devolve null — os três handlers de id usam esta mesma checagem.functionidDaRota(req,res){constid=Number(req.params.id);if(!Number.isInteger(id)||id<=0){res.status(400).json({erro:'O id precisa ser um número inteiro positivo.'});returnnull;}returnid;}// GET /api/produtos/7exports.obter=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constproduto=lista.find((item)=>item.id===id);if(!produto){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}res.json(produto);};
Três decisões que valem comentário:
typeof categoria === 'string' protege contra a query string repetida (?categoria=a&categoria=b), que chegaria como array e quebraria o normalizar.
[...lista].sort(...) ordena uma cópia. sort altera o array original, e o original aqui veio direto do arquivo — ordenar no lugar não faria estrago hoje, mas é o tipo de efeito colateral que assombra depois.
Number('abc') é NaN, e Number.isInteger(NaN) é false. Por isso /api/produtos/abc devolve 400, e não uma busca silenciosa por undefined. A checagem virou a função idDaRota justamente porque obter, atualizar e remover precisam da mesma regra: se ela existisse só no obter, PUT /api/produtos/abacaxi devolveria 404 e a sua API passaria a mentir sobre a diferença entre "id inválido" e "produto inexistente".
Acrescente ao mesmo arquivo do controlador, logo abaixo de normalizar, a função de validação:
cafe-cerrado-api/controllers/produtosController.js (acrescente após normalizar)
JavaScript
// Valida e normaliza o corpo da requisição.// Com { parcial: true }, campos ausentes são ignorados (usado no PUT).functionvalidarProduto(corpo={},{parcial=false}={}){consterros=[];constdados={};if(corpo.nome!==undefined||!parcial){constnome=typeofcorpo.nome==='string'?corpo.nome.trim():'';if(nome.length<3){erros.push({campo:'nome',mensagem:'O nome precisa ter ao menos 3 caracteres.'});}else{dados.nome=nome;}}if(corpo.preco!==undefined||!parcial){constpreco=Number(corpo.preco);if(!Number.isFinite(preco)||preco<=0){erros.push({campo:'preco',mensagem:'O preço precisa ser um número maior que zero.'});}else{dados.preco=Math.round(preco*100)/100;}}if(corpo.categoria!==undefined||!parcial){constcategoria=typeofcorpo.categoria==='string'?normalizar(corpo.categoria):'';if(!CATEGORIAS.includes(categoria)){erros.push({campo:'categoria',mensagem:`A categoria precisa ser uma destas: ${CATEGORIAS.join(', ')}.`,});}else{dados.categoria=categoria;}}if(corpo.descricao!==undefined){dados.descricao=String(corpo.descricao).trim();}elseif(!parcial){dados.descricao='';}if(corpo.imagem!==undefined){dados.imagem=String(corpo.imagem).trim();}elseif(!parcial){dados.imagem='';}return{erros,dados};}
Agora as três operações de escrita, no fim do arquivo:
cafe-cerrado-api/controllers/produtosController.js (acrescente ao final)
JavaScript
// POST /api/produtosexports.criar=async(req,res)=>{const{erros,dados}=validarProduto(req.body);if(erros.length>0){returnres.status(400).json({erro:'Dados inválidos.',detalhes:erros});}constlista=awaitrepositorio.lerTodos();constnovo={id:repositorio.proximoId(lista),...dados};lista.push(novo);awaitrepositorio.salvarTodos(lista);res.status(201).location(`/api/produtos/${novo.id}`).json(novo);};// PUT /api/produtos/7exports.atualizar=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}const{erros,dados}=validarProduto(req.body,{parcial:true});if(erros.length>0){returnres.status(400).json({erro:'Dados inválidos.',detalhes:erros});}if(Object.keys(dados).length===0){returnres.status(400).json({erro:'Envie ao menos um campo para atualizar.'});}constatualizado={...lista[indice],...dados,id};lista[indice]=atualizado;awaitrepositorio.salvarTodos(lista);res.json(atualizado);};// DELETE /api/produtos/7exports.remover=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1){returnres.status(404).json({erro:`Produto ${id} não encontrado.`});}lista.splice(indice,1);awaitrepositorio.salvarTodos(lista);res.status(204).end();};
Detalhes que merecem sua atenção:
Em criar, o objeto novo é montado como { id: ..., ...dados }, e não a partir de req.body. Só os campos que a validação aprovou entram no arquivo.
Em atualizar, o id aparece de novo no fim de { ...lista[indice], ...dados, id }. Isso impede que alguém troque o id do produto mandando {"id": 999} no corpo — o último valor vence no espalhamento.
res.status(204).end(): 204 significa "sem conteúdo". Chamar res.json() depois de 204 é contraditório, e alguns clientes reclamam.
Nenhum controlador tem try/catch. No Express 5, um erro lançado dentro de uma função async é capturado e encaminhado automaticamente ao middleware de erro. Era exatamente isso que exigia .catch(next) no Express 4.
⚠️ Atenção
A ordem importa. Se você acrescentar depois uma rota fixa como router.get('/destaques', ...), ela precisa vir antes de router.get('/:id', ...). O Express testa na ordem de registro, e /:id casa com qualquer coisa — inclusive com a palavra destaques, que viraria req.params.id = 'destaques' e devolveria 400. Esse é o desafio ⭐ de hoje.
E o server.jsnão é reescrito: ele continua sendo o índice enxuto da Aula 12, com os middlewares em middlewares/, os dois routers montados e o fallback da SPA. Muda uma linha só — o 404 da API, que agora usa o curinga do Express 5 para cobrir também /api sozinho:
cafe-cerrado-api/server.js
JavaScript
constpath=require('node:path');constexpress=require('express');constprodutosRouter=require('./routes/produtos');constcategoriasRouter=require('./routes/categorias');constregistrarRequisicao=require('./middlewares/registro');const{naoEncontradoApi,tratadorDeErros}=require('./middlewares/erros');constapp=express();constPORTA=process.env.PORT||3000;// 1. Interpreta corpos JSON e coloca o resultado em req.body.app.use(express.json());// 2. Log de toda requisição, com status e duração (middlewares/registro.js).app.use(registrarRequisicao);// 3. Site estático do Café Cerrado (Unidades 1 e 2).app.use(express.static(path.join(__dirname,'public')));// 4. Recursos da API — um app.use por recurso.app.use('/api/produtos',produtosRouter);app.use('/api/categorias',categoriasRouter);// 5. Qualquer outra rota sob /api que não casou: 404 em JSON.// Mesmo middleware da Aula 12, agora montado com o curinga do Express 5.app.all('/api/{*splat}',naoEncontradoApi);// 6. Fora da API, devolve o index.html: quem resolve a rota é a SPA da Aula 10.app.get('/{*splat}',(req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});// 7. Tratador de erros: quatro parâmetros, sempre por último.app.use(tratadorDeErros);app.listen(PORTA,()=>{console.log(`Café Cerrado API em http://localhost:${PORTA}`);});
Três coisas que continuam exatamente como estavam, e não é por acaso:
process.env.PORT || 3000. Fixar 3000 no código funciona na sua máquina e quebra no primeiro deploy: os serviços de hospedagem definem a porta por variável de ambiente, e o Capítulo 05 da trilha Deploy cobra isso.
path.join(__dirname, 'public') em vez de 'public'. Pelo mesmo motivo do __dirname no repositório: caminho relativo é resolvido a partir de onde você rodou o node.
O fallback app.get('/{*splat}', …). Sem ele, recarregar a página em /#/cardapio funciona (o hash não vai ao servidor), mas qualquer caminho novo devolve o 404 padrão do Express em HTML — e o Checkpoint da Aula 12 deixa de valer.
O padrão '/api/{*splat}' é a sintaxe de curinga do Express 5. As chaves tornam o trecho opcional (então /api sozinho também casa) e *splat captura o resto do caminho em req.params.splat, que é um array de segmentos. No Express 4 isso se escrevia '/api/*' — se você encontrar essa forma em um tutorial antigo, saiba que ela não funciona mais.
💡 Dica
O log do passo 2 usa res.on('finish', ...) em vez de imprimir antes de next(). A diferença: finish dispara quando a resposta terminou de ser enviada, então você consegue registrar o status e a duração reais. Um log que só mostra o que entrou não ajuda a caçar lentidão.
Substitua o testes.http da Aula 12 por este roteiro completo. Cada bloco separado por ### vira um botão "Send Request" no VS Code.
cafe-cerrado-api/testes.http
HTTP
@base = http://localhost:3000/api### 1. Listar tudo (200, array com 10 itens)GET {{base}}/produtos### 2. Buscar por nome, sem acento e em minúscula (200, acha o "Frappê de Café")GET {{base}}/produtos?q=cafe### 3. Filtrar por categoria e ordenar do mais barato ao mais caro (200)GET {{base}}/produtos?categoria=doces&ordenar=preco### 4. Detalhar um produto existente (200)GET {{base}}/produtos/3### 5. Detalhar um id inexistente (404)GET {{base}}/produtos/9999### 6. Detalhar um id que nem é número (400)GET {{base}}/produtos/abacaxi### 7. Criar produto válido (201 + cabeçalho Location, id 11)POST {{base}}/produtosContent-Type: application/json{ "nome": "Suco de Cupuaçu", "categoria": "geladas", "preco": 11.5, "descricao": "Polpa batida com água gelada, sem açúcar.", "imagem": "img/suco-cupuacu.jpg"}### 8. Criar produto inválido (400 com três itens em detalhes)POST {{base}}/produtosContent-Type: application/json{ "nome": "Ab", "categoria": "sobremesa", "preco": -3}### 9. Atualizar só o preço (200, os outros campos continuam iguais)PUT {{base}}/produtos/11Content-Type: application/json{ "preco": 12.9}### 10. Atualizar produto inexistente (404)PUT {{base}}/produtos/9999Content-Type: application/json{ "preco": 1}### 11. Excluir (204, sem corpo)DELETE {{base}}/produtos/11### 12. Excluir de novo (404 — o recurso já não existe)DELETE {{base}}/produtos/11### 13. Rota de API que não existe (404 em JSON, não em HTML)GET {{base}}/pedidos
Com npm run dev rodando, execute os 13 blocos na ordem. O roteiro foi montado para contar uma história: o bloco 7 cria o produto de id 11 (o cardápio vai até o 10), o 9 altera esse mesmo produto, o 11 o exclui e o 12 prova que ele sumiu.
O resultado esperado, bloco a bloco:
Blocos
Status esperado
O que confirma
1 a 4
200
Leitura, busca, filtro e ordenação
5 e 6
404 e 400
Id inexistente × id malformado
7 e 8
201 e 400
Criação e validação: o bloco 8 viola as três regras de uma vez (nome curto, categoria fora da lista branca e preço negativo), então detalhes traz três itens
9 a 13
200, 404, 204, 404, 404
Atualização parcial, exclusão e 404 de API
Duas provas finais, e só então a prática está encerrada:
Abra data/produtos.json no editor depois do bloco 7. O produto novo está lá, com indentação de 2 espaços. Os dados saíram da memória e foram para o disco.
Pare o servidor com Ctrl + C, suba de novo e rode o bloco 1. O produto criado continua na lista. Era exatamente isso que não acontecia até a Aula 12.
A2. O cliente chama GET /api/produtos?q=. O que req.query.q vale nessa requisição, e por que a listagem devolve todos os produtos em vez de nenhum? Aponte a linha exata do controlador que decide isso.
A3. Verdadeiro ou falso, com uma linha de justificativa cada:
(a) No Express 5, exports.criar precisa de try/catch para que um erro de fs.writeFile chegue ao tratador de erros.
(b) req.query.categoria = 'doces' dentro de um middleware altera o filtro que o controlador vai ler.
(c) DELETE /api/produtos/2 repetido devolve o mesmo status nas duas vezes.
A4. Em atualizar, o objeto final é { ...lista[indice], ...dados, id }. Descreva o que acontece com o produto 3 se um cliente mandar PUT /api/produtos/3 com o corpo {"id": 99, "preco": 7} — e o que aconteceria se o id não estivesse repetido no fim.
A5. Alguém trocou lista = [...lista].sort(...) por lista.sort(...) no listar. O endpoint continua devolvendo a resposta certa? E o arquivo produtos.json no disco, muda? Justifique olhando de onde veio o array.
A6. Explique, em duas linhas, por que data/repositorio.js não pode conter res.status(404) — e o que quebraria se contivesse.
B1. Endpoint de contagem. Implemente GET /api/produtos/contagem, que devolve { "total": 10 } respeitando os mesmos filtros de q e categoria do listar.
Resultado esperado: GET /api/produtos/contagem?categoria=cafes devolve 200 com {"total":4}; sem query string, devolve o total geral. A rota nova não pode ser engolida por /:id.
Dica
Duas coisas: a rota fixa precisa ser registrada antes de router.get('/:id', ...), e a lógica de filtro está duplicada — extraia-a para uma função aplicarFiltros(lista, req.query) usada pelos dois controladores.
B2. Categorias com controlador próprio. O routes/categorias.js da Aula 12 responde GET /api/categorias, mas ainda com a lógica dentro do router. Aplique a ele a arquitetura de hoje: crie controllers/categoriasController.js, faça o GET /api/categorias devolver também a quantidade de produtos de cada categoria e acrescente GET /api/categorias/:slug/produtos.
Resultado esperado: GET /api/categorias devolve [{"slug":"cafes","total":4}, {"slug":"geladas","total":2}, {"slug":"salgados","total":2}, {"slug":"doces","total":2}]; GET /api/categorias/doces/produtos devolve só os doces; GET /api/categorias/inexistente/produtos devolve 404.
Dica
O app.use('/api/categorias', categoriasRouter) já está no server.js desde a Aula 12: o trabalho é mover o corpo dos handlers para controllers/categoriasController.js e ler os produtos pelo repositorio, não por require do JSON. Para contar por categoria, reduce sobre a lista de produtos acumulando em um objeto resolve em cinco linhas.
B3. Nome duplicado devolve 409. Hoje é possível criar dois produtos chamados "Cappuccino Sinop". Impeça isso no criar e no atualizar, devolvendo 409 Conflict com uma mensagem clara.
Resultado esperado: criar um produto com nome já existente (ignorando acento e caixa) devolve 409; renomear um produto para o nome de outro também; renomear um produto para o próprio nome continua funcionando.
Dica
Compare com normalizar dos dois lados. No atualizar, a checagem precisa ignorar o próprio produto: lista.some((p) => p.id !== id && normalizar(p.nome) === normalizar(dados.nome)).
B4. Contrato documentado. Escreva no README.md do cafe-cerrado-api a tabela de contrato da API: uma linha por endpoint, com método, caminho e o que devolve em caso de sucesso. Abaixo da tabela, um bloco JSON de exemplo do corpo do POST e um da resposta de erro 400.
Resultado esperado: alguém que nunca viu o projeto consegue usar a API só lendo o README, sem abrir código.
Dica
Cinco linhas na tabela (mais as que você criou nos itens B1 e B2). Não esqueça de documentar os parâmetros de query string aceitos por GET /api/produtos — é a parte que o front da Aula 15 mais vai usar.
C1. Paginação com metadados. Listar 12 produtos é fácil; listar 12 mil não é. Implemente paginação em GET /api/produtos com os parâmetros pagina (padrão 1) e limite (padrão 10, máximo 50), devolvendo um envelope com os dados e os metadados:
Valores inválidos (?pagina=0, ?limite=999, ?pagina=abc) não podem quebrar nem devolver 500: eles caem no padrão ou no teto. A paginação é aplicada depois do filtro e da ordenação — total é o total filtrado, não o total do arquivo.
Resultado esperado: ?limite=2&pagina=2 devolve os produtos 3 e 4 da lista ordenada, com totalPaginas: 3; ?pagina=99 devolve dados: [] e paginacao coerente; ?limite=999 devolve no máximo 50 itens.
Dica
const pagina = Math.max(1, Number(req.query.pagina) || 1) resolve 0, abc e ausência de uma vez, porque NaN || 1 é 1. Para o limite, Math.min(50, Math.max(1, Number(req.query.limite) || 10)). A fatia é lista.slice((pagina - 1) * limite, pagina * limite), e totalPaginas é Math.ceil(total / limite) — cuidado com o caso total === 0, em que o resultado deve ser 0 ou 1, e não NaN.
O Café Cerrado quer uma vitrine de destaques na página inicial. Você acrescenta router.get('/destaques', controlador.destaques) no fim do arquivo de rotas, sobe o servidor, abre http://localhost:3000/api/produtos/destaques e recebe:
JSON
{"erro":"O id precisa ser um número inteiro positivo."}
O controlador destaques nem foi chamado — coloque um console.log dentro dele para se convencer. Descubra por que o Express entregou a requisição para o controlador errado e conserte, deixando o endpoint de destaques funcionando: ele devolve os três produtos mais caros.
Critérios de pronto
GET /api/produtos/destaques devolve 200 com exatamente três produtos, do mais caro para o mais barato.
GET /api/produtos/3 continua devolvendo o produto 3, sem regressão.
Um comentário de duas linhas no routes/produtos.js explica a regra que você descobriu sobre a ordem de registro das rotas.
Um bloco novo no testes.http cobre o endpoint de destaques.
Pistas
Adicione console.log('entrou em obter', req.params.id) na primeira linha de exports.obter e repita a requisição. O que aparece no terminal?
Leia a página "Routing" da documentação do Express prestando atenção em uma frase: as rotas são avaliadas na ordem em que foram definidas, e a primeira que casar vence.
/:id é um curinga de um segmento. Que caminhos de um segmento não casam com ele?
A correção é mover uma linha. A prevenção é uma convenção: rotas fixas antes de rotas com parâmetro, sempre.
O PUT que você escreveu hoje mente: pelo RFC 9110, PUT substitui o recurso inteiro, então mandar {"preco": 12.9} deveria apagar nome, categoria e descrição. O nosso preserva — comportamento de PATCH usando o nome errado. Muitas APIs de mercado fazem isso, mas quase nenhuma documenta, e é aí que o cliente se machuca. Implemente os dois métodos com a semântica correta e documente a diferença.
Critérios de pronto
PUT /api/produtos/3 com corpo incompleto devolve 400 listando os campos obrigatórios ausentes; com corpo completo, substitui todos os campos (o id é preservado).
PATCH /api/produtos/3 com {"preco": 12.9} altera só o preço e devolve 200 com o produto inteiro.
PATCH com corpo {} devolve 400 com uma mensagem que explica o problema.
A tabela de contrato no README.md ganha as duas linhas, com uma frase dizendo o que cada método faz com os campos ausentes.
O testes.http prova a diferença: um PUT parcial que falha e um PATCH equivalente que funciona, lado a lado.
Pistas
A função validarProduto já tem a chave: o PUT usa { parcial: false } e o PATCH usa { parcial: true }.
Com parcial: false, o objeto salvo deve ser montado do zero — { id, ...dados } — e não espalhando o produto antigo por cima.
router.patch('/:id', controlador.remendar) é a linha nova no arquivo de rotas.
Antes de codificar, procure na especificação do HTTP (RFC 9110, seção sobre PUT) a frase que define substituição. Cole-a como comentário no controlador: ela justifica o 400.
A dona do Café Cerrado pediu um resumo do cardápio: quantos produtos existem, quanto custa o mais caro e o mais barato, qual o preço médio por categoria. Ela não sabe o que é JSON, mas o seu front vai saber. Construa GET /api/produtos/resumo sem fazer o cálculo virar um monstro de dez for aninhados — e sem duplicar nada que já existe no controlador.
Critérios de pronto
GET /api/produtos/resumo devolve 200 com total, precoMinimo, precoMaximo, precoMedio (duas casas) e um array porCategoria com slug, total e precoMedio de cada categoria.
O resumo respeita ?categoria= e ?q=, reaproveitando a mesma função de filtro do listar (nenhuma linha de filtro copiada e colada).
Com o cardápio vazio (renomeie produtos.json temporariamente), o endpoint devolve 200 com total: 0 e nenhum NaN no corpo.
Um bloco no testes.http e uma linha na tabela do README.md.
Pistas
reduce resolve os quatro agregados numéricos em uma passada; Math.min(...lista.map(...)) estoura com lista vazia, então trate esse caso antes.
Para agrupar por categoria sem bibliotecas: Object.groupBy existe no Node 22 e devolve um objeto com um array por chave.
Arredonde só na hora de responder: Number(media.toFixed(2)) devolve número, enquanto media.toFixed(2) devolve string — e string em campo numérico atrapalha o front.
Lembre-se do desafio ⭐: resumo é uma rota fixa.
⭐⭐⭐
⭐⭐⭐ Dois pedidos ao mesmo tempo, um produto perdido¶
nodeapibugperformance
Seu criar faz três coisas em sequência: lê o arquivo, acrescenta um item, grava o arquivo. Entre a leitura e a gravação existe uma janela de alguns milissegundos. Se duas requisições entrarem nessa janela juntas, as duas leem a mesma lista de 5 itens, cada uma acrescenta o seu produto com o mesmo id 6, e a segunda gravação apaga a primeira. Isso se chama lost update, e é a razão de existirem transações em bancos de dados. Prove que o bug existe na sua API, meça o estrago e conserte.
Critérios de pronto
Um script scripts/estresse.js dispara 50 POST simultâneos com Promise.all e imprime quantos produtos foram efetivamente gravados no arquivo. Antes da correção, o número é visivelmente menor que 50.
Uma tabela de três linhas no README.md registra a medição: produtos esperados, produtos gravados antes da correção, produtos gravados depois.
Depois da correção, as 50 requisições resultam em 50 produtos com ids únicos e sequenciais, sem nenhum id repetido.
A correção fica dentro do repositório: o controlador não muda uma linha.
Um comentário no repositorio.js explica em três linhas por que o Node, sendo de thread única, mesmo assim sofre desse problema.
Pistas
O Node executa JavaScript em uma thread só, mas await devolve o controle ao event loop: entre o await lerTodos() e o await salvarTodos() outra requisição roda inteirinha. Concorrência não é paralelismo.
A solução mais simples é uma fila de promessas: guarde a última operação em uma variável e encadeie a próxima com fila = fila.then(() => operacao()). Assim, cada escrita só começa quando a anterior terminou.
Exponha uma função atualizarComExclusividade(transformar) no repositório, que recebe uma função (lista) => novaLista, e faça criar, atualizar e remover passarem por ela.
Para o script de estresse, Array.from({ length: 50 }, (valor, i) => fetch(url, opcoes(i))) dentro de Promise.all basta; conte o resultado com fs.readFile depois de todas as respostas chegarem.
Meça também o tempo total: a fila serializa as escritas e isso custa. Vale a pena discutir no README se o custo compensa.
No seu projeto autoral, refatore a API para a arquitetura de hoje e complete o CRUD:
Separe routes/, controllers/ e data/repositorio.js, deixando o arquivo de rotas com uma linha por endpoint.
Implemente os cinco endpoints do seu recurso principal com os status corretos (200, 201, 204, 400, 404).
Persista em arquivo JSON com fs/promises, tratando ENOENT e gravando por arquivo temporário + rename.
Implemente busca e filtro por query string no listar, com normalização de acentos.
Atualize o testes.http com o roteiro completo (criar → listar → atualizar → detalhar → excluir → detalhar), incluindo pelo menos dois casos de erro.
Critério de pronto: clonando o repositório em uma pasta limpa, npm install && npm run dev sobe a API; os 13 blocos equivalentes do seu testes.http devolvem os status esperados; e um produto criado continua existindo depois de reiniciar o servidor.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — capítulo sobre APIs REST.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — organização em camadas e lógica de negócio.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — modelagem das operações de um sistema.
Sua API já faz tudo — e é exatamente esse o problema: qualquer pessoa com um terminal pode excluir o cardápio inteiro. Na próxima aula você resolve a pergunta que toda aplicação real precisa responder antes de ir ao ar: quem está fazendo esta requisição? Vamos delegar o login ao Google, verificar o token no servidor e proteger as rotas de escrita. Crie antes da aula uma conta no Google Cloud Console com o seu Gmail — isso poupa quinze minutos da prática.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 14 — Autenticação com Google (OAuth 2.0)
Nível 2 — Desenvolvimento Web · WebLab
Sua API está completa e tem um problema grave: qualquer pessoa com um terminal pode apagar o cardápio inteiro do Café Cerrado. curl -X DELETE http://localhost:3000/api/produtos/1 e pronto. Hoje a aplicação aprende a responder a pergunta que separa um exercício de um sistema de verdade — quem está fazendo esta requisição? — sem que você precise guardar a senha de ninguém.
Na Aula 13 a API do Café Cerrado ganhou controladores, CRUD completo e persistência em arquivo JSON — está funcional, organizada e escancarada. Hoje colocamos uma porta na frente das operações de escrita: o front delega o login ao Google, o servidor verifica quem chegou e só então deixa criar, editar ou excluir.
Checklist antes de começar:
[ ] cafe-cerrado-api com os cinco endpoints de /api/produtos respondendo (Aula 13).
[ ] O site do Café Cerrado sendo servido por express.static(path.join(__dirname, 'public')) em http://localhost:3000.
[ ] testes.http funcionando com a extensão REST Client.
[ ] Uma conta Google (Gmail) para usar no Google Cloud Console — se possível, já com o console aberto.
[ ] Git configurado no repositório, com node_modules/ já ignorado no .gitignore.
Duas perguntas diferentes, feitas em momentos diferentes, com respostas diferentes:
Autenticação responde "quem é você?". O resultado é uma identidade: maria@gmail.com.
Autorização responde "o que você pode fazer?". O resultado é uma permissão: "pode criar produtos, não pode excluir os dos outros".
Confundir as duas gera bugs difíceis de enxergar. O HTTP tem um status para cada caso, e os nomes atrapalham:
Status
Nome oficial
Significa de verdade
401
Unauthorized
Não sei quem você é. Faça login.
403
Forbidden
Sei quem você é, e você não pode fazer isso.
Sim, o 401 se chama "Unauthorized" e quer dizer "não autenticado" — um erro de nomenclatura que ficou na especificação do HTTP para sempre. Guarde pelo comportamento, não pelo nome: 401 é convite para o front mostrar a tela de login; 403 é para mostrar "você não tem permissão", porque fazer login de novo não vai resolver.
Hoje implementamos a autenticação e o 401. Na Aula 16, cada produto ganha um dono e aparece o primeiro 403.
⚠️ Atenção
Esconder o botão "Excluir" para quem não está logado não é autorização. É cortesia com o usuário. Qualquer pessoa abre o DevTools, remove o atributo hidden e clica; ou pula o seu site inteiro e manda a requisição pelo curl. A proteção real acontece no servidor, em toda requisição sensível, sem exceção. Interface é conveniência; servidor é segurança.
A alternativa óbvia seria criar uma tabela de usuários com e-mail e senha. Pense no que isso obriga você a fazer direito, sem errar nenhuma vez:
Nunca guardar a senha em texto puro — usar uma função de hash lenta e com sal (bcrypt, scrypt, argon2).
Implementar "esqueci minha senha" com token de uso único e expiração.
Limitar tentativas de login para dificultar ataque de força bruta.
Detectar vazamentos, avisar usuários, forçar troca de senha.
Lidar com o fato de que as pessoas reutilizam a mesma senha em dez sites — o vazamento do seu projeto de faculdade vira o problema do banco delas.
É muita responsabilidade para um cardápio de cafeteria. A indústria resolveu isso delegando a autenticação a um provedor de identidade que já faz tudo isso em escala: Google, Microsoft, Apple, GitHub. É o "Entrar com Google" que você usa todo dia sem pensar.
Dois protocolos sustentam isso:
OAuth 2.0 (RFC 6749) é um protocolo de autorização delegada: permite que um aplicativo acesse recursos em nome do usuário — ler a agenda, enviar um e-mail — sem nunca conhecer a senha dele. Ele não foi criado para dizer quem o usuário é.
OpenID Connect (OIDC) é uma camada de identidade construída em cima do OAuth 2.0. Ela acrescenta um artefato que o OAuth sozinho não tem: o ID token, um documento assinado pelo provedor dizendo "esta pessoa é maria@gmail.com, e eu, Google, garanto".
O que vamos usar hoje é OIDC, no fluxo mais simples que existe para aplicação web: o Google devolve o ID token direto ao navegador, e o navegador o entrega ao nosso servidor, que verifica a assinatura.
Repare no que não acontece: a senha do usuário nunca passa pelo seu servidor, nunca aparece no seu código e nunca vira sua responsabilidade. O login acontece inteiramente no domínio do Google. Você recebe só o resultado, assinado.
🧠 Você sabia?
A biblioteca JavaScript que a maior parte dos tutoriais na internet ainda ensina — gapi.auth2, do "Google Sign-In for Websites" — foi descontinuada e desligada em 2023. Código escrito com ela simplesmente não funciona mais: o botão não aparece e o console reclama de idpiframe_initialization_failed. A substituta é a Google Identity Services (GSI), que usamos hoje. Isso é um bom lembrete de um fato da profissão: a idade do tutorial importa mais que o número de estrelas dele. Antes de copiar uma solução de autenticação da internet, procure a data e confira na documentação oficial se a API ainda existe.
Cabeçalho: qual algoritmo assinou (RS256) e qual chave foi usada (kid).
Dados: as informações do usuário e do próprio token, chamadas claims.
Assinatura: o resultado de assinar cabeçalho + dados com a chave privada do Google.
Os dois primeiros blocos são apenas base64url — uma codificação, não uma criptografia. Qualquer pessoa lê o conteúdo. Isso não é falha: o JWT não foi feito para esconder, foi feito para provar autoria.
As claims que interessam em um ID token do Google:
Decodificar é desfazer o base64url e ler o JSON. Faz-se no navegador, em uma linha, e não prova absolutamente nada — eu posso escrever um JSON dizendo que sou diretoria@exemplo.br, codificar em base64url e mandar para a sua API.
Verificar é conferir a assinatura com a chave pública do Google e checar três coisas: que iss é o Google, que exp ainda não passou e que aud é exatamente o seu Client ID. Só a chave privada do Google produz uma assinatura que bate com a chave pública dele — e essa chave privada nunca sai dos servidores do Google.
A checagem de aud é a que mais gente esquece, e é a mais perigosa. Sem ela, um token legítimo emitido para outro aplicativo qualquer é aceito pela sua API. Como qualquer pessoa pode criar um aplicativo no Google e obter tokens válidos dos próprios usuários, esquecer o aud transforma sua verificação em teatro.
A biblioteca google-auth-library faz as quatro checagens em uma chamada, desde que você passe o audience. É por isso que a usamos em vez de escrever a verificação à mão.
🔬 Investigue
Abra https://myaccount.google.com em uma aba para garantir que está logado. Depois, no console do navegador, cole atob('eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJlbWFpbCI6ImV4ZW1wbG9AZ21haWwuY29tIn0') e veja um JSON aparecer. Você acabou de "abrir" um pedaço de token sem nenhuma chave. Agora responda por escrito, em duas linhas: se ler é tão fácil assim, o que exatamente impede alguém de inventar um token dizendo que é você? No fim da aula, quando tiver um ID token de verdade, repita a experiência com ele: atob(token.split('.')[1]) mostra as suas próprias claims.
🔎 Por baixo do capô
Como o verifyIdToken conhece a chave pública do Google? Ele busca em https://www.googleapis.com/oauth2/v3/certs, um endereço público que devolve o conjunto de chaves em uso, cada uma com o seu kid. O cabeçalho do token diz qual kid usar; a biblioteca escolhe a chave certa e confere a assinatura. As chaves são trocadas de tempos em tempos, e a biblioteca respeita o Cache-Control da resposta para não buscar a lista a cada requisição. É por isso que a verificação funciona sem você configurar chave nenhuma — e também por isso que ela precisa de internet.
O que acontece entre o clique no botão e o produto criado:
O front pede ao Google que desenhe o botão "Entrar com o Google", informando o Client ID da aplicação.
O usuário clica; o Google abre a própria tela de login (ou reconhece uma sessão já ativa). A senha é digitada no domínio do Google, nunca no seu.
O Google devolve ao seu front um ID token assinado, entregue a uma função de callback que você registrou.
O front envia esse token ao seu back-end, em POST /api/auth/google.
O back-end verifica a assinatura, o emissor, a validade e o aud com google-auth-library. Deu certo: o Google garante a identidade.
O back-end cria a sua própria sessão — um token curto, assinado por você — e devolve ao front junto com nome, e-mail e foto.
Dali em diante, toda requisição de escrita leva o cabeçalho Authorization: Bearer <token da sessão>, e o middleware exigirLogin confere antes de deixar o controlador rodar.
Sua aplicação vai precisar de dois valores de configuração:
GOOGLE_CLIENT_ID — identifica a sua aplicação para o Google.
SESSAO_SEGREDO — a chave com que você assina os seus tokens de sessão.
O primeiro não é segredo: ele aparece no HTML de qualquer site que use login do Google, e tem que aparecer mesmo, porque o navegador precisa dele. O segundo é segredo de verdade: quem o tiver consegue forjar sessões válidas da sua aplicação e entrar como qualquer usuário.
Mesmo assim, os dois vão para o mesmo lugar — um arquivo .env fora do Git — por um motivo prático: configuração que muda entre máquinas não pertence ao código. O Client ID do seu computador é diferente do Client ID do servidor de produção; o segredo de sessão também. Deixá-los fora do código é o que permite publicar o mesmo repositório em qualquer lugar.
⚠️ Atenção
Um segredo commitado no GitHub é considerado vazado para sempre, mesmo que você apague no commit seguinte: o histórico do Git guarda tudo, e existem robôs varrendo repositórios públicos em busca de chaves — em minutos, não em dias. Se acontecer com você, a resposta correta não é apagar o arquivo: é revogar a credencial no console do provedor e gerar outra. Coloque .env no .gitignoreantes do primeiro git add.
💡 Dica
Commite um .env.exemplo com as chaves e valores fictícios. Ele documenta o que a aplicação precisa para rodar e evita a mensagem "clonei o projeto e não funciona". O README.md só precisa dizer: copie .env.exemplo para .env e preencha.
6. Sessão própria: por que não usar o token do Google em tudo¶
Uma dúvida honesta aparece aqui: já que o front tem um ID token do Google, por que não mandar esse token em toda requisição e verificar de novo no servidor a cada vez?
Funciona, e muitos tutoriais fazem exatamente isso. Mas tem três problemas:
Custo. Cada verificação consulta as chaves públicas do Google. Há cache, mas ainda assim você amarra o funcionamento da sua API à disponibilidade de um serviço externo — em toda requisição.
Validade curta. O ID token do Google expira em cerca de uma hora e não pode ser renovado sem interação do usuário nesse fluxo. Sua sessão passa a durar o que o Google decidir.
Informação sua. Quando o projeto crescer, a sessão precisa carregar dados que só a sua aplicação conhece: papel de administrador, preferências, plano contratado. Nada disso cabe em um token emitido pelo Google.
Por isso o padrão é: verifique o token do provedor uma vez, no login, e emita a sua própria sessão. É o que faremos, com um token bem simples de dois blocos:
Texto
<dados em base64url>.<assinatura HMAC-SHA256>
Assinar com HMAC significa calcular um resumo criptográfico dos dados misturado com o seu segredo. Sem o segredo, ninguém consegue produzir a assinatura correta — então ninguém consegue alterar os dados sem invalidar o token. É o mesmo princípio do JWT do Google, com um algoritmo simétrico (a mesma chave assina e confere), que é o suficiente quando quem assina e quem confere são o mesmo servidor.
Você vai escrever isso com o módulo node:crypto, que já vem no Node — sem instalar nada.
📌 Vale gravar
Saiba explicar por que a assinatura de um token não pode ser comparada com ===. Comparar strings byte a byte com parada no primeiro byte diferente vaza informação pelo tempo de resposta: um atacante que meça milhares de tentativas consegue descobrir a assinatura correta byte a byte. A defesa é comparar em tempo constante — em Node, crypto.timingSafeEqual.
🧩 Padrão de projeto em uso: Chain of Responsibility¶
O middleware do Express, que você usa desde a Aula 12, é uma implementação do padrão Chain of Responsibility (GoF): uma requisição percorre uma corrente de manipuladores, e cada um decide se trata, se repassa (next()) ou se interrompe respondendo.
A cadeia que a rota POST /api/produtos terá ao fim desta aula:
Ordem
Elo
Decisão
1
express.json()
Transforma o corpo em objeto e repassa
2
Log
Registra e repassa
3
exigirLogin
Repassa com req.usuario, ou responde 401 e interrompe
4
controlador.criar
Valida, grava e responde
O ganho do padrão é que cada elo ignora completamente os outros. O exigirLogin não sabe que existe um controlador de produtos; o controlador não sabe como a identidade chegou em req.usuario — se veio do Google, de um login com senha ou de um teste automatizado. Trocar o provedor de identidade um dia significará reescrever um arquivo, e nenhum controlador.
Esse desacoplamento tem um preço conhecido: como qualquer elo pode interromper a corrente, esquecer um next() faz a requisição travar sem erro nenhum. É o bug mais silencioso do Express.
💻 Mão na massa — login com Google no Café Cerrado¶
Ao fim desta prática, o site do Café Cerrado terá um botão de login do Google, e as rotas de escrita da API só funcionarão para quem estiver autenticado.
Passo 1 — Criar o projeto no Google Cloud Console¶
No seletor de projetos, no topo da página, escolha Novo projeto. Dê o nome cafe-cerrado e crie.
Confirme, no seletor, que o projeto novo é o projeto ativo. Configurar credenciais no projeto errado é o tropeço mais comum deste passo.
No menu, vá em APIs e serviços → Tela de permissão OAuth. Escolha o tipo Externo e preencha: nome do app (Café Cerrado), e-mail de suporte e e-mail do desenvolvedor. Salve.
Ainda na tela de permissão, adicione o seu próprio e-mail em Usuários de teste. Enquanto o app estiver como "em teste", só usuários dessa lista conseguem entrar — e isso é ótimo para um trabalho de faculdade.
Vá em Credenciais → Criar credenciais → ID do cliente OAuth. Tipo de aplicativo: Aplicativo da Web. Nome: cafe-cerrado-local.
Em Origens JavaScript autorizadas, adicione exatamente http://localhost:3000. Sem barra no final.
Crie e copie o Client ID, no formato 000000000000-letras.apps.googleusercontent.com.
⚠️ Atenção
"Origem" é o trio protocolo + host + porta. http://localhost:3000 e http://127.0.0.1:3000 são origens diferentes para o Google, e http://localhost:5500 (a porta do Live Server) é outra ainda. Se você abrir o site pelo Live Server em vez de pelo seu servidor Express, o botão do Google não vai aparecer e o console vai dizer The given origin is not allowed for the given client ID. Nesta aula, o site é sempre servido pelo Express — é o express.static da Aula 11 que faz isso.
Passo 2 — Instalar as dependências e guardar os segredos¶
Confirme que deu certo antes de seguir. O comando abaixo não pode listar o .env:
Terminal
gitstatus--short
gitcheck-ignore-v.env
O primeiro mostra o que entraria no commit; o segundo confirma qual regra do .gitignore está barrando o arquivo. Se git status mostrar .env, pare tudo e resolva agora.
Crie a pasta auth/ e o módulo que assina e confere as sessões. Ele não sabe nada sobre HTTP nem sobre Google: só transforma um usuário em texto assinado e de volta.
cafe-cerrado-api/auth/sessao.js
JavaScript
// Token de sessão da própria aplicação: dados em base64url + assinatura HMAC.// Quem não tem o SESSAO_SEGREDO não consegue produzir uma assinatura válida.constcrypto=require('node:crypto');constDURACAO_MS=8*60*60*1000;// 8 horasfunctionsegredo(){constvalor=process.env.SESSAO_SEGREDO;if(!valor){thrownewError('SESSAO_SEGREDO não definido. Confira o arquivo .env.');}returnvalor;}functionassinar(conteudo){returncrypto.createHmac('sha256',segredo()).update(conteudo).digest('base64url');}functioncriarToken(usuario){constdados={email:usuario.email,nome:usuario.nome,foto:usuario.foto,expiraEm:Date.now()+DURACAO_MS,};constcorpo=Buffer.from(JSON.stringify(dados),'utf-8').toString('base64url');return`${corpo}.${assinar(corpo)}`;}// Devolve os dados do usuário se o token for válido; null em qualquer outro caso.functionlerToken(token){if(typeoftoken!=='string'){returnnull;}constpartes=token.split('.');if(partes.length!==2){returnnull;}const[corpo,assinaturaRecebida]=partes;constassinaturaEsperada=assinar(corpo);constrecebida=Buffer.from(assinaturaRecebida);constesperada=Buffer.from(assinaturaEsperada);if(recebida.length!==esperada.length||!crypto.timingSafeEqual(recebida,esperada)){returnnull;}try{constdados=JSON.parse(Buffer.from(corpo,'base64url').toString('utf-8'));if(typeofdados.expiraEm!=='number'||Date.now()>dados.expiraEm){returnnull;}return{email:dados.email,nome:dados.nome,foto:dados.foto};}catch(erro){returnnull;}}module.exports={criarToken,lerToken};
Três detalhes que valem a leitura atenta:
A assinatura é conferida antes de o JSON ser interpretado. Nunca confie no conteúdo de um token cuja assinatura você ainda não validou.
timingSafeEqual exige buffers do mesmo tamanho — por isso a comparação de comprimento vem antes, com || (que não avalia o lado direito quando o esquerdo já é verdadeiro).
lerToken devolve null para todo problema: token torto, assinatura errada, JSON quebrado, prazo vencido. Quem chama não precisa distinguir os casos, e o atacante também não descobre qual foi o erro.
const{OAuth2Client}=require('google-auth-library');constsessao=require('../auth/sessao');constcliente=newOAuth2Client(process.env.GOOGLE_CLIENT_ID);// POST /api/auth/google { "credential": "<ID token do Google>" }exports.entrarComGoogle=async(req,res)=>{const{credential}=req.body??{};if(!credential){returnres.status(400).json({erro:'Envie o campo credential com o ID token do Google.'});}letbilhete;try{bilhete=awaitcliente.verifyIdToken({idToken:credential,audience:process.env.GOOGLE_CLIENT_ID,// o token foi emitido para NÓS?});}catch(erro){console.error('Falha ao verificar o ID token:',erro.message);returnres.status(401).json({erro:'Token do Google inválido ou expirado.'});}constdados=bilhete.getPayload();if(!dados.email_verified){returnres.status(401).json({erro:'A conta Google precisa ter e-mail verificado.'});}constusuario={email:dados.email,nome:dados.name,foto:dados.picture};res.json({usuario,token:sessao.criarToken(usuario)});};// GET /api/auth/eu — quem sou eu, segundo o servidorexports.eu=(req,res)=>{res.json({usuario:req.usuario});};
Aqui o try/catch é obrigatório, apesar do Express 5 capturar erros de funções async sozinho. O motivo: um token inválido não é um erro inesperado do servidor — é uma resposta esperada da aplicação. Sem o catch, a falha viraria 500 Erro interno do servidor, e o front concluiria que a sua API está quebrada em vez de pedir um novo login. Use o tratador global para o que você não previu; trate explicitamente o que você previu.
💡 Dica
Note que a aplicação nunca usa client secret. No fluxo do Google Identity Services para aplicações web, o token chega ao navegador e é verificado por assinatura — não há troca de código por token no servidor, então não há segredo do cliente envolvido. Se algum tutorial mandar você colar um client_secret no front-end, feche a página.
Passo 5 — O middleware exigirLogin e as rotas protegidas¶
cafe-cerrado-api/middlewares/exigirLogin.js
JavaScript
constsessao=require('../auth/sessao');// Barra a requisição que não trouxer um token de sessão válido.// Quando passa, deixa req.usuario disponível para os controladores.module.exports=functionexigirLogin(req,res,next){constcabecalho=req.headers.authorization??'';if(!cabecalho.startsWith('Bearer ')){returnres.status(401).json({erro:'Faça login para continuar.'});}constusuario=sessao.lerToken(cabecalho.slice(7));if(!usuario){returnres.status(401).json({erro:'Sessão inválida ou expirada. Entre novamente.'});}req.usuario=usuario;next();};
cabecalho.slice(7) corta exatamente os 7 caracteres de 'Bearer '. Esse formato — o esquema, um espaço e a credencial — está na especificação do HTTP; Bearer significa "portador": quem apresentar o token é tratado como dono dele. É por isso que um token vazado é tão sério quanto uma senha vazada, e por isso que ele expira.
Agora o arquivo de rotas de produtos: leitura aberta, escrita protegida. Duas palavras a mais por linha.
Leia essas cinco linhas como um documento de política de acesso: qualquer visitante lê o cardápio; só quem está logado altera. Essa clareza é consequência direta da separação que você fez na Aula 13 — com a lógica dentro das rotas, essa política estaria diluída em cem linhas.
// A PRIMEIRA linha do projeto: carrega o .env em process.env.// Precisa vir antes de qualquer require que leia process.env.require('dotenv').config();constpath=require('node:path');constexpress=require('express');constprodutosRouter=require('./routes/produtos');constcategoriasRouter=require('./routes/categorias');constauthRouter=require('./routes/auth');constregistrarRequisicao=require('./middlewares/registro');const{naoEncontradoApi,tratadorDeErros}=require('./middlewares/erros');constapp=express();constPORTA=process.env.PORT||3000;// Falha cedo e com mensagem clara se a configuração estiver incompleta.for(constchaveof['GOOGLE_CLIENT_ID','SESSAO_SEGREDO']){if(!process.env[chave]){console.error(`Variável ${chave} ausente. Copie .env.exemplo para .env e preencha.`);process.exit(1);}}app.use(express.json());app.use(registrarRequisicao);app.use(express.static(path.join(__dirname,'public')));// Configuração pública que o front precisa conhecer.// O Client ID não é segredo; o segredo de sessão nunca sai daqui.app.get('/api/config',(req,res)=>{res.json({googleClientId:process.env.GOOGLE_CLIENT_ID});});app.use('/api/auth',authRouter);app.use('/api/produtos',produtosRouter);app.use('/api/categorias',categoriasRouter);app.all('/api/{*splat}',naoEncontradoApi);app.get('/{*splat}',(req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});app.use(tratadorDeErros);app.listen(PORTA,()=>{console.log(`Café Cerrado API em http://localhost:${PORTA}`);});
Fora as três linhas novas — o /api/config, o authRouter e o bloco que verifica a configuração —, este server.js é o mesmo da Aula 13: os middlewares continuam em middlewares/, o fallback /{*splat} continua entregando o index.html da SPA e a porta continua vindo de process.env.PORT. Sempre PORT, nunca PORTA: é o nome que os serviços de hospedagem definem, e é o que o Capítulo 05 da trilha Deploy espera encontrar.
A ordem do require('dotenv').config() não é estética. O authController.js executa new OAuth2Client(process.env.GOOGLE_CLIENT_ID) no momento em que é importado — se o .env ainda não tiver sido carregado, o cliente nasce com undefined e toda verificação falha com uma mensagem confusa. Carregue a configuração antes de tudo.
💡 Dica
O Node 22 lê variáveis de ambiente sem biblioteca nenhuma: node --env-file=.env server.js. É uma opção legítima e um argumento a menos no package.json de dependências. Usamos o dotenv porque ele funciona igual em qualquer versão e em qualquer serviço de hospedagem, inclusive nos que você vai encontrar na trilha de Deploy. Saber que a alternativa nativa existe é o que importa.
O alt="" da foto é proposital: o nome do usuário está escrito ao lado, em texto. Uma imagem puramente decorativa com alt vazio é ignorada pelo leitor de tela, e é exatamente isso que se quer — repetir "foto de Maria Silva" logo antes de ler "Maria Silva" só atrapalha. O role="status" com aria-live="polite" faz o leitor de tela anunciar as mensagens de login sem interromper o que o usuário estiver fazendo.
Agora o módulo de autenticação:
cafe-cerrado-api/public/js/auth.js
JavaScript
// Autenticação do Café Cerrado com Google Identity Services.constCHAVE_SESSAO='cafe-cerrado-sessao';constareaLogin=document.querySelector('#area-login');constareaUsuario=document.querySelector('#area-usuario');constnomeUsuario=document.querySelector('#nome-usuario');constfotoUsuario=document.querySelector('#foto-usuario');constbotaoSair=document.querySelector('#btn-sair');constaviso=document.querySelector('#aviso-login');exportfunctionobterSessao(){constbruto=sessionStorage.getItem(CHAVE_SESSAO);if(!bruto){returnnull;}try{returnJSON.parse(bruto);}catch(erro){sessionStorage.removeItem(CHAVE_SESSAO);returnnull;}}exportfunctionobterToken(){returnobterSessao()?.token??null;}functionmostrarUsuario(usuario){nomeUsuario.textContent=usuario.nome;fotoUsuario.src=usuario.foto;areaUsuario.hidden=false;areaLogin.hidden=true;document.dispatchEvent(newCustomEvent('sessao-alterada'));}exportfunctionsair(){sessionStorage.removeItem(CHAVE_SESSAO);// Guarda com ?.: o clique pode acontecer antes de a GSI terminar de carregar,// e um ReferenceError aqui deixaria a pessoa presa na sessão.window.google?.accounts?.id?.disableAutoSelect();areaUsuario.hidden=true;areaLogin.hidden=false;aviso.textContent='Você saiu da sua conta.';document.dispatchEvent(newCustomEvent('sessao-alterada'));}// Chamado pelo Google quando o login termina com sucesso.asyncfunctionaoReceberCredencial(resposta){aviso.textContent='Entrando…';constrequisicao=awaitfetch('/api/auth/google',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({credential:resposta.credential}),});if(!requisicao.ok){constcorpo=awaitrequisicao.json().catch(()=>({}));aviso.textContent=corpo.erro??'Não foi possível entrar. Tente de novo.';return;}constsessao=awaitrequisicao.json();sessionStorage.setItem(CHAVE_SESSAO,JSON.stringify(sessao));aviso.textContent='';mostrarUsuario(sessao.usuario);}asyncfunctioniniciar(){constconfiguracao=awaitfetch('/api/config').then((r)=>r.json());google.accounts.id.initialize({client_id:configuracao.googleClientId,callback:aoReceberCredencial,});google.accounts.id.renderButton(document.querySelector('#botao-google'),{type:'standard',theme:'outline',size:'large',text:'signin_with',locale:'pt-BR',});constsessao=obterSessao();if(sessao){mostrarUsuario(sessao.usuario);}}botaoSair.addEventListener('click',sair);// O script do Google carrega com "async": pode chegar antes ou depois deste módulo.if(window.google?.accounts?.id){iniciar();}else{window.onGoogleLibraryLoad=iniciar;}
Duas decisões merecem discussão:
A corrida do carregamento. O script do Google tem async, então não há garantia de ordem entre ele e o seu módulo. Chamar google.accounts.id.initialize direto dá TypeError: Cannot read properties of undefined (reading 'accounts') em metade das vezes — o pior tipo de bug, o que só aparece às vezes. O bloco final cobre os dois cenários: se a biblioteca já está lá, inicia; se não, registra window.onGoogleLibraryLoad, o gancho que a própria GSI chama ao terminar de carregar.
O CustomEvent. O módulo avisa o resto da aplicação toda vez que a sessão muda. Na Aula 15, o app.js vai ouvir esse evento para mostrar ou esconder os botões de editar e excluir, sem que os dois arquivos precisem se conhecer.
⚠️ Atenção
Guardamos o token no sessionStorage porque é simples e some ao fechar a aba. Saiba o que isso custa: qualquer JavaScript que rode na sua página — inclusive um script de terceiro comprometido — consegue ler sessionStorage e roubar a sessão. Isso se chama XSS. A defesa profissional é entregar a sessão em um cookie httpOnly, que o JavaScript não enxerga e o navegador envia sozinho. Não é mais difícil, é só mais assunto do que cabe hoje — e é o desafio ⭐⭐⭐ desta aula.
Acrescente ao testes.http os blocos de autenticação. O @token você preenche daqui a pouco:
cafe-cerrado-api/testes.http (acrescente ao final)
HTTP
@token = cole-aqui-o-token-copiado-do-navegador### 14. Configuração pública (200, com o Client ID)GET {{base}}/config### 15. Quem sou eu, sem token (401)GET {{base}}/auth/eu### 16. Login com token falso (401, e não 500)POST {{base}}/auth/googleContent-Type: application/json{ "credential": "isto.nao.e-um-token"}### 17. Criar produto SEM token (401) — a proteção do diaPOST {{base}}/produtosContent-Type: application/json{ "nome": "Café Gelado da Casa", "categoria": "geladas", "preco": 14.0}### 18. Listar SEM token (200) — leitura continua públicaGET {{base}}/produtos### 19. Quem sou eu, com token (200, com e-mail e nome)GET {{base}}/auth/euAuthorization: Bearer {{token}}### 20. Criar produto COM token (201)POST {{base}}/produtosContent-Type: application/jsonAuthorization: Bearer {{token}}{ "nome": "Café Gelado da Casa", "categoria": "geladas", "preco": 14.0}### 21. Excluir COM token (204) — o produto criado no bloco 20DELETE {{base}}/produtos/11Authorization: Bearer {{token}}
Abra http://localhost:3000. O botão "Fazer login com o Google" aparece no cabeçalho. Se não aparecer, abra o console: a mensagem de erro do GSI diz exatamente o que falta.
Clique, escolha a sua conta. Nome e foto substituem o botão. No terminal do servidor, o log mostra POST /api/auth/google -> 200.
No console do navegador, execute JSON.parse(sessionStorage.getItem('cafe-cerrado-sessao')).token e copie o valor (sem as aspas). Cole na variável @token do testes.http.
Rode os blocos 14 a 21 na ordem. Os status esperados:
Blocos
Status
O que prova
14 e 18
200
Configuração e leitura continuam públicas
15, 16 e 17
401
Sem identidade não se lê /auth/eu nem se escreve
19, 20 e 21
200, 201, 204
Com a sessão válida, tudo funciona
Prova final da assinatura: no bloco 19, troque um caractere do token colado e envie de novo. A resposta tem que ser 401. Um único byte diferente destrói a assinatura — é isso que impede alguém de editar os próprios dados de sessão.
Clique em "Sair" no site e recarregue a página: o botão do Google volta, porque o sessionStorage foi limpo.
A1. Classifique cada situação como 401 ou 403, com uma linha de justificativa:
(a) Requisição sem cabeçalho Authorization.
(b) Token de sessão expirado há dois minutos.
(c) Usuário logado tentando excluir um produto cadastrado por outra pessoa.
(d) Token com a assinatura adulterada.
A2. Um colega diz: "decodifiquei o JWT com atob e vi que o e-mail é professor@exemplo.br, então o usuário é o professor". Explique em três linhas por que a conclusão está errada e o que faltou fazer.
A3. Preveja a resposta de cada requisição sem rodar, e depois confira:
A4. No authController.js, remova mentalmente a linha audience: process.env.GOOGLE_CLIENT_ID. A verificação da assinatura continua funcionando? Que ataque passa a ser possível? Responda em três linhas.
A5. O que acontece se require('dotenv').config() for movido para depois de const authRouter = require('./routes/auth') no server.js? Descreva a mensagem de erro que o usuário veria ao tentar entrar e explique a causa.
A6. Em exigirLogin, alguém trocou return res.status(401).json(...) por res.status(401).json(...), sem o return. Descreva o que acontece com uma requisição sem token — e por que o erro que aparece no terminal fala em cabeçalhos.
B1. Login no seu projeto autoral. Configure o Google Cloud Console para o seu projeto e implemente o fluxo completo: botão, POST /api/auth/google, sessão assinada e exibição do nome e da foto no cabeçalho.
Resultado esperado: entrar mostra nome e foto; recarregar a página mantém a sessão; "Sair" limpa tudo e traz o botão de volta.
Dica
Crie um Client ID novo para o seu projeto, com o nome dele — não reaproveite o do Café Cerrado. Se a porta do seu servidor não for 3000, é essa porta que precisa estar em "Origens JavaScript autorizadas".
B2. Proteja as escritas. Aplique o exigirLogin nas rotas POST, PUT e DELETE do seu recurso principal, mantendo os GET públicos, e prove a proteção com quatro blocos no testes.http.
Resultado esperado: escrita sem token devolve 401 com mensagem em JSON; leitura sem token devolve 200; escrita com token funciona normalmente.
Dica
O middleware entra entre o caminho e o controlador: router.post('/', exigirLogin, controlador.criar). Se todas as rotas do arquivo precisassem de login, uma linha router.use(exigirLogin) no topo resolveria de uma vez.
B3. Registre quem fez o quê. Faça o middleware de log incluir o e-mail do usuário quando houver sessão, produzindo linhas como POST /api/produtos -> 201 (12 ms) por maria@gmail.com.
Resultado esperado: requisições anônimas continuam sendo registradas normalmente, sem quebrar; requisições autenticadas mostram o e-mail.
Dica
O log roda antes do exigirLogin, então req.usuario ainda não existe quando a linha é montada — mas o callback de res.on('finish', ...) executa depois de tudo. Leia req.usuario lá dentro, com ?. para o caso anônimo.
B4. Diagnóstico honesto. Escreva no README.md uma seção "Segurança" com três parágrafos: onde o token de sessão é guardado no navegador, qual o risco dessa escolha, e o que você faria diferente em um sistema com dados sensíveis.
Resultado esperado: o texto cita XSS pelo nome, explica o que um cookie httpOnly mudaria e não promete segurança que o projeto não tem.
Dica
Um bom parágrafo de risco responde três perguntas: o que um atacante precisaria conseguir, o que ele obteria, e por quanto tempo o estrago dura. A duração está no DURACAO_MS do sessao.js.
C1. Sessão que expira na cara do usuário. Reduza DURACAO_MS para 60 segundos e observe o desastre: passado um minuto, cada tentativa de escrita devolve 401 e o site continua exibindo alegremente o nome e a foto do usuário, como se nada tivesse acontecido. Conserte o comportamento nas duas pontas.
O servidor precisa dizer por que negou, de forma que o front distinga "nunca entrou" de "a sessão venceu"; e o front precisa reagir a um 401 limpando a sessão, voltando o botão de login e avisando a pessoa — sem que cada chamada fetch do projeto precise repetir esse tratamento.
Resultado esperado: com a duração em 60 segundos, esperar um minuto e tentar criar um produto faz o site voltar sozinho ao estado deslogado, com a mensagem "Sua sessão expirou. Entre novamente."; o console não mostra nenhum erro não tratado; e no fim você devolve DURACAO_MS para 8 horas.
Dica
No servidor, devolva um campo a mais junto do erro (por exemplo codigo: 'sessao_expirada' × codigo: 'sem_token') — isso exige separar os dois casos no lerToken, hoje unificados em null. No front, escreva uma função requisitar(url, opcoes) que envolve o fetch, adiciona o Authorization quando há token e trata o 401 em um lugar só; é a mesma camada que a Aula 15 vai usar para todo o CRUD. A função sair() já faz tudo que o tratamento precisa.
Abra o site do Café Cerrado pelo Live Server do VS Code, em http://127.0.0.1:5500, em vez de pelo seu servidor Express. O botão do Google some, e o console solta uma mensagem que menciona origin. Antes de consertar, entenda: o erro não veio do seu código nem do seu servidor — veio do Google, sobre uma decisão que você configurou. Reproduza três variações do problema, explique o que cada uma revela sobre o conceito de origem e deixe o projeto documentado para que ninguém da sua equipe caia nisso.
Critérios de pronto
Um arquivo docs/origens.md registra, para cada uma das três variações (porta diferente, 127.0.0.1 no lugar de localhost, e https no lugar de http), a mensagem exata do console e uma linha explicando por que o Google recusou.
Uma frase define "origem" em termos de protocolo, host e porta, com um exemplo de duas URLs que parecem iguais e são origens diferentes.
O README.md ganha um aviso de uma linha dizendo por qual endereço o site deve ser aberto e por quê.
O botão volta a funcionar no endereço correto, sem que você tenha cadastrado todas as origens possíveis no console do Google.
Pistas
Copie a mensagem inteira do console, incluindo o prefixo [GSI_LOGGER], e procure por ela na documentação do Google Identity Services.
A configuração relevante está em "Origens JavaScript autorizadas", na credencial que você criou. Ela aceita várias entradas — mas cadastrar tudo não é a resposta, é a preguiça.
Para a variação com https, lembre-se de que localhost é tratado como origem segura mesmo sem certificado; teste e explique o que acontece.
Vale investigar também o que muda se você abrir o arquivo com duplo clique (file://). O que o console diz sobre a origem nesse caso?
Hoje qualquer pessoa com uma conta Google — o planeta inteiro — vira um usuário capaz de mexer no cardápio do Café Cerrado. Isso está certo para um blog e errado para um sistema interno. Implemente autorização de verdade: a API passa a ter uma lista de e-mails autorizados a escrever, e quem está autenticado mas fora da lista recebe 403, com uma mensagem que explique a diferença.
Critérios de pronto
Um middleware exigirPermissao roda depois de exigirLogin e devolve 403 com { "erro": "..." } para e-mails fora da lista.
A lista vem do .env (por exemplo EMAILS_AUTORIZADOS=a@x.com,b@y.com), nunca do código, e o .env.exemplo documenta o formato.
GET continua público, escrita sem token continua 401 e escrita com token não autorizado devolve 403 — os três casos provados no testes.http.
GET /api/auth/eu passa a devolver um campo booleano dizendo se aquele usuário pode escrever, para o front decidir o que mostrar.
Um parágrafo no README.md explica, com as suas palavras, por que 401 e 403 não são intercambiáveis.
Pistas
process.env.EMAILS_AUTORIZADOS.split(',').map((e) => e.trim().toLowerCase()) transforma a variável em lista utilizável.
Compare sempre em minúsculas: o Google devolve o e-mail como cadastrado, e a sua lista foi digitada à mão.
A ordem na rota importa: router.post('/', exigirLogin, exigirPermissao, controlador.criar). Inverter os dois faz o exigirPermissao ler um req.usuario que ainda não existe.
Pense no caso da lista vazia ou ausente: liberar todo mundo ou bloquear todo mundo? Escolha, justifique no README e implemente a escolha explicitamente — comportamento de segurança nunca deve ser acidente.
O ID token do Google e o token de sessão que você emitiu têm prazos de validade diferentes, definidos por gente diferente, por motivos diferentes. Poucos desenvolvedores sabem dizer quais são. Descubra os dois experimentalmente, documente e depois tome uma decisão de projeto informada sobre a duração da sua sessão.
Critérios de pronto
Um arquivo docs/validade-dos-tokens.md mostra as claims iat e exp de um ID token real (obtidas decodificando o token no console) convertidas para horário legível, com a duração calculada em minutos.
O mesmo documento registra a duração da sessão emitida pela sua API e como você a mediu, sem confiar apenas no valor da constante.
Uma tabela de três linhas compara os dois tokens: quem emite, quanto dura, o que acontece quando vence.
Um parágrafo justifica a duração escolhida para a sessão do seu projeto, considerando que ela é usada em um laboratório da universidade com computadores compartilhados.
A constante DURACAO_MS passa a ser lida do .env, com um padrão sensato quando a variável não existir.
Pistas
JSON.parse(atob(token.split('.')[1])) no console devolve as claims do ID token. iat e exp estão em segundos desde 1970 — multiplique por 1000 antes de passar para new Date().
Para medir a sessão sem confiar na constante, decodifique o corpo do seu próprio token com Buffer.from(corpo, 'base64url') em um script Node e olhe o expiraEm.
Um valor vindo do .env chega como string: Number(process.env.SESSAO_HORAS) || 8 resolve conversão e padrão de uma vez.
Para o parágrafo final, pense no cenário concreto: alguém entra no seu sistema no laboratório, esquece de sair e vai embora. Quanto tempo a sessão continua aberta?
Se um único script malicioso rodar na sua página, ele lê o sessionStorage e leva a sessão embora. É assim que contas são roubadas de verdade. A defesa padrão da indústria é entregar a sessão em um cookie httpOnly: o navegador guarda, envia sozinho em cada requisição e não deixa o JavaScript ler. Migre o Café Cerrado para esse modelo e prove, com o DevTools, que o token sumiu do alcance do JavaScript.
Critérios de pronto
POST /api/auth/google passa a responder com Set-Cookie contendo o token de sessão, com os atributos HttpOnly, SameSite=Lax, Path=/ e Max-Age coerente com a duração da sessão. O corpo da resposta traz só os dados do usuário.
exigirLogin aceita a sessão vinda do cookie; o front não guarda mais token nenhum no sessionStorage.
Existe POST /api/auth/sair, que apaga o cookie, e o botão "Sair" passa a chamá-lo.
No console do navegador, document.cookienão mostra o token, enquanto a aba Application do DevTools mostra o cookie presente — com prova em duas capturas de tela no docs/.
O README.md explica em um parágrafo o que SameSite faz e por que ele importa aqui, citando o ataque que esse atributo previne.
Pistas
Enviar é fácil: res.cookie('sessao', token, { httpOnly: true, sameSite: 'lax', maxAge: DURACAO_MS, path: '/' }) já vem no Express. Ler exige interpretar req.headers.cookie, que é uma string única com pares separados por ; — escreva uma função de dez linhas ou instale cookie-parser.
Faça o exigirLogin aceitar as duas origens durante a migração (cabeçalho Authorizationou cookie) e só depois remova a antiga. Migração em dois passos evita ficar sem login no meio do caminho.
Para apagar um cookie, envie-o de novo com validade no passado: res.clearCookie('sessao', { path: '/' }). O path precisa bater com o do cookie original, ou o navegador ignora.
Cookies criam uma porta nova para um ataque chamado CSRF, porque o navegador passa a enviar a credencial sozinho — inclusive em requisições disparadas por outro site. Leia sobre SameSite na MDN antes de escrever o parágrafo do README; é ele que fecha essa porta no nosso caso.
Crie o projeto no Google Cloud Console, obtenha o Client ID e configure .env (no .gitignore) e .env.exemplo (versionado).
Implemente POST /api/auth/google verificando o ID token com google-auth-library, com audience preenchido.
Emita um token de sessão assinado pela sua aplicação e devolva-o junto com nome, e-mail e foto.
Adicione o botão do Google no cabeçalho do site, com a área de usuário (nome, foto, botão Sair) e a mensagem de status acessível.
Proteja POST, PUT e DELETE com o exigirLogin, mantendo os GET públicos.
Registre no testes.http os quatro cenários: leitura sem token (200), escrita sem token (401), login com token falso (401) e escrita com token válido (201).
Critério de pronto: o git status não mostra o .env em momento algum; um visitante anônimo consegue ler o conteúdo; e nenhuma operação de escrita funciona sem um token de sessão válido — verificado pelo curl, não pela interface.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — segurança e autenticação em aplicações web.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — segurança em aplicações de grande porte.
A API sabe quem você é e o site sabe mostrar o seu nome — mas ainda não existe uma tela para cadastrar um produto. Na próxima aula, o front-end assíncrono da Unidade 2 finalmente encontra a API da Unidade 3: formulário que serve para criar e editar, exclusão com confirmação, feedback acessível e lista que se atualiza sem recarregar a página, tudo enviando o token que você emitiu hoje.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EAD
Aula 15 — CRUD com front-end assíncrono
Nível 2 — Desenvolvimento Web · WebLab
Sua API já faz CRUD completo e já sabe quem está batendo na porta. Só que, até agora, quem usou esses recursos foi você — pelo testes.http, com a extensão REST Client. Nenhuma outra pessoa consegue cadastrar um café no Café Cerrado sem escrever uma requisição HTTP na mão. Hoje isso muda: a interface que você construiu na Unidade 2 passa a criar, editar e excluir produtos consumindo a sua própria API, sem recarregar a página uma única vez.
Descrever o contrato completo de um recurso REST (método, caminho, corpo, resposta, status) e usá-lo como acordo entre front e back.
Ler o contrato de um recurso já implementado (o repositório e o controlador das Aulas 13 e 14) e construir um cliente que o respeite sem alterá-lo.
Explicar por que uma gravação em duas etapas (arquivo temporário + rename) protege os dados contra um servidor que morre no meio da escrita.
Construir uma camada de acesso à API no cliente (public/js/api.js) que centraliza cabeçalhos, token e tratamento de erros.
Implementar os quatro estados de uma tela que depende de rede — carregando, erro, vazio e conteúdo — e renderizá-los a partir de uma única fonte de verdade.
Reutilizar um mesmo formulário para criar e editar registros, com o padrão de "modo edição" e foco controlado.
Excluir registros com confirmação e anunciar cada resultado com aria-live, sem recarregar a página.
Depurar uma integração front-back pela aba Network do DevTools, identificando de qual lado está o defeito.
[ ] Repositório cafe-cerrado-api rodando com npm run dev, servindo o site em http://localhost:3000 por express.static(path.join(__dirname, 'public')) (Aula 11).
[ ] routes/produtos.js, controllers/produtosController.js e os middlewares de log, 404 e erro funcionando (Aulas 12 e 13).
[ ] CRUD da API respondendo pelo testes.http: GET, POST, PUT e DELETE em /api/produtos (Aula 13).
[ ] Login Google funcionando, .env com GOOGLE_CLIENT_ID fora do Git e o middleware exigirLogin protegendo as rotas de escrita (Aula 14).
[ ] fetch com async/await e tratamento de erro no cliente (Aulas 09 e 10) — hoje é tudo isso ao mesmo tempo.
Na aula passada você delegou o login ao Google, verificou o ID token no servidor com google-auth-library e barrou com 401 toda escrita sem token. As duas metades do sistema — API com CRUD e API com autenticação — existem, mas só respondem ao testes.http. Hoje elas ganham interface: a tela do cardápio passa a listar, criar, editar e excluir produtos consumindo /api/produtos com fetch, e os dados passam a sobreviver ao reinício do servidor. Na próxima aula, cada registro ganha dono e esta trilha se encerra com o Marco 3.
Na Aula 10 o seu fetch foi buscar dados no JSONPlaceholder — um servidor de outra pessoa, em outro domínio. Aquilo é uma requisição cross-origin, e só funcionou porque o JSONPlaceholder responde com o cabeçalho Access-Control-Allow-Origin: *, autorizando qualquer site a lê-lo.
Agora a situação é outra e muito mais simples. Desde a Aula 11 o Express serve o site estático (express.static) e a API no mesmo processo, na mesma porta. Abrir http://localhost:3000/index.html e pedir fetch("/api/produtos") é uma requisição de mesma origem: mesmo protocolo (http), mesmo host (localhost), mesma porta (3000). Nada de CORS, nada de cabeçalhos especiais, nada de preflight.
Isso tem uma consequência prática importante no código: use sempre caminhos relativos.
JavaScript
// Certo: funciona em localhost:3000, em localhost:4000 e no dia em que o// projeto for publicado num domínio de verdade — a URL acompanha o site.constresposta=awaitfetch("/api/produtos");// Errado: quebra assim que a porta ou o domínio mudarem.constresposta2=awaitfetch("http://localhost:3000/api/produtos");
⚠️ Atenção
Abrir o index.html com um duplo clique (file:///home/voce/cafe-cerrado-api/public/index.html) não funciona mais. O protocolo file:// não tem servidor, e /api/produtos viraria um caminho no seu disco. A partir de agora o site só é aberto por http://localhost:3000, com o npm run dev rodando. Se você usava Live Server, aposente-o nesta unidade: quem serve o front agora é o seu próprio Express.
Antes de escrever qualquer código de integração, front e back precisam concordar num contrato: para cada operação, qual método HTTP, qual caminho, o que vai no corpo, o que volta e qual status. O contrato é o que permite que você mexa num lado sem ler o código do outro — e é o que você vai consultar às duas da manhã, quando a tela mostrar "undefined".
Ele não é novidade: é exatamente o que a sua API responde desde a Aula 13, com as escritas protegidas pelo exigirLogin da Aula 14. O que fazemos aqui é escrevê-lo por extenso, do ponto de vista de quem consome.
Método
Caminho
Autenticação
GET
/api/produtos
Pública
GET
/api/produtos/:id
Pública
POST
/api/produtos
Exige login
PUT
/api/produtos/:id
Exige login
DELETE
/api/produtos/:id
Exige login
Detalhando corpo e resposta de cada operação:
GET /api/produtos — lista o cardápio. Aceita três parâmetros de query string, todos criados na Aula 13 e todos usados pela tela de hoje: ?q=termo (busca no nome e na descrição, ignorando acento e caixa), ?categoria=cafes (filtra pela categoria exata) e ?ordenar=preco (também -preco e nome). Status 200. Resposta:
JSON
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"}]
GET /api/produtos/:id — um produto. Status 200; 404 com { "erro": "Produto 99 não encontrado." }; e 400 quando o id nem é um número inteiro positivo (/api/produtos/abacaxi).
POST /api/produtos — corpo com os campos editáveis. categoria só aceita um dos quatro ids do cardápio (cafes, geladas, salgados, doces) — é a lista branca do controlador:
JSON
{"nome":"Suco de Cupuaçu","categoria":"geladas","preco":10.5,"descricao":"Polpa batida com água gelada e um fio de mel.","imagem":"img/suco-cupuacu.jpg"}
Resposta: o produto criado, já com id, status 201 e cabeçalho Location: /api/produtos/11. Sem token: 401. Corpo inválido: 400 com o formato de erro abaixo — guarde-o, porque o formulário da seção 5 depende dele para colorir o campo certo:
JSON
{"erro":"Dados inválidos.","detalhes":[{"campo":"nome","mensagem":"O nome precisa ter ao menos 3 caracteres."},{"campo":"preco","mensagem":"O preço precisa ser um número maior que zero."}]}
Cada item de detalhes traz o campo e a mensagem, e não só um texto solto. É essa dupla que permite ao front pendurar a mensagem embaixo do <input> correspondente em vez de despejar tudo num alerta genérico.
PUT /api/produtos/:id — mesmo corpo do POST, com atualização parcial permitida (envie só o que mudou). Resposta: o produto atualizado, status 200. Inexistente: 404. Id malformado: 400. Sem token: 401.
DELETE /api/produtos/:id — sem corpo. Resposta: status 204 No Content, sem corpo nenhum. Inexistente: 404. Id malformado: 400. Sem token: 401.
💡 Dica204 não pode ter corpo: resposta.json() sobre uma resposta 204 explode com Unexpected end of JSON input. A camada de API que escreveremos na seção 3 trata isso em uma linha — e é o tipo de detalhe que consome uma tarde de quem não sabe que existe.
📌 Vale gravar
Decore a semântica dos status que este contrato usa: 200 OK (deu certo e há corpo), 201 Created (criou um recurso novo), 204 No Content (deu certo e não há corpo), 400 Bad Request (o cliente mandou algo inválido), 401 Unauthorized (não sei quem você é), 404 Not Found (o recurso não existe). O 403 entra na próxima aula.
Toda a lógica de tela desta aula cabe numa frase: o JavaScript nunca edita a tela por partes; ele muda o estado e manda desenhar tudo de novo.
Texto
evento do usuário → chamada à API → atualiza o estado → renderizar() → HTML na tela
Concretamente: quando você exclui um produto, o código não procura o <article> daquele card para removê-lo do DOM. Ele chama a API, recarrega a lista de produtos do servidor, guarda no array produtos e chama renderizar(), que apaga o container e o redesenha inteiro. Parece desperdício — e é, para listas gigantes — mas elimina de uma vez a classe de bug mais comum de front-end: a tela dizendo uma coisa e os dados dizendo outra.
Guarde esse ciclo. É exatamente o que Vue e React automatizam, e você vai reencontrá-lo no Nível 3 sob outro nome ("reatividade"). Aqui você o implementa à mão, que é a melhor forma de entender o que a ferramenta faz por você depois.
2. O back-end que já está pronto: repositório e controlador¶
2.1 O repositório da Aula 13, revisto em três linhas¶
O data/repositorio.js já existe desde a Aula 13 e não muda hoje. Relembre o essencial: ele é o único arquivo do projeto que sabe onde os produtos moram, exporta lerTodos(), salvarTodos(lista) e proximoId(lista), resolve o caminho com path.join(__dirname, 'produtos.json') e usa node:fs/promises. Nenhuma das três funções menciona res, req ou status HTTP — é isso que permitirá trocar o arquivo JSON por um banco de dados no Nível 3 reescrevendo só esse arquivo.
Um detalhe daquele arquivo que vale reter, porque volta a importar hoje: proximoId usa o maior id existente mais um, e não lista.length + 1. Com length + 1, basta excluir um produto do meio para o próximo cadastro reaproveitar um id já usado — e a tela passa a ter dois cards com a mesma chave.
2.2 O controlador da Aula 13 é o contrato que o front vai consumir¶
O controllers/produtosController.js também não é reescrito. Vale a pena reler o que ele já faz, porque cada item desta lista vira uma funcionalidade da tela de hoje:
O que o controlador da Aula 13 já entrega
O que a tela de hoje faz com isso
?q= com normalizar (sem acento, sem caixa)
O campo de busca com debounce
?categoria= validado pela lista branca
O <select> de categoria
?ordenar=preco / -preco / nome
O <select> de ordenação
Campo imagem no objeto salvo
A foto do card
400 para id que não é inteiro positivo
Mensagem clara em vez de "não encontrado"
400 com detalhes: [{ campo, mensagem }]
A mensagem embaixo do <input> certo
201 com cabeçalho Location
A confirmação do cadastro
204 sem corpo no DELETE
O card que some sem erro no console
E as rotas continuam as da Aula 14 — leitura pública, escrita protegida:
⚠️ Atenção
Resista à tentação de "simplificar" o controlador para escrever o front mais rápido — trocar detalhes: [{ campo, mensagem }] por um array de strings, deixar cair o ?categoria= ou aceitar qualquer categoria. Cada uma dessas simplificações apaga uma funcionalidade da tela que você vai construir hoje, e o desafio ⭐⭐ desta aula cobra exatamente o formato de erro que você teria jogado fora.
Nada muda no back-end hoje. O trabalho da aula inteira acontece em public/.
fs.writeFile não é instantâneo. Para um arquivo de 300 KB, o sistema operacional pode truncar o arquivo antigo, começar a escrever o novo conteúdo e — se o processo morrer nesse instante (você apertou Ctrl+C, a máquina reiniciou, o --watch recarregou) — deixar no disco um JSON pela metade. Na próxima leitura, JSON.parse lança Unexpected end of JSON input e o cardápio inteiro se perde.
O truque do arquivo temporário resolve isso:
Escreve todo o conteúdo em produtos.json.tmp. Se morrer aqui, o produtos.json original continua intacto.
Renomeia produtos.json.tmp para produtos.json. No mesmo sistema de arquivos, o rename é atômico: ou o nome aponta para o arquivo antigo, ou aponta para o novo. Nunca para um meio-termo.
🔎 Por baixo do capô
A atomicidade do rename não é gentileza do Node: é garantia do POSIX, o padrão que rege os sistemas Unix. Renomear um arquivo é trocar uma entrada no diretório, uma operação que o sistema de arquivos trata como indivisível. É o mesmo mecanismo que bancos de dados, editores de texto e o próprio git usam para nunca deixarem um arquivo pela metade. Bancos de dados de verdade vão além, com journaling e write-ahead log, mas o princípio é este.
🧠 Você sabia?
O JSON que você está gravando aqui nasceu como um efeito colateral do JavaScript: Douglas Crockford formalizou o formato em 2001 a partir da sintaxe de objeto literal da linguagem, sem inventar nada novo — e por isso ele pegou. A ironia é que hoje JSON.parse é mais rápido que escrever o mesmo objeto direto no código: o motor V8 sabe que um texto JSON tem gramática fechada e o lê de uma vez, enquanto um literal de objeto precisa passar pelo parser completo de JavaScript. Times de front-end grandes exploram isso para carregar dados de configuração, e é por isso que sempre tratamos dados como texto a ser interpretado, nunca como código a ser executado.
🔬 Investigue
Com o servidor rodando, abra data/produtos.json no VS Code e deixe-o visível ao lado do navegador. Agora cadastre um produto pela API (via testes.http, ainda) e observe: o arquivo muda sozinho, na sua frente. Em seguida, rode echo '[{ "id": 1,' > data/produtos.json para corromper o arquivo de propósito e recarregue GET /api/produtos. Qual mensagem exata aparece no terminal? Em que linha do repositorio.js ela nasce? Restaure com echo "[]" > data/produtos.json e siga em frente — agora você reconhece esse erro em um segundo.
Sem organização, o CRUD do front vira isto: cinco fetch diferentes, cada um com o seu headers, o seu if (!resposta.ok), o seu JSON.stringify. No dia em que a API passar a exigir um cabeçalho novo, você precisa lembrar de todos os cinco. No dia em que o tratamento de erro melhorar, idem.
Concentre tudo numa função. É o mesmo raciocínio do repositório do back, aplicado ao cliente.
// Camada de acesso à API. Todo fetch do projeto passa por aqui: é o único// lugar que conhece cabeçalhos, token e formato de erro do servidor.import{obterToken}from"./auth.js";constBASE="/api/produtos";// Erro com status: quem chamou consegue reagir diferente a 401, 404 e 400.exportclassErroDeApiextendsError{constructor(mensagem,status,detalhes=[]){super(mensagem);this.name="ErroDeApi";this.status=status;this.detalhes=detalhes;}}asyncfunctionrequisitar(url,opcoes={}){constcabecalhos={"Content-Type":"application/json"};consttoken=obterToken();if(token)cabecalhos.Authorization=`Bearer ${token}`;letresposta;try{resposta=awaitfetch(url,{...opcoes,headers:cabecalhos});}catch(falhaDeRede){// fetch só rejeita quando a requisição nem chegou a ser respondida:// servidor parado, DNS errado, sem rede. Erro 404 ou 500 NÃO cai aqui.console.error("Falha de rede:",falhaDeRede);thrownewErroDeApi("Sem resposta do servidor. O npm run dev está rodando?",0);}if(resposta.status===401){thrownewErroDeApi("Faça login com o Google para continuar.",401);}if(!resposta.ok){// O servidor respondeu com erro. Tenta ler a mensagem que ele mandou;// se o corpo não for JSON (uma página de erro em HTML, por exemplo),// o catch devolve um objeto vazio e caímos na mensagem genérica.constcorpo=awaitresposta.json().catch(()=>({}));thrownewErroDeApi(corpo.erro||`Erro HTTP ${resposta.status}`,resposta.status,corpo.detalhes??[]);}// 204 = sem corpo. Chamar resposta.json() aqui lançaria// "Unexpected end of JSON input".if(resposta.status===204)returnnull;returnresposta.json();}exportconstapi={listar(filtros={}){constparametros=newURLSearchParams();for(const[chave,valor]ofObject.entries(filtros)){if(valor!==""&&valor!==null&&valor!==undefined){parametros.set(chave,valor);}}constconsulta=parametros.toString();returnrequisitar(consulta?`${BASE}?${consulta}`:BASE);},obter(id){returnrequisitar(`${BASE}/${id}`);},criar(dados){returnrequisitar(BASE,{method:"POST",body:JSON.stringify(dados)});},atualizar(id,dados){returnrequisitar(`${BASE}/${id}`,{method:"PUT",body:JSON.stringify(dados)});},remover(id){returnrequisitar(`${BASE}/${id}`,{method:"DELETE"});},};
Três decisões desse arquivo valem uma pausa para pensar — ou discutir, se você estiver estudando em grupo:
fetch não rejeita em erro HTTP. Um 404 ou um 500 são respostas válidas: a promessa resolve normalmente, com resposta.ok === false. O try/catch em volta do fetch só pega falha de rede. Quem esquece disso escreve código que "funciona" mesmo quando o servidor recusou tudo.
Erro com status. Uma Error comum só carrega a mensagem. A ErroDeApi carrega também o status e os detalhes da validação, e é isso que permite à tela reagir de formas diferentes a 401 (peça login) e a 400 (mostre o que está errado no formulário).
O token vem do módulo de autenticação, não é passado por parâmetro. Quem chama api.criar(dados) não precisa nem saber que existe token. Se amanhã a sessão mudar de mecanismo, muda só o auth.js.
O auth.js da Aula 14 já faz o essencial: pede o Client ID a GET /api/config, chama google.accounts.id.initialize, troca a credencial do Google por uma sessão própria em POST /api/auth/google e guarda { usuario, token } no sessionStorage. Nada disso muda — mudar seria quebrar a autenticação inteira.
O que falta é uma interface para o resto da aplicação: hoje api.js precisa do token e app.js precisa saber quando alguém entra ou sai. A Aula 14 avisava com um CustomEvent; vamos trocar o evento por três funções exportadas, que é mais explícito e mais fácil de testar.
A marcação do cabeçalho é a mesma da Aula 14, sem uma linha a mais:
⚠️ Atenção
Repare no que não está aqui: nenhum data-client_id, nenhum data-callback, nenhum <div id="a15-g_id_onload">. O Client ID mora no .env do servidor e chega ao navegador por GET /api/config; o callback é passado por JavaScript em google.accounts.id.initialize. Se você encontrar um tutorial mandando colar o Client ID no HTML, saiba que ele funciona — e que você passa a ter a mesma configuração em dois lugares, que um dia vão divergir. Uma fonte só: o .env.
cafe-cerrado-api/public/js/auth.js
JavaScript
// Sessão do usuário no cliente.// O token guardado aqui é o token de SESSÃO emitido pela nossa API// (HMAC, 8 horas, Aula 14) — nunca o ID token do Google, que é usado// uma única vez, no login, e descartado em seguida.constCHAVE_SESSAO="cafe-cerrado-sessao";constareaLogin=document.querySelector("#area-login");constareaUsuario=document.querySelector("#area-usuario");constnomeUsuario=document.querySelector("#nome-usuario");constfotoUsuario=document.querySelector("#foto-usuario");constbotaoSair=document.querySelector("#btn-sair");constaviso=document.querySelector("#aviso-login");constouvintes=[];functionlerSessao(){constbruto=sessionStorage.getItem(CHAVE_SESSAO);if(!bruto)returnnull;try{returnJSON.parse(bruto);}catch(erro){sessionStorage.removeItem(CHAVE_SESSAO);returnnull;}}exportfunctionobterToken(){returnlerSessao()?.token??null;}exportfunctionobterUsuario(){returnlerSessao()?.usuario??null;}// Quem quiser reagir a login/logout registra uma função aqui. É chamada// imediatamente com o estado atual e de novo a cada mudança.exportfunctionaoMudarSessao(callback){ouvintes.push(callback);callback(obterUsuario());}functionavisarOuvintes(){for(constouvinteofouvintes)ouvinte(obterUsuario());}functionpintarAreaDoUsuario(){constusuario=obterUsuario();if(usuario){nomeUsuario.textContent=usuario.nome;fotoUsuario.src=usuario.foto;}areaUsuario.hidden=!usuario;areaLogin.hidden=Boolean(usuario);}// Chamada pelo Google quando o login termina com sucesso.asyncfunctionaoReceberCredencial(resposta){aviso.textContent="Entrando…";constrequisicao=awaitfetch("/api/auth/google",{method:"POST",headers:{"Content-Type":"application/json"},// O back-end da Aula 14 espera o campo credential com o ID token do Google.body:JSON.stringify({credential:resposta.credential}),});if(!requisicao.ok){constcorpo=awaitrequisicao.json().catch(()=>({}));aviso.textContent=corpo.erro??"Não foi possível entrar. Tente de novo.";return;}// A resposta é { usuario, token }: os dois campos vão inteiros para o// sessionStorage, e é o "token" daqui que vai no cabeçalho Authorization.constsessao=awaitrequisicao.json();sessionStorage.setItem(CHAVE_SESSAO,JSON.stringify(sessao));aviso.textContent="";pintarAreaDoUsuario();avisarOuvintes();}exportfunctionsair(){sessionStorage.removeItem(CHAVE_SESSAO);window.google?.accounts?.id?.disableAutoSelect();// impede o login automático na voltaaviso.textContent="Você saiu da sua conta.";pintarAreaDoUsuario();avisarOuvintes();}asyncfunctioniniciar(){constconfiguracao=awaitfetch("/api/config").then((r)=>r.json());google.accounts.id.initialize({client_id:configuracao.googleClientId,callback:aoReceberCredencial,});google.accounts.id.renderButton(document.querySelector("#botao-google"),{type:"standard",theme:"outline",size:"large",text:"signin_with",locale:"pt-BR",});pintarAreaDoUsuario();// recarregar a página mantém a sessãoavisarOuvintes();}botaoSair.addEventListener("click",sair);// O script do Google carrega com "async": pode chegar antes ou depois deste módulo.if(window.google?.accounts?.id){iniciar();}else{window.onGoogleLibraryLoad=iniciar;}
Três decisões que valem discussão:
obterToken() devolve o token da nossa sessão, não a credencial do Google. O ID token do Google prova quem você é para o Google; ele é verificado uma vez, no POST /api/auth/google, e depois some. O que viaja em todo Authorization: Bearer … é o token HMAC que o seu servidor assinou, e é ele que o exigirLogin sabe conferir. Mandar o ID token no lugar dele resulta em 401 em toda escrita — é o erro mais caro que dá para cometer nesta unidade.
O estado mora no sessionStorage, não em variáveis do módulo. Guardar em let usuarioLogado funciona até a primeira tecla F5, quando o módulo é recarregado do zero e a pessoa "desloga sozinha". Lendo do sessionStorage a cada chamada, recarregar a página mantém a sessão — que é o que a Aula 14 prometeu.
window.google?.accounts?.id?. com encadeamento opcional. O script da GSI é async: se alguém clicar em "Sair" antes de ele terminar de baixar, google ainda não existe e um ReferenceError deixaria a pessoa presa na sessão.
A tela do cardápio tem três variáveis de estado e nada mais:
JavaScript
letprodutos=[];// o que a API devolveu na última cargaletcarregando=false;// há requisição em andamento?leterroAtual=null;// mensagem do último erro, ou null
Toda função que muda uma dessas três chama renderizar() no fim. Nenhuma função além de renderizar() toca no DOM da lista. Essa disciplina — parece exagerada em 200 linhas de código — é o que impede a tela de mentir.
Uma tela que depende de rede nunca tem dois estados ("tem dado" / "não tem"). Tem quatro, e ignorar qualquer um deles produz uma interface que trava sem explicação:
Estado
Quando acontece
O que o usuário vê
Carregando
Requisição em andamento
"Carregando o cardápio…"
Erro
A API falhou ou a rede caiu
Mensagem do erro e botão "Tentar de novo"
Vazio
Sucesso, mas zero registros
"Nenhum produto cadastrado ainda"
Conteúdo
Sucesso, com registros
Os cards
O estado vazio merece um cuidado especial: ele precisa distinguir "não há nada cadastrado" de "sua busca não achou nada". São situações diferentes, e a segunda pede um caminho de saída ("limpar busca").
4.3 textContent, nunca innerHTML, para dados do servidor¶
Você viu isso na Aula 07 e vale repetir agora que os dados vêm de fora: montar HTML por concatenação de strings com conteúdo que o usuário digitou é o caminho mais curto para um XSS.
JavaScript
// Perigoso: se alguém cadastrar um produto chamado// <img src=x onerror="alert(document.cookie)">, o navegador executa.card.innerHTML=`<h3>${produto.nome}</h3>`;// Seguro: textContent escreve texto, nunca marcação.consttitulo=document.createElement("h3");titulo.textContent=produto.nome;card.appendChild(titulo);
innerHTML = "" para limpar um container é seguro (não há dado do usuário ali) e é o que usaremos. O perigo está em inserir dados externos como HTML.
Manter dois formulários — um de cadastro e um de edição — significa manter dois HTML, duas validações e dois manipuladores de submit em sincronia. O padrão profissional é um formulário só, com um campo oculto guardando o id:
campo idvazio → o submit chama api.criar();
campo idpreenchido → o submit chama api.atualizar(id, dados).
O botão muda de rótulo ("Adicionar" ↔ "Salvar alterações") e um botão "Cancelar" aparece para sair do modo edição.
O novalidate desliga as bolhas de validação nativa do navegador para que a mensagem apareça no nosso #feedback — mas os atributos required e minlength continuam ali, disponíveis para form.checkValidity(). É o meio-termo entre a validação nativa da Aula 03 e o controle total do JavaScript.
Um detalhe que derruba muita gente: dentro de um <form>, você acessa os campos pelo name (form.nome, form.preco). Mas idnão funciona assim.
JavaScript
constform=document.querySelector("#form-produto");console.log(form.id);// "form-produto" ← o atributo id do FORMULÁRIOconsole.log(form.id.value);// undefined ← e aqui o código quebraconsole.log(form.elements.id.value);// "" ← o campo oculto, correto
id, name, action, method, children e length são propriedades que o próprio HTMLFormElement já possui — elas vencem os campos de mesmo nome. Por isso, neste projeto, sempre acesse campos por form.elements.<nome>. É mais longo e nunca surpreende.
A validação do navegador é conveniência: ela evita uma ida ao servidor e dá resposta instantânea. A validação do servidor é segurança: é a única que um curl não consegue pular. As duas coexistem, e nenhuma substitui a outra.
Quando o servidor devolve 400, ele manda também os detalhes — e é responsabilidade da tela mostrá-los, não engoli-los. Lembre do formato do controlador da Aula 13: cada item é um objeto { campo, mensagem }, não uma string solta.
JavaScript
try{awaitapi.criar(dados);}catch(erro){// erro.detalhes vem do { erro, detalhes } do controlador (seção 2.2):// [{ campo: "nome", mensagem: "O nome precisa ter ao menos 3 caracteres." }, …]constextra=erro.detalhes?.length?` (${erro.detalhes.map((d)=>`${d.campo}: ${d.mensagem}`).join("; ")})`:"";avisar(`${erro.message}${extra}`,"erro");}
Isso já é melhor que engolir o erro, mas ainda é o mínimo: com o campo em mãos, dá para pendurar cada mensagem embaixo do <input> culpado. É exatamente o desafio ⭐⭐ desta aula.
6. Excluir sem susto: confirmação e feedback acessível¶
Exclusão é a única operação irreversível do CRUD. Três cuidados mínimos:
1. Confirmar antes.confirm() resolve por hoje: é uma linha, é acessível por padrão e é bloqueante — o código só continua depois da decisão.
JavaScript
if(!confirm(`Excluir "${produto.nome}"? Esta ação não pode ser desfeita.`))return;
A limitação é séria: confirm() congela a aba inteira, não é estilizável e está em desuso em aplicações reais. O caminho moderno é o elemento <dialog> com showModal(), que já é o desafio ⭐⭐⭐ desta aula.
2. Anunciar o resultado. O parágrafo #feedback tem role="status" e aria-live="polite": quando o seu texto muda, o leitor de tela anuncia a mensagem sem interromper o que estava lendo. É a mesma técnica da Aula 06, agora valendo para o full-stack. Sem isso, quem navega por leitor de tela clica em "Excluir" e não recebe nenhuma confirmação de que algo aconteceu.
3. Cuidar do foco. Ao excluir um card, o botão que tinha o foco deixa de existir — e o foco volta para o <body>, jogando o usuário de teclado para o topo da página. Depois de recarregar a lista, devolva o foco a um ponto previsível (o campo de busca ou o título da seção).
💡 Dica
Quer saber se o seu feedback funciona mesmo? Desligue o monitor, ligue o leitor de tela (NVDA no Windows, Orca no Linux, VoiceOver no macOS com Cmd+F5) e tente cadastrar um produto só com o teclado. Cinco minutos desse exercício ensinam mais sobre acessibilidade do que uma aula inteira.
Vale parar um minuto e olhar o que você construiu. Cada clique em "Adicionar" percorre, em ordem, tudo o que foi estudado até aqui:
HTML e CSS (Unidade 1) — o formulário semântico, com <label for>, estados de foco visíveis e layout responsivo.
JavaScript no cliente (Unidade 2) — submit interceptado com preventDefault(), dados lidos do formulário, fetch com async/await.
HTTP (Aula 01) — uma requisição POST /api/produtos, com Content-Type: application/json, Authorization: Bearer … e o corpo serializado.
Express (Aulas 11–13) — a cadeia de middlewares: express.json() → log → exigirLogin → rota → controlador.
Autenticação (Aula 14) — o exigirLogin confere a assinatura HMAC do token de sessão e preenche req.usuario. (O google-auth-library já fez o trabalho dele uma única vez, lá no login.)
Validação e persistência (hoje) — o controlador valida, o repositório grava em disco de forma atômica, o servidor responde 201.
De volta ao cliente — api.criar() resolve, o estado é atualizado, renderizar() redesenha a lista e o aria-live anuncia "Produto criado".
Sete camadas, uma requisição, nenhum recarregamento de página. Isso é uma aplicação web moderna — e é exatamente o esqueleto de qualquer sistema que você vai encontrar no mercado.
🧩 Padrão de projeto em uso — Repository e Fachada¶
Dois padrões clássicos apareceram hoje, um em cada ponta:
Repository (back-end). O data/repositorio.js é um repositório: ele oferece uma coleção de objetos em memória (lerTodos, salvarTodos) e esconde completamente onde e como esses objetos são guardados. O controlador pede "todos os produtos" e não sabe se vieram de um JSON, de um MySQL ou de uma API remota. O teste do padrão é este: para migrar o Café Cerrado para um banco de dados, quantos arquivos você precisa abrir? Um.
Fachada / Facade (front-end). O objeto api do public/js/api.js é uma fachada: uma interface simples (api.criar(dados)) na frente de um subsistema chato (montar URL, serializar JSON, anexar token, interpretar status, extrair mensagem de erro). Quem usa a fachada escreve uma linha; quem a mantém concentra a complexidade num lugar só.
Repare que os dois padrões resolvem o mesmo problema com o mesmo remédio: isolar o que muda. É essa a ideia por trás da arquitetura em camadas que você vem construindo desde a Aula 12 — e é o assunto central do Nível 3, onde padrões de projeto viram conteúdo de ementa.
💻 Mão na massa — o cardápio do Café Cerrado vira administrável¶
Ao final destes passos, qualquer pessoa logada com uma conta Google consegue cadastrar, editar e excluir produtos do Café Cerrado pela interface, e os dados sobrevivem ao reinício do servidor.
Passo 1 — confirme o back-end, sem escrever nada. Nada muda no servidor hoje. Só confirme que as peças das Aulas 13 e 14 estão no lugar:
Terminal
cdcafe-cerrado-api
lsdata/controllers/middlewares/
# esperado em data/: produtos.json repositorio.js# esperado em controllers/: produtosController.js authController.js# esperado em middlewares/: registro.js erros.js exigirLogin.js
Passo 2 — confirme a API pelo testes.http, antes de tocar no front. Este é o hábito que separa depurar de adivinhar: se a API está certa, todo problema que aparecer depois é do cliente.
cafe-cerrado-api/testes.http
HTTP
@base = http://localhost:3000/api@token = cole-aqui-o-token-de-sessao### listar tudoGET {{base}}/produtos### buscar por termo, sem acento e sem caixa (acha o "Frappê de Café")GET {{base}}/produtos?q=cafe### filtrar e ordenar (só os doces, do mais barato ao mais caro)GET {{base}}/produtos?categoria=doces&ordenar=preco### criar sem token — deve responder 401POST {{base}}/produtosContent-Type: application/json{ "nome": "Teste sem token", "categoria": "cafes", "preco": 1 }### criar com token — deve responder 201POST {{base}}/produtosContent-Type: application/jsonAuthorization: Bearer {{token}}{ "nome": "Suco de Cupuaçu", "categoria": "geladas", "preco": 10.5, "descricao": "Polpa batida com água gelada e um fio de mel.", "imagem": "img/suco-cupuacu.jpg" }### corpo inválido — deve responder 400 com detalhes [{ campo, mensagem }]POST {{base}}/produtosContent-Type: application/jsonAuthorization: Bearer {{token}}{ "nome": "ab", "categoria": "sobremesa", "preco": "muito caro" }### excluir o produto criado acima — deve responder 204 sem corpoDELETE {{base}}/produtos/11Authorization: Bearer {{token}}
Para preencher o @token: faça login no site, abra o console do navegador e rode
Copie o valor sem as aspas e cole na variável. É o mesmo procedimento da Aula 14 — e é o token de sessão, o que o seu servidor assinou, válido por 8 horas. O ID token do Google não serve aqui: o exigirLogin confere a assinatura HMAC da sua própria API e responderia 401 para qualquer outra coisa.
Passo 3 — atualize o auth.js para a versão com ouvintes da seção 3.3.
Passo 4 — crie a camada de API do cliente:public/js/api.js, com o conteúdo da seção 3.2.
Passo 5 — acrescente a área de gestão ao HTML. Cole o trecho da seção 5.1 dentro do <main> de public/index.html, logo acima da lista do cardápio, e confirme que a busca e o container da lista existem com estes ids:
trecho de public/index.html — dentro de <main>
HTML
<sectionaria-labelledby="titulo-cardapio"><h2id="titulo-cardapio">Cardápio</h2><divclass="campo"><labelfor="busca">Buscar no cardápio</label><inputid="busca"name="busca"type="search"placeholder="café, bolo, pão…"></div><divid="lista-produtos"class="grade-produtos"></div></section>
Se a sua página já tinha um campo de busca da Aula 08 com outro id, mantenha o seu e ajuste o seletor no app.js — o que não pode é ter dois campos de busca disputando a mesma lista.
type="module" já se comporta como defer (o script só roda depois do HTML estar montado), então o atributo defer é ignorado ali e não precisa ser escrito.
Passo 6 — escreva o app.js completo.
cafe-cerrado-api/public/js/app.js
JavaScript
import{api}from"./api.js";import{aoMudarSessao,obterUsuario}from"./auth.js";// ---------- elementos da tela ----------constlistaEl=document.querySelector("#lista-produtos");constformEl=document.querySelector("#form-produto");constfeedbackEl=document.querySelector("#feedback");constbuscaEl=document.querySelector("#busca");constareaGestaoEl=document.querySelector("#area-gestao");constbtnSalvarEl=document.querySelector("#btn-salvar");constbtnCancelarEl=document.querySelector("#btn-cancelar");// ---------- estado: a única fonte de verdade da tela ----------letprodutos=[];letcarregando=false;leterroAtual=null;constmoeda=newIntl.NumberFormat("pt-BR",{style:"currency",currency:"BRL"});// ---------- feedback acessível ----------functionavisar(mensagem,tipo="sucesso"){feedbackEl.textContent=mensagem;feedbackEl.className=`feedback feedback--${tipo}`;}// ---------- render ----------functionparagrafoDeEstado(texto){constp=document.createElement("p");p.className="estado-lista";p.textContent=texto;returnp;}functioncriarCard(produto){constcard=document.createElement("article");card.className="card-produto";card.dataset.id=produto.id;consttitulo=document.createElement("h3");titulo.textContent=produto.nome;constpreco=document.createElement("p");preco.className="preco";preco.textContent=moeda.format(produto.preco);constdescricao=document.createElement("p");descricao.className="descricao";descricao.textContent=produto.descricao||"Sem descrição.";constcategoria=document.createElement("p");categoria.className="categoria";categoria.textContent=produto.categoria;card.append(titulo,preco,categoria,descricao);// Botões de escrita só para quem está logado. Isso é conforto de interface:// quem protege de verdade é o middleware exigirLogin, no servidor.if(obterUsuario()){constacoes=document.createElement("div");acoes.className="acoes-card";constbtnEditar=document.createElement("button");btnEditar.type="button";btnEditar.textContent="Editar";btnEditar.setAttribute("aria-label",`Editar ${produto.nome}`);btnEditar.addEventListener("click",()=>entrarEmModoEdicao(produto));constbtnExcluir=document.createElement("button");btnExcluir.type="button";btnExcluir.className="perigo";btnExcluir.textContent="Excluir";btnExcluir.setAttribute("aria-label",`Excluir ${produto.nome}`);btnExcluir.addEventListener("click",()=>excluirProduto(produto));acoes.append(btnEditar,btnExcluir);card.appendChild(acoes);}returncard;}functionrenderizar(){listaEl.innerHTML="";if(carregando){listaEl.appendChild(paragrafoDeEstado("Carregando o cardápio…"));return;}if(erroAtual){listaEl.appendChild(paragrafoDeEstado(erroAtual));constbotao=document.createElement("button");botao.type="button";botao.textContent="Tentar de novo";botao.addEventListener("click",carregarProdutos);listaEl.appendChild(botao);return;}if(produtos.length===0){consthouveBusca=buscaEl.value.trim()!=="";listaEl.appendChild(paragrafoDeEstado(houveBusca?`Nada encontrado para "${buscaEl.value.trim()}".`:"Nenhum produto cadastrado ainda."));return;}// Monta tudo fora da árvore e insere de uma vez: um único reflow.constfragmento=document.createDocumentFragment();for(constprodutoofprodutos)fragmento.appendChild(criarCard(produto));listaEl.appendChild(fragmento);}// ---------- operações ----------asyncfunctioncarregarProdutos(){carregando=true;erroAtual=null;renderizar();try{produtos=awaitapi.listar({q:buscaEl.value.trim()});}catch(erro){produtos=[];erroAtual=erro.message;}finally{carregando=false;renderizar();}}functionentrarEmModoEdicao(produto){formEl.elements.id.value=produto.id;formEl.elements.nome.value=produto.nome;formEl.elements.preco.value=produto.preco;formEl.elements.categoria.value=produto.categoria??"";formEl.elements.descricao.value=produto.descricao??"";btnSalvarEl.textContent="Salvar alterações";btnCancelarEl.hidden=false;formEl.elements.nome.focus();avisar(`Editando "${produto.nome}".`,"info");}functionsairDoModoEdicao(){formEl.reset();formEl.elements.id.value="";btnSalvarEl.textContent="Adicionar";btnCancelarEl.hidden=true;}asyncfunctionexcluirProduto(produto){if(!confirm(`Excluir "${produto.nome}"? Esta ação não pode ser desfeita.`))return;try{awaitapi.remover(produto.id);avisar(`"${produto.nome}" foi excluído.`);awaitcarregarProdutos();buscaEl.focus();// o botão clicado sumiu: devolve o foco a um ponto estável}catch(erro){avisar(erro.message,"erro");}}// ---------- eventos ----------formEl.addEventListener("submit",async(evento)=>{evento.preventDefault();if(!formEl.checkValidity()){avisar("Preencha nome (3 letras ou mais) e preço.","erro");formEl.elements.nome.focus();return;}constid=formEl.elements.id.value;constdados={nome:formEl.elements.nome.value.trim(),preco:Number(formEl.elements.preco.value),// o input devolve string!categoria:formEl.elements.categoria.value.trim(),descricao:formEl.elements.descricao.value.trim(),};btnSalvarEl.disabled=true;// evita duplo clique criando dois registrostry{if(id){awaitapi.atualizar(id,dados);avisar(`"${dados.nome}" foi atualizado.`);}else{awaitapi.criar(dados);avisar(`"${dados.nome}" foi criado.`);}sairDoModoEdicao();awaitcarregarProdutos();}catch(erro){constextra=erro.detalhes?.length?` (${erro.detalhes.join("; ")})`:"";avisar(`${erro.message}${extra}`,"erro");}finally{btnSalvarEl.disabled=false;}});btnCancelarEl.addEventListener("click",()=>{sairDoModoEdicao();avisar("Edição cancelada.","info");});// Busca com atraso: só consulta a API 400 ms depois da última tecla.lettemporizadorBusca=null;buscaEl.addEventListener("input",()=>{clearTimeout(temporizadorBusca);temporizadorBusca=setTimeout(carregarProdutos,400);});// Login e logout mudam a interface: mostra/esconde o formulário e redesenha// os cards (com ou sem botões de ação).aoMudarSessao((usuario)=>{areaGestaoEl.hidden=!usuario;if(!usuario)sairDoModoEdicao();renderizar();});carregarProdutos();
Passo 7 — estilo mínimo para os estados. Acrescente ao seu CSS (o arquivo que você mantém desde a Aula 04):
npmrundev
# abra http://localhost:3000 (NÃO abra o arquivo pelo disco)
Como testar — o resultado esperado, passo a passo:
Sem login, a lista carrega e nenhum botão "Editar"/"Excluir" aparece; a área de gestão está oculta.
Faça login com o Google: seu nome e sua foto aparecem, o formulário aparece e os cards ganham os botões.
Cadastre "Torta de Limão", 9.90, categoria doces. O feedback anuncia a criação e o card aparece na lista sem recarregar a página. Na aba Network do DevTools: POST /api/produtos com status 201.
Clique em "Editar" nesse card: o formulário se preenche, o botão vira "Salvar alterações" e o "Cancelar" aparece. Mude o preço para 10.50 e salve — PUT com 200, card atualizado.
Digite torta na busca: a requisição só sai 400 ms depois da última tecla (confirme na aba Network) e a lista filtra — e traz também a "Torta de Frango", porque a busca do servidor casa por trecho do nome.
Exclua o produto e confirme no confirm(): DELETE com 204, o card some, o feedback anuncia.
Derrube o servidor com Ctrl+C, suba de novo com npm run dev e recarregue: os produtos continuam lá. Abra data/produtos.json e confirme.
Saia da conta ("Sair"): os botões de escrita somem, a área de gestão se esconde e a listagem continua funcionando.
A1. Por que fetch("/api/produtos") funciona sem nenhuma configuração de CORS neste projeto, enquanto o fetch da Aula 10 (JSONPlaceholder) dependia de um cabeçalho do servidor? Responda citando os três componentes de uma "origem".
A2. O que acontece se o servidor responder 204 e o cliente chamar resposta.json()? Qual mensagem exata aparece no console? Qual linha do api.js evita isso?
A3. Preveja a saída. Dado const form = document.querySelector("#form-produto") com um campo <input type="hidden" name="id" value="7">, o que imprime cada linha?
Descreva, em duas frases, o que acontece na tela em cada um dos dois caminhos (sucesso e falha). Em seguida, diga o que muda de comportamento se a palavra await for apagada da linha do api.remover(id).
A6. Cite os quatro estados de uma tela que depende de rede e escreva a mensagem que você mostraria em cada um, no seu projeto autoral.
A7. Por que proximoId usa Math.max(...lista.map((p) => p.id)) + 1 em vez de lista.length + 1? Dê um exemplo concreto de sequência de operações que quebra a segunda opção.
A8. Qual é a diferença entre a gravação fs.writeFile(ARQUIVO, dados) e a sequência writeFile(tmp) + rename(tmp, ARQUIVO)? Em que cenário exato a segunda salva os seus dados?
A9. O que evento.preventDefault() impede no submit do formulário? O que aconteceria na tela sem essa linha?
A10. Explique por que btnSalvarEl.disabled = true antes da requisição e false no finally é melhor do que desabilitar o botão só depois da resposta.
B1. Acrescente ao cardápio um filtro por categoria: um <select id="a15-filtro-categoria"> preenchido dinamicamente com as categorias distintas dos produtos carregados, enviado à API como ?categoria=. Implemente o filtro também no controlador listar, combinando-o com o ?q= já existente.
Resultado esperado: escolher "doces" mostra só os doces; escolher "Todas" volta à lista inteira; buscar "bolo" com "doces" selecionado aplica os dois filtros juntos, e a URL da requisição na aba Network mostra ?q=bolo&categoria=doces.
Dica
Para as categorias distintas: [...new Set(produtos.map((p) => p.categoria))].sort(). Cuidado com a ordem de execução — se você preencher o <select> a partir da lista já filtrada, as opções somem conforme o usuário filtra. Guarde as categorias de uma carga sem filtro, ou crie um endpoint GET /api/categorias.
B2. Implemente um contador de caracteres para o campo "Descrição": limite de 140 caracteres, contador atualizado a cada tecla, e bloqueio do envio (com mensagem no #feedback) se o limite for ultrapassado. Adicione a mesma verificação na função validar do servidor.
Resultado esperado: o contador mostra "0/140" e vai subindo; ao passar de 140, ele fica vermelho e o submit é recusado com mensagem clara. Um POST com 200 caracteres pelo testes.http recebe 400 com o detalhe correspondente.
Dica
O evento certo é input (dispara a cada tecla), não change (só ao sair do campo). Associe o contador ao campo com aria-describedby para que o leitor de tela o anuncie. No servidor, a checagem entra na mesma lista erros das outras regras — assim a mensagem chega ao front pelo detalhes sem nenhum código novo.
B3. Troque as escutas individuais dos botões de card por delegação de eventos: um único addEventListener("click") no #lista-produtos, usando evento.target.closest("button") e o data-id do card para descobrir qual produto foi clicado.
Resultado esperado: editar e excluir continuam funcionando exatamente igual, mas o criarCard fica mais curto e a lista passa a ter 1 listener em vez de 2 por produto. Confirme no painel Elements → Event Listeners do DevTools.
Dica
Marque cada botão com dataset.acao = "editar" ou "excluir". No listener: const botao = evento.target.closest("button[data-acao]"); if (!botao) return; e depois const id = Number(botao.closest(".card-produto").dataset.id). Para achar o produto, produtos.find((p) => p.id === id).
B4. Adicione um indicador de carregamento no próprio botão: enquanto a requisição de salvar estiver em andamento, o texto do botão vira "Salvando…" e volta ao normal no finally. Faça o mesmo para a exclusão, desabilitando os dois botões daquele card.
Resultado esperado: com o throttling "Slow 3G" ligado na aba Network, dá para ver o botão mudar de texto e voltar. Nenhum duplo clique cria dois registros.
Dica
Guarde o texto original antes de trocar (const rotulo = botao.textContent) e restaure no finally. Cuidado: se você recarregar a lista no sucesso, o botão do card excluído nem existe mais — proteja o restore com uma verificação de que o elemento ainda está no documento (botao.isConnected).
C1. Implemente o desfazer da exclusão: ao excluir um produto, em vez de sumir para sempre, o feedback exibe "Produto excluído · Desfazer" por 6 segundos. Clicar em "Desfazer" recria o produto com os mesmos dados (id novo é aceitável) e cancela o sumiço definitivo.
Dica
Guarde uma cópia do objeto antes de chamar api.remover. O botão "Desfazer" é um <button> inserido dentro do #feedback (que é aria-live, então será anunciado). Use setTimeout para limpar a oferta depois de 6 s e clearTimeout se o usuário clicar antes. Reflita: existe alguma forma de desfazer sem recriar, ou seja, sem gerar um id novo? O que mudaria no servidor?
C2. Faça a tela sobreviver à perda de conexão: se o fetch falhar por rede (status 0 na ErroDeApi), mostre um aviso fixo "Você está offline — as alterações não foram salvas" e tente recarregar a lista automaticamente a cada 5 segundos até voltar.
Dica
window.addEventListener("online", ...) e "offline" avisam mudanças de conectividade, e navigator.onLine dá o estado atual — mas nenhum dos dois detecta "o servidor caiu com a rede funcionando", que é o caso mais comum em desenvolvimento. Combine os eventos com a sua própria detecção pelo status 0. Pare o setInterval assim que uma carga der certo.
Um colega "otimizou" a exclusão para não precisar buscar a lista de novo no servidor: em vez de recarregar, ele remove o item direto do array local. Funciona lindamente — até você digitar algo na busca. Com o filtro bolo ativo, excluir o único resultado faz outro produto sumir da lista quando você limpa a busca. O trecho alterado é este:
Reproduza o bug, explique-o e conserte-o sem voltar a fazer uma requisição extra a cada exclusão.
Critérios de pronto
Um comentário de até 4 linhas no topo da função descreve a sequência exata de cliques que reproduz o bug e por que o índice está errado.
Excluir funciona igual com e sem busca ativa, e nenhum produto some sem ter sido excluído.
A correção não faz uma requisição GET extra por exclusão (a única requisição é o DELETE).
O estado vazio aparece corretamente quando o último produto da lista filtrada é excluído.
Pistas
Na aba Console, imprima produtos antes e depois do splice com a busca ativa. O array que está na tela é o mesmo que está na memória?
splice trabalha por posição; filter trabalha por conteúdo. Qual dos dois não depende de a lista estar na mesma ordem da tela?
produtos = produtos.filter((p) => p.id !== produto.id) remove pelo identificador, não pela posição — e funciona mesmo que a lista tenha sido filtrada ou reordenada.
Vale a pena passar indice como parâmetro? Que informação de um produto nunca muda e é suficiente para identificá-lo?
Ligue o throttling "Slow 3G" na aba Network, digite cafe rápido no campo de busca e observe: a resposta de caf pode chegar depois da resposta de cafe, e a tela termina mostrando o resultado errado, para um termo que já não está mais no campo. É a race condition clássica de busca incremental — e ela existe em produção em muito site grande. Resolva de verdade: cancele a requisição obsoleta.
Critérios de pronto
Digitar rápido nunca deixa a tela com um resultado que não corresponde ao texto atual do campo, mesmo com "Slow 3G".
Requisições obsoletas aparecem como canceled na aba Network — você não está apenas ignorando a resposta, está abortando a requisição.
O AbortError não vira mensagem de erro na tela (abortar foi decisão sua, não falha).
Um comentário registra quantas requisições saem ao digitar "cafezinho" letra por letra, antes e depois do seu ajuste.
Pistas
Procure AbortController na MDN: const controlador = new AbortController() e passe signal: controlador.signal nas opções do fetch.
Guarde o controlador da requisição em andamento numa variável de módulo; antes de disparar a próxima, chame controlador.abort() na anterior.
Quando um fetch é abortado, ele rejeita com um erro cujo name é "AbortError" — trate esse caso separadamente no catch do api.js ou do carregarProdutos.
O debounce de 400 ms reduz o problema, mas não elimina: uma rede lenta o suficiente ainda inverte a ordem. Os dois mecanismos são complementares.
O servidor já faz a parte dele: desde a Aula 13, o 400 devolve detalhes como lista de objetos { "campo": "preco", "mensagem": "…" }. A tela é que desperdiça essa informação — ela junta tudo num parágrafo só: "Dados inválidos (nome: O nome precisa ter ao menos 3 caracteres.; preco: O preço precisa ser um número maior que zero.)". Formulários bons colocam cada mensagem embaixo do campo culpado. Aproveite o campo que já vem pronto e leve cada mensagem ao lugar certo, do jeito que um leitor de tela entende.
Critérios de pronto
Nenhuma mensagem de validação sobra no parágrafo genérico: toda entrada de detalhes é levada ao seu campo, e o testes.http mostra o formato { campo, mensagem } que o servidor devolve.
Cada campo com erro recebe aria-invalid="true" e um <p> de mensagem associado por aria-describedby; o primeiro campo com erro recebe o foco.
Corrigir o campo limpa a mensagem e o aria-invalid daquele campo, sem recarregar nada.
Um cenário no testes.http prova que a resposta continua útil para um cliente que não é o seu front (a mensagem faz sentido sozinha).
Pistas
Não mexa no controlador: validarProduto já empurra { campo, mensagem } para erros. Todo o trabalho é do lado do cliente.
No cliente, o ErroDeApi já carrega detalhes; percorra a lista e use document.querySelector(#${detalhe.campo}) para achar o input.
aria-describedby aceita o id de qualquer elemento; crie os <p> de erro uma vez no HTML, vazios e escondidos, em vez de criá-los a cada falha.
Para limpar, o evento input de cada campo é suficiente — não espere o próximo envio.
O confirm() do navegador congela a aba inteira, não pode ser estilizado, não cabe na identidade visual do Café Cerrado e some sem deixar rastro em capturas de tela. Toda aplicação séria usa um diálogo próprio — e a plataforma web tem um elemento nativo para isso desde 2022: <dialog>. Substitua a confirmação de exclusão por um diálogo modal acessível de verdade, e prove que ele é acessível.
Critérios de pronto
A exclusão usa um <dialog> aberto com showModal(), com título, nome do produto e dois botões (Cancelar / Excluir).
Funciona 100% por teclado: Esc fecha, Tab circula apenas dentro do diálogo, e ao fechar o foco volta ao botão que abriu.
A promessa de exclusão só resolve depois da decisão do usuário — o excluirProduto continua legível, sem callbacks aninhados.
Um arquivo docs/acessibilidade-dialogo.md registra o teste feito com leitor de tela (qual, em que sistema) e o que foi anunciado ao abrir o diálogo.
O diálogo é reutilizável: uma função confirmar({ titulo, mensagem, rotuloConfirmar }) serve para qualquer confirmação futura do projeto.
Pistas
showModal() (e não show()) é o que ativa o backdrop, o ::backdrop estilizável, o fechamento por Esc e o confinamento do foco — tudo isso de graça, nativamente.
Para transformar o diálogo numa promessa: return new Promise((resolver) => { dialogo.addEventListener("close", () => resolver(dialogo.returnValue === "confirmar"), { once: true }); dialogo.showModal(); }).
<button value="confirmar"> dentro de um <form method="dialog"> fecha o diálogo e define returnValue sem uma linha de JavaScript.
Guarde document.activeElement antes de abrir e chame .focus() nele depois de fechar — o <dialog> devolve o foco sozinho na maioria dos navegadores, mas não conte com isso quando o elemento original tiver sido removido do DOM.
Para o teste com leitor de tela: NVDA (Windows, gratuito), Orca (Linux, já instalado no GNOME) ou VoiceOver (macOS, Cmd+F5).
No projeto autoral, replique tudo o que foi feito hoje no Café Cerrado:
Garanta que o seu recurso tem a camada data/repositorio.js com gravação atômica e que os controladores só falam com ela.
Crie public/js/api.js com a fachada de acesso à sua API (listar, obter, criar, atualizar, remover) e o tratamento centralizado de erro.
Implemente na interface o CRUD completo: formulário único com modo edição, exclusão com confirmação, feedback com aria-live e recarga da lista após cada operação.
Trate os quatro estados da tela (carregando, erro, vazio, conteúdo), com mensagens escritas para o seu domínio — nada de "carregando produtos" num projeto de quadras esportivas.
Oculte os controles de escrita para visitantes não logados, lembrando que a proteção real continua sendo o exigirLogin no servidor.
Atualize o testes.http com os cenários novos: criar com token, criar sem token (401), corpo inválido (400), excluir (204) e excluir de novo (404).
Faça o teste do reinício: crie três registros, derrube o servidor, suba de novo e confirme que continuam lá.
Critério de pronto: com o servidor rodando, é possível fazer login, criar, editar e excluir registros pela interface, sem nenhum recarregamento de página; os dados sobrevivem ao reinício; e o testes.http cobre os cinco cenários da etapa 6.
Guarde no seu repositório: commit + push. Confirme, antes do push, que .env e node_modules/não estão no commit.
Ao final desta aula, o seu repositório precisa ter:
[ ] data/repositorio.js e o controlador das Aulas 13 e 14 intactos: filtros, ordenação, lista branca de categorias e detalhes: [{ campo, mensagem }] continuam de pé.
[ ] Controladores async, sem readFile/writeFile espalhados, respondendo 200, 201, 204, 400 e 404 corretamente.
[ ] public/js/api.js com a fachada e a classe ErroDeApi carregando status e detalhes.
[ ] public/js/auth.js como módulo ES, exportando obterToken (o token de sessão), obterUsuario e aoMudarSessao, com o Client ID vindo de GET /api/config.
[ ] public/js/app.js com estado, renderizar() e os quatro estados da tela.
[ ] Formulário único com modo edição funcionando (criar e editar no mesmo lugar).
[ ] Exclusão com confirmação, feedback anunciado por aria-live e foco tratado.
[ ] Dados sobrevivendo ao reinício do servidor.
[ ] testes.http versionado, cobrindo sucesso, 400, 401 e 404.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — integração front-end e back-end.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — construção de uma aplicação completa com Node.js.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — camada de dados e organização de aplicações que crescem.
Na próxima aula, cada produto ganha um dono: o e-mail extraído do token verificado passa a marcar quem criou cada registro, e a API aprende a diferença entre "não sei quem você é" (401) e "sei quem você é, mas isso não é seu" (403). É também a aula de encerramento desta trilha, com o roteiro completo de auto-teste, o Marco 3 e os caminhos para continuar depois daqui.
Nível 2Unidade 3 · Web dinâmica server-side3 aulas de 50 min + 1 h EADFecha a unidade · Marco 3
Aula 16 — CRUD completo com autenticação e entrega final
Nível 2 — Desenvolvimento Web · WebLab
Última aula desta trilha. As duas metades do sistema já existem: o CRUD com persistência e o login do Google. Hoje elas se encontram no detalhe que separa um projeto de aula de um sistema de verdade — saber de quem é cada registro. Ao fim destes 150 minutos, a sua aplicação vai recusar com 401 quem não está logado, recusar com 403 quem está logado mas mexe no que não é seu, e rodar do zero numa pasta limpa com dois comandos. É também o dia do Marco 3, o fechamento do semestre e a hora de olhar para o que vem depois.
[ ] cafe-cerrado-api com CRUD completo, persistência em arquivo e a camada data/repositorio.js (Aula 15).
[ ] Front consumindo a própria API por public/js/api.js, com formulário de criar/editar e exclusão funcionando (Aula 15).
[ ] Login Google operante, .env com GOOGLE_CLIENT_ID fora do Git e o middleware exigirLogin nas rotas de escrita (Aula 14).
[ ] testes.http versionado, com pelo menos um cenário de cada status (200, 201, 204, 400, 401, 404).
[ ] Uma segunda conta Google disponível (uma pessoal e outra de trabalho/estudo, por exemplo) ou peça a outra pessoa para logar com a conta dela por um minuto — hoje vamos precisar de duas identidades diferentes.
Na aula passada a interface ganhou o CRUD inteiro: formulário único para criar e editar, exclusão com confirmação, feedback anunciado por aria-live e dados sobrevivendo ao reinício do servidor. Ficou uma brecha, e ela é grave: qualquer pessoa logada — com qualquer conta Google do planeta — pode editar e excluir os produtos cadastrados por qualquer outra. Hoje fechamos essa brecha, rodamos o roteiro de auto-teste da entrega e encerramos esta trilha.
A Aula 14 respondeu à primeira pergunta. Lá, o authController usou o google-auth-libraryuma única vez, no login, para conferir o ID token do Google e emitir uma sessão própria — um token assinado com HMAC pelo seu servidor, válido por 8 horas. O middleware exigirLogin confere a assinatura dessa sessão a cada requisição e preenche req.usuario com dados em que dá para confiar: nome, e-mail e foto, que vieram do Google no momento do login. Isso é autenticação.
Falta a segunda pergunta. Hoje, com um token válido de qualquer conta Google, dá para apagar o cardápio inteiro do Café Cerrado. O sistema sabe quem você é e não faz absolutamente nada com essa informação. Decidir o que cada identidade pode fazer é autorização — e é a parte que quase todo projeto de aula esquece.
Texto
autenticação → "quem é você?" → 401 quando não sei
autorização → "você pode fazer isso?" → 403 quando sei quem é, mas não pode
Não autenticado (o nome do status é histórico e enganoso)
Nenhum token, token expirado ou assinatura inválida
403 Forbidden
Autenticado, mas sem permissão
Editar ou excluir um produto de outra pessoa
404 Not Found
O recurso não existe
PUT /api/produtos/9999
O 401 costuma vir acompanhado de uma instrução: "faça login". O 403 é diferente — repetir o login não muda nada, porque o problema não é a identidade, é a permissão. Por isso a interface reage de formas distintas: no 401, abre o botão de login; no 403, explica que aquele item é de outra pessoa.
💡 Dica
Existe uma escola que responde 404 em vez de 403 para recursos que existem mas não são seus. O argumento é bom: um 403 confirma que o recurso existe, o que já é informação para quem está sondando o sistema (dá para descobrir quantos registros existem só variando o id). Para o Café Cerrado, cujo cardápio é público, o 403 é mais honesto e mais didático. Num sistema de prontuários médicos, o 404 seria a escolha certa. Saber justificar a decisão vale mais do que decorar a regra.
Regra de ouro: autorização é regra de negócio, e regra de negócio mora no servidor. Esconder um botão no HTML não protege nada — a página é do usuário, ele pode reescrevê-la no DevTools em cinco segundos, ou simplesmente ignorá-la e mandar a requisição pelo curl. A interface orienta; o servidor garante.
🔬 Investigue
Abra o site logado, clique com o botão direito num card e escolha "Inspecionar". No painel Elements, encontre um <div class="acoes-card"> escondido (ou apague um hidden de qualquer elemento) e veja o botão aparecer. Agora clique nele. O que acontece? Depois desta aula, a resposta será 403 e nada mudará no banco de dados. Antes dela, a exclusão acontece. É essa a diferença entre uma interface bonita e um sistema seguro — e é literalmente o mesmo experimento que um invasor faria primeiro.
🧠 Você sabia?
A falha que estamos corrigindo hoje tem nome: IDOR (Insecure Direct Object Reference, referência direta a objeto insegura). É quando o sistema aceita um identificador vindo do cliente (/api/produtos/7) e age sobre ele sem checar se aquele cliente tem direito àquele objeto. Na lista OWASP Top 10 de 2021 — o ranking das falhas de segurança web mais críticas, mantido por uma fundação sem fins lucrativos —, a categoria que abriga o IDOR (Broken Access Control) subiu para o primeiro lugar, presente em 94% das aplicações testadas. Ou seja: a linha de código que você vai escrever hoje é, estatisticamente, a correção de segurança mais necessária da web.
O middleware exigirLogin já deixa req.usuario preenchido com os dados da sessão verificada — o e-mail veio do Google no login e viajou dentro de um token que só o seu servidor sabe assinar. Basta usá-lo no momento da criação:
JavaScript
constnovo={id:repo.proximoId(produtos),nome:req.body.nome.trim(),categoria:req.body.categoria?.trim()||"geral",preco:req.body.preco,descricao:req.body.descricao?.trim()||"",dono:req.usuario.email,// vem do TOKEN verificado — jamais de req.bodycriadoEm:newDate().toISOString(),};
⚠️ Atenção
A linha dono: req.usuario.email é a linha mais importante desta aula. Se ela fosse dono: req.body.dono, qualquer pessoa poderia mandar { "dono": "professor@exemplo.br" } e criar registros em nome de outra — e depois nem editá-los conseguiria. Vale como princípio geral: tudo que identifica o autor de uma ação vem do token verificado; tudo que o cliente manda no corpo é palpite até ser validado.
criadoEm guarda o instante em ISO 8601 ("2030-03-14T18:32:05.123Z"), o formato que new Date() entende de volta sem ambiguidade e que ordena corretamente como texto. Nunca grave data como "14/03 às 18h32": formatar é trabalho da interface, com Intl.DateTimeFormat.
Os produtos cadastrados antes de hoje não têm o campo dono. Sem tratamento, eles ficariam impossíveis de editar: undefined !== "voce@gmail.com" é sempre verdade, então toda tentativa responderia 403. Um script resolve isso de uma vez:
cafe-cerrado-api/scripts/definir-dono.js
JavaScript
// Uso: node scripts/definir-dono.js seu-email@gmail.com// Atribui um dono aos produtos criados antes da regra de autorização existir.constrepo=require("../data/repositorio");asyncfunctionprincipal(){constemail=process.argv[2];if(!email){console.error("Informe o e-mail: node scripts/definir-dono.js voce@gmail.com");process.exit(1);}constprodutos=awaitrepo.lerTodos();letajustados=0;for(constprodutoofprodutos){if(!produto.dono){produto.dono=email;produto.criadoEm=produto.criadoEm??newDate().toISOString();ajustados+=1;}}awaitrepo.salvarTodos(produtos);console.log(`${ajustados} produto(s) passaram a pertencer a ${email}.`);}principal().catch((erro)=>{console.error("Falha na migração:",erro);process.exit(1);});
Terminal
nodescripts/definir-dono.jsseu-email@gmail.com
# esperado: 6 produto(s) passaram a pertencer a seu-email@gmail.com.
Guarde esse script no repositório. Ele é a versão artesanal do que, num projeto com banco de dados, se chama migração: um passo versionado que leva os dados de um formato ao seguinte. Você vai reencontrar o conceito no Nível 3, com nome e ferramenta próprios.
Duas linhas de checagem entram em atualizar e remover. Como a regra é idêntica nas duas, ela vira uma função — assim, no dia em que administradores puderem editar tudo, você muda um lugar só.
constrepositorio=require("../data/repositorio");// A lista branca de categorias do cardápio (Aula 13).constCATEGORIAS=["cafes","geladas","salgados","doces"];// Deixa o texto comparável: sem acento, sem maiúscula, sem espaço nas pontas.functionnormalizar(texto){returnString(texto??"").normalize("NFD").replace(/[\u0300-\u036f]/g,"").toLowerCase().trim();}// Converte o :id da rota. Se não for inteiro positivo, já responde 400.functionidDaRota(req,res){constid=Number(req.params.id);if(!Number.isInteger(id)||id<=0){res.status(400).json({erro:"O id precisa ser um número inteiro positivo."});returnnull;}returnid;}// Valida e normaliza o corpo (Aula 13). Com { parcial: true }, ignora ausentes.functionvalidarProduto(corpo={},{parcial=false}={}){consterros=[];constdados={};if(corpo.nome!==undefined||!parcial){constnome=typeofcorpo.nome==="string"?corpo.nome.trim():"";if(nome.length<3){erros.push({campo:"nome",mensagem:"O nome precisa ter ao menos 3 caracteres."});}else{dados.nome=nome;}}if(corpo.preco!==undefined||!parcial){constpreco=Number(corpo.preco);if(!Number.isFinite(preco)||preco<=0){erros.push({campo:"preco",mensagem:"O preço precisa ser um número maior que zero."});}else{dados.preco=Math.round(preco*100)/100;}}if(corpo.categoria!==undefined||!parcial){constcategoria=typeofcorpo.categoria==="string"?normalizar(corpo.categoria):"";if(!CATEGORIAS.includes(categoria)){erros.push({campo:"categoria",mensagem:`A categoria precisa ser uma destas: ${CATEGORIAS.join(", ")}.`,});}else{dados.categoria=categoria;}}if(corpo.descricao!==undefined){dados.descricao=String(corpo.descricao).trim();}elseif(!parcial){dados.descricao="";}if(corpo.imagem!==undefined){dados.imagem=String(corpo.imagem).trim();}elseif(!parcial){dados.imagem="";}return{erros,dados};}// Única definição da regra de autorização do projeto.functionpodeAlterar(produto,usuario){returnproduto.dono===usuario.email;}// GET /api/produtos?q=cafe&categoria=cafes&ordenar=precoexports.listar=async(req,res)=>{const{q,categoria,ordenar}=req.query;letlista=awaitrepositorio.lerTodos();if(typeofcategoria==="string"&&categoria!==""){constalvo=normalizar(categoria);lista=lista.filter((produto)=>normalizar(produto.categoria)===alvo);}if(typeofq==="string"&&q!==""){consttermo=normalizar(q);lista=lista.filter((produto)=>normalizar(produto.nome).includes(termo)||normalizar(produto.descricao).includes(termo),);}if(ordenar==="preco"){lista=[...lista].sort((a,b)=>a.preco-b.preco);}elseif(ordenar==="-preco"){lista=[...lista].sort((a,b)=>b.preco-a.preco);}elseif(ordenar==="nome"){lista=[...lista].sort((a,b)=>a.nome.localeCompare(b.nome,"pt-BR"));}res.json(lista);};exports.obter=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constproduto=lista.find((item)=>item.id===id);if(!produto)returnres.status(404).json({erro:`Produto ${id} não encontrado.`});res.json(produto);};exports.criar=async(req,res)=>{const{erros,dados}=validarProduto(req.body);if(erros.length>0){returnres.status(400).json({erro:"Dados inválidos.",detalhes:erros});}constlista=awaitrepositorio.lerTodos();constnovo={id:repositorio.proximoId(lista),...dados,dono:req.usuario.email,// do token verificado, NUNCA do corpocriadoEm:newDate().toISOString(),};lista.push(novo);awaitrepositorio.salvarTodos(lista);res.status(201).location(`/api/produtos/${novo.id}`).json(novo);};exports.atualizar=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1)returnres.status(404).json({erro:`Produto ${id} não encontrado.`});if(!podeAlterar(lista[indice],req.usuario)){returnres.status(403).json({erro:"Este produto foi cadastrado por outra pessoa."});}const{erros,dados}=validarProduto(req.body,{parcial:true});if(erros.length>0){returnres.status(400).json({erro:"Dados inválidos.",detalhes:erros});}if(Object.keys(dados).length===0){returnres.status(400).json({erro:"Envie ao menos um campo para atualizar."});}// O id, o dono e a data de criação são preservados de propósito: nenhum// deles pode ser trocado por um campo vindo do corpo da requisição.constatualizado={...lista[indice],...dados,id,dono:lista[indice].dono,criadoEm:lista[indice].criadoEm,atualizadoEm:newDate().toISOString(),};lista[indice]=atualizado;awaitrepositorio.salvarTodos(lista);res.json(atualizado);};exports.remover=async(req,res)=>{constid=idDaRota(req,res);if(id===null)return;constlista=awaitrepositorio.lerTodos();constindice=lista.findIndex((item)=>item.id===id);if(indice===-1)returnres.status(404).json({erro:`Produto ${id} não encontrado.`});if(!podeAlterar(lista[indice],req.usuario)){returnres.status(403).json({erro:"Este produto foi cadastrado por outra pessoa."});}lista.splice(indice,1);awaitrepositorio.salvarTodos(lista);res.status(204).end();};
Este é o controlador da Aula 13 — filtros, ordenação, lista branca de categorias, 400 para id malformado e detalhes: [{ campo, mensagem }] intactos — com três acréscimos de hoje: a função podeAlterar, os campos dono e criadoEm no criar, e a checagem de 403 no atualizar e no remover. Nada foi removido; autorização se soma ao que existe.
Repare também no objeto montado dentro de atualizar: dono e criadoEm são reafirmados a partir do registro que já estava no disco. Sem isso, bastaria mandar {"dono": "eu@gmail.com"} no corpo de um PUT para tomar posse de um produto alheio — a mesma armadilha do id que a Aula 13 fechou, agora com consequência de segurança.
3.2 A versão em middleware (e a armadilha da referência)¶
Dá para tirar a checagem dos controladores e transformá-la num middleware, no espírito da Aula 12. Fica mais elegante e coloca a regra literalmente na rota:
cafe-cerrado-api/middlewares/exigirDono.js
JavaScript
constrepo=require("../data/repositorio");// Roda DEPOIS de exigirLogin: conta com req.usuario já preenchido.// Deixa a lista inteira e o produto encontrado disponíveis para o controlador,// para não ler o arquivo duas vezes na mesma requisição.module.exports=asyncfunctionexigirDono(req,res,next){constprodutos=awaitrepo.lerTodos();constproduto=produtos.find((p)=>p.id===Number(req.params.id));if(!produto)returnres.status(404).json({erro:"Produto não encontrado"});if(produto.dono!==req.usuario.email){returnres.status(403).json({erro:"Este produto foi cadastrado por outra pessoa"});}req.produtos=produtos;// a lista que será salvareq.produto=produto;// referência para um item DENTRO dessa listanext();};
🔎 Por baixo do capô
Por que o middleware precisa passar req.produtosereq.produto? Porque repo.lerTodos() faz JSON.parse a cada chamada, e JSON.parse cria objetos novos. Se o controlador chamasse lerTodos() de novo, ele receberia uma lista diferente, com objetos diferentes: alterar req.produto não teria efeito nenhum sobre a lista que seria salva, e o PUT responderia 200 sem mudar nada no disco. Passando as duas coisas, o req.produto é uma referência a um item de dentro de req.produtos — mudar um muda o outro, porque são o mesmo objeto na memória. Esse é um dos efeitos de referência mais traiçoeiros do JavaScript, e ele custa horas de depuração a quem nunca tropeçou nele.
Se você adotar o middleware, os controladores atualizar e remover passam a usar req.produtos e req.produto em vez de reler o arquivo. Escolha um dos dois caminhos — a checagem no controlador (seção 3.1) ou o middleware (seção 3.2) — e mantenha a coerência. Ter a regra nos dois lugares é pior do que tê-la em um só: um dia você corrige um e esquece o outro.
Antes de tocar na interface, prove a regra com requisições cruas. Você vai precisar de dois tokens de sessão: um seu e um de uma segunda conta (uma alternativa sua, ou a de outra pessoa que te empreste o login por um minuto). Cada um se obtém do mesmo jeito da Aula 14: faça login com a conta, abra o console do navegador e rode JSON.parse(sessionStorage.getItem("cafe-cerrado-sessao")).token. Não use o ID token do Google — o exigirLogin confere a assinatura HMAC da sua própria API e recusaria qualquer outra coisa com 401.
cafe-cerrado-api/testes.http
HTTP
@tokenA = cole-aqui-o-token-de-sessao-da-conta-A@tokenB = cole-aqui-o-token-de-sessao-da-conta-B### A conta A cria um produto — 201, id 11, com "dono" igual ao e-mail da conta APOST http://localhost:3000/api/produtosContent-Type: application/jsonAuthorization: Bearer {{tokenA}}{ "nome": "Espresso Duplo", "categoria": "cafes", "preco": 8.5, "descricao": "Duas doses curtas na mesma xícara." }### A conta A edita o próprio produto — 200PUT http://localhost:3000/api/produtos/11Content-Type: application/jsonAuthorization: Bearer {{tokenA}}{ "nome": "Espresso Duplo", "categoria": "cafes", "preco": 9, "descricao": "Duas doses curtas na mesma xícara." }### A conta B tenta editar o produto da conta A — 403PUT http://localhost:3000/api/produtos/11Content-Type: application/jsonAuthorization: Bearer {{tokenB}}{ "nome": "Sequestrado", "categoria": "cafes", "preco": 1, "descricao": "Não deve funcionar" }### A conta B tenta excluir o produto da conta A — 403DELETE http://localhost:3000/api/produtos/11Authorization: Bearer {{tokenB}}### Ninguém tenta excluir sem token — 401DELETE http://localhost:3000/api/produtos/11### Excluir um produto que não existe — 404DELETE http://localhost:3000/api/produtos/99999Authorization: Bearer {{tokenA}}### A conta A exclui o próprio produto — 204DELETE http://localhost:3000/api/produtos/11Authorization: Bearer {{tokenA}}
As variáveis @tokenA e @tokenB são um recurso da extensão REST Client: declaradas no topo do arquivo, são usadas com {{tokenA}} em qualquer requisição. Trocar de token vira uma edição só.
⚠️ Atenção
Esses tokens são credenciais reais, válidas por cerca de uma hora, que identificam pessoas de verdade. Nunca comite um testes.http com tokens preenchidos: deixe os marcadores COLE_AQUI... no arquivo versionado e preencha localmente na hora de testar. É o mesmo cuidado do .env — segredo no histórico do Git é segredo vazado para sempre.
Com o servidor garantindo a regra, a interface pode (e deve) refletir a mesma lógica — não por segurança, mas por educação: mostrar um botão que sempre falha é maltratar quem usa o sistema.
Duas mudanças no criarCard do public/js/app.js: exibir quem cadastrou e mostrar os botões só para o dono.
cafe-cerrado-api/public/js/app.js — a função criarCard atualizada
JavaScript
functioncriarCard(produto){constcard=document.createElement("article");card.className="card-produto";card.dataset.id=produto.id;consttitulo=document.createElement("h3");titulo.textContent=produto.nome;constpreco=document.createElement("p");preco.className="preco";preco.textContent=moeda.format(produto.preco);constcategoria=document.createElement("p");categoria.className="categoria";categoria.textContent=produto.categoria;constdescricao=document.createElement("p");descricao.className="descricao";descricao.textContent=produto.descricao||"Sem descrição.";constautor=document.createElement("p");autor.className="autor";autor.textContent=produto.dono?`cadastrado por ${produto.dono}`:"cadastro antigo, sem dono";card.append(titulo,preco,categoria,descricao,autor);// Conforto de interface: só o dono vê os botões. A garantia é o 403 do servidor.constusuario=obterUsuario();constsouDono=Boolean(usuario)&&produto.dono===usuario.email;if(souDono){constacoes=document.createElement("div");acoes.className="acoes-card";constbtnEditar=document.createElement("button");btnEditar.type="button";btnEditar.textContent="Editar";btnEditar.setAttribute("aria-label",`Editar ${produto.nome}`);btnEditar.addEventListener("click",()=>entrarEmModoEdicao(produto));constbtnExcluir=document.createElement("button");btnExcluir.type="button";btnExcluir.className="perigo";btnExcluir.textContent="Excluir";btnExcluir.setAttribute("aria-label",`Excluir ${produto.nome}`);btnExcluir.addEventListener("click",()=>excluirProduto(produto));acoes.append(btnEditar,btnExcluir);card.appendChild(acoes);}returncard;}
E o tratamento de erro ganha uma mensagem específica para o 403, aproveitando o status que a ErroDeApi da Aula 15 já carrega:
cafe-cerrado-api/public/js/app.js — a função de mensagem de erro
JavaScript
functionmensagemDeErro(erro){if(erro.status===401)return"Sua sessão expirou. Entre com o Google de novo.";if(erro.status===403)return"Este item foi cadastrado por outra pessoa — você não pode alterá-lo.";if(erro.status===404)return"Este item não existe mais. A lista será atualizada.";constextra=erro.detalhes?.length?` (${erro.detalhes.join("; ")})`:"";return`${erro.message}${extra}`;}
Use-a nos dois catch que mostram erro ao usuário (o do submit e o do excluirProduto), trocando avisar(erro.message, "erro") por avisar(mensagemDeErro(erro), "erro"). No caso do 404, vale ainda chamar carregarProdutos() em seguida: se o item sumiu, a tela está desatualizada.
Antes de considerar o projeto pronto, faça o que uma revisão rigorosa faria. Com o servidor rodando e o navegador em uma janela anônima (para não reaproveitar a sua sessão), percorra os dez passos abaixo. Anote o que falhar; corrija; repita do começo.
Visitante. Abra o site sem login: a lista carrega, nenhum botão de escrita aparece, a área de gestão está oculta.
Escrita bloqueada. Pelo testes.http, faça um POST /api/produtos sem cabeçalho Authorization. Esperado: 401 com JSON explicando.
Login. Clique no botão do Google: nome e foto aparecem, o formulário de cadastro fica visível, os cards seus ganham botões.
Create. Cadastre um item: feedback de sucesso, item na lista sem recarregar, 201 na aba Network, campo dono com o seu e-mail no data/produtos.json.
Read. Busque por um termo com e sem acento (cafe e café): a busca encontra nos dois casos. Peça GET /api/produtos/99999: 404.
Update. Edite o item criado: a mudança aparece na tela sem recarregar, 200 na Network, atualizadoEm preenchido no arquivo.
Delete. Exclua com confirmação: o card some, 204 na Network. Repita o mesmo DELETE no testes.http: 404.
Autorização. Com o token da segunda conta, tente editar e excluir um item da primeira: 403 nos dois casos, e o arquivo no disco intacto.
Persistência. Derrube o servidor (Ctrl+C), suba de novo e recarregue a página: tudo continua lá.
Higiene.git status limpo, node_modules/ e .env fora do Git, e o teste da pasta limpa da seção 6 passando.
📌 Vale gravar
Este roteiro é um bom resumo de como o back-end da Unidade 3 funciona: qual status cada situação produz, quem valida o quê, em que ordem os middlewares rodam e por que a proteção do cliente não substitui a do servidor. Se você consegue explicar cada um dos dez passos para outra pessoa, você domina a parte de back-end desta unidade.
6. Higiene do repositório e o teste da pasta limpa¶
Se você descobriu tarde demais que node_modules/ ou .env já estão no histórico, o comando abaixo remove os arquivos do controle de versão sem apagá-los do seu disco:
Terminal
gitrm-r--cachednode_modules.env
gitcommit-m"remove arquivos que não deveriam estar versionados"
Isso resolve para o futuro. Se um segredo real chegou a subir para o GitHub, considere-o vazado: gere um Client ID novo no Google Cloud Console e apague o antigo. Reescrever o histórico é possível, mas cópias já podem ter sido feitas.
Para que outra pessoa saiba quais variáveis existem, versione um exemplo sem valores:
As três variáveis são obrigatórias, e o SESSAO_SEGREDO é a mais fácil de esquecer: sem ele o server.js da Aula 14 encerra com process.exit(1) e a mensagem "Variável SESSAO_SEGREDO ausente" — o que reprova na hora o teste da pasta limpa da próxima seção. Gere um valor com:
O erro mais comum na entrega não é código errado — é código que só roda na máquina de quem escreveu. Prove que não é o seu caso clonando o próprio repositório em outra pasta:
Terminal
cd/tmp
gitclonehttps://github.com/seu-usuario/cafe-cerrado-api.gitteste-entrega
cdteste-entrega
cp/caminho/do/seu/projeto/.env.env# o .env não vem do Git, e está certo assim
npminstall
npmrundev
# abra http://localhost:3000 e repita os passos 1 a 7 do roteiro de auto-teste
Três coisas costumam falhar aqui, e todas fazem o projeto parecer quebrado para quem só tem o repositório, sem acesso à sua máquina:
Cannot find module 'express' — alguma dependência foi instalada sem entrar no package.json. Rode npm install express google-auth-library dotenv na pasta original e comite o package.json e o package-lock.json.
data/produtos.json não existe — o arquivo de dados está no .gitignore ou nunca foi comitado. Versione uma versão pequena com dois ou três itens de exemplo: quem for testar precisa ver a tela cheia, não vazia.
A aplicação sobe, mas o login não funciona — o README.md não explica que é preciso criar um Client ID próprio e adicionar http://localhost:3000 às origens autorizadas. Explique.
6.3 O README que faz o projeto existir para os outros¶
Um repositório sem README é um projeto que só funciona para quem o escreveu. Este é o esqueleto mínimo — copie a estrutura e escreva o conteúdo do seu projeto:
cafe-cerrado-api/README.md
Markdown
# Café Cerrado — API e site
Aplicação full-stack da cafeteria fictícia Café Cerrado, desenvolvida na
no Nível 2 do WebLab (Desenvolvimento Web).
Site estático servido pelo Express, API REST com CRUD de produtos,
login com Google e persistência em arquivo JSON.
## Tecnologias-Node.js 22 LTS e Express 5
-Google Identity Services (login) e google-auth-library (verificação do token)
-HTML5, CSS3 e Bootstrap 5.3 no front; JavaScript com módulos ES e fetch
-Persistência em arquivo JSON com fs/promises
## Como rodar
Pré-requisitos: Node.js 22 ou superior e uma conta Google.
1. Clone o repositório e instale as dependências:
git clone https://github.com/seu-usuario/cafe-cerrado-api.git
cd cafe-cerrado-api
npm install
2. Crie um projeto no Google Cloud Console, gere um ID do cliente OAuth do
tipo "Aplicativo da Web" e adicione `http://localhost:3000` às origens
JavaScript autorizadas.
3. Copie `.env.exemplo` para `.env` e preencha o `GOOGLE_CLIENT_ID` e o
`SESSAO_SEGREDO` (o Client ID chega ao navegador por `GET /api/config`;
não há nada para colar no HTML):
cp .env.exemplo .env
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
4. Suba o servidor:
npm run dev
5. Acesse <http://localhost:3000>.
## Endpoints da API
| Método | Caminho | Descrição |
|---|---|---|
| GET | /api/produtos | Lista produtos; aceita `?q=`, `?categoria=` e `?ordenar=` |
| GET | /api/produtos/:id | Um produto |
| POST | /api/produtos | Cria (exige login) |
| PUT | /api/produtos/:id | Atualiza (exige login e ser o dono) |
| DELETE | /api/produtos/:id | Remove (exige login e ser o dono) |
| GET | /api/categorias | Lista as categorias do cardápio, como `{ id, nome }` |
| GET | /api/config | Configuração pública do front (Client ID do Google) |
| POST | /api/auth/google | Recebe `{ credential }`, verifica o ID token e devolve `{ usuario, token }` |
| GET | /api/auth/eu | Dados do usuário da sessão atual (exige login) |
Status possíveis: 200, 201, 204, 400, 401, 403, 404 e 500.
## Estrutura de pastas-`server.js` — configuração do Express e dos middlewares
-`routes/` — definição das rotas
-`controllers/` — regras de cada operação
-`middlewares/` — log, autenticação, autorização, 404 e erros
-`data/` — repositório de persistência e o arquivo de dados
-`public/` — site (HTML, CSS, JavaScript, imagens)
-`scripts/` — utilitários de manutenção
-`testes.http` — requisições de teste (extensão REST Client)
## Decisões técnicas-Bootstrap 5.3 como framework CSS, pela documentação em português e pelo
sistema de grid pronto (justificativa completa na Unidade 1).
-Persistência em arquivo JSON, isolada em `data/repositorio.js`, para que a
troca por um banco de dados afete um arquivo só.
-Login delegado ao Google: o projeto não guarda senhas.
## Autor
Seu Nome — Nível 2 do WebLab.
Os blocos de comando dentro do README acima estão recuados em oito espaços: dentro de um bloco de código Markdown, essa é a forma de mostrar código sem abrir outra cerca. No seu README de verdade, use cercas normais com três crases.
Nada disso é específico do Café Cerrado. Troque "produto" por "consulta", "vaga", "chamado" ou "pedido" e você tem, respectivamente, um sistema de clínica, um portal de estágios, um help desk e um delivery. A arquitetura é a mesma. Foi por isso que passamos o semestre insistindo em separar camadas: é ela que você leva daqui, não o cardápio.
Passo 1 — acrescente o dono ao criar. Abra controllers/produtosController.js e aplique as mudanças da seção 3.1: a função podeAlterar, os campos dono e criadoEm no criar, e o atualizadoEm no atualizar.
Passo 2 — proteja atualizar e remover com a checagem de 403, na ordem correta (existe → é seu → é válido).
Passo 3 — migre os produtos antigos:
Terminal
mkdir-pscripts
# crie scripts/definir-dono.js com o conteúdo da seção 2.2
nodescripts/definir-dono.jsseu-email@gmail.com
catdata/produtos.json|head-20
# confirme que os produtos agora têm "dono" e "criadoEm"
Passo 4 — prove a regra pelo testes.http com dois tokens, como na seção 3.3. Não avance enquanto os 403 não aparecerem: se a interface ficar pronta antes da regra, você vai depurar dois problemas ao mesmo tempo.
Passo 5 — atualize o criarCard com a versão da seção 4, e acrescente a função mensagemDeErro ao app.js, usando-a nos dois catch.
Passo 6 — acrescente o estilo do autor ao public/css/estilo.css.
Passo 7 — rode o roteiro de auto-teste completo da seção 5, os dez passos, em janela anônima.
Passo 8 — arrume a casa:
Terminal
# confira o que está versionado
gitls-files|grep-E"node_modules|\.env$"&&echo"PROBLEMA: remova estes arquivos"# crie o .env.exemplo e o .gitignore da seção 6.1, se ainda não existirem
gitadd.
gitcommit-m"autorização por dono, README e roteiro de entrega"
gitpush
Passo 9 — faça o teste da pasta limpa da seção 6.2, do git clone até o login funcionando.
Como testar — o resultado esperado:
Um produto criado por você mostra "cadastrado por seu-email@gmail.com" e tem os botões Editar/Excluir.
Um produto criado por outra conta aparece na lista, mostra o e-mail do dono e não tem botões.
Forçar a exclusão de um produto alheio pelo testes.http responde 403 e o arquivo não muda.
Sem token, qualquer escrita responde 401.
npm install && npm run dev funciona numa pasta recém-clonada, seguindo só o que está escrito no README.
A1. Explique, em duas frases, a diferença entre 401 e 403. Dê um exemplo de cada, tirado do seu projeto autoral.
A2. Por que dono: req.usuario.email é seguro e dono: req.body.dono não é? Descreva o ataque exato que a segunda forma permite.
A3. Na função atualizar, qual é a ordem correta das verificações (400, 403, 404)? O que aconteceria de estranho se a validação de dados viesse antes da busca do produto?
A4. O que é IDOR? Escreva uma requisição curl ou um bloco do testes.http que explore essa falha numa API que não checa o dono.
A5. Um produto cadastrado antes da regra de hoje não tem o campo dono. O que acontece ao tentar editá-lo? Qual comparação, exatamente, produz esse resultado?
A6. Por que esconder o botão "Excluir" no HTML não é uma medida de segurança? Cite duas formas de burlar essa "proteção" sem sair do navegador.
A7. O que faz git rm -r --cached node_modules? Qual é a diferença em relação a rm -rf node_modules?
A8. Por que .env.exemplo é versionado e .env não? O que deve haver dentro de cada um?
A9. Cite três motivos pelos quais um projeto que roda na sua máquina pode falhar num git clone limpo, e como o README evita cada um deles.
A10. Explique por que o middleware exigirDono precisa entregar ao controlador tanto req.produtos quanto req.produto, em vez de só o segundo.
B1. Mostre quando cada produto foi cadastrado, em linguagem natural ("há 3 dias", "há 2 horas"), a partir do campo criadoEm, usando Intl.RelativeTimeFormat com o locale pt-BR. Inclua o valor exato em um title no elemento, formatado com Intl.DateTimeFormat.
Resultado esperado: cada card exibe "cadastrado por fulano · há 2 horas"; passar o mouse mostra a data e a hora completas; produtos migrados pelo script mostram o instante da migração.
Dica
Calcule a diferença em milissegundos (Date.now() - new Date(produto.criadoEm)), escolha a maior unidade que couber (dia, hora, minuto) e chame new Intl.RelativeTimeFormat("pt-BR", { numeric: "auto" }).format(-valor, unidade) — o valor negativo é o que produz "há". Uma função formatarRelativo(iso) isolada facilita testar no console.
B2. Acrescente um filtro "somente os meus" à listagem: uma caixa de seleção que, marcada, mostra apenas os produtos do usuário logado. Implemente o filtro no servidor, com ?meus=1 e o e-mail vindo do token — não no cliente.
Resultado esperado: marcada a caixa, a requisição vai para /api/produtos?meus=1 com o cabeçalho Authorization, e a resposta traz só os seus itens. Sem login, a caixa fica desabilitada.
Dica
A rota GET /api/produtos é pública, então ela não pode exigir login — mas pode aceitar um token quando ele vier. Crie um middleware identificarOpcional que tenta verificar o token e, se falhar, apenas segue sem req.usuario. No controlador, if (req.query.meus === "1" && req.usuario) filtra por dono.
B3. Escreva o README.md completo do seu projeto autoral, seguindo a estrutura da seção 6.3, e valide-o com o teste da pasta limpa. Peça a outra pessoa — colega de estudos, amigo, alguém de uma comunidade online — para seguir só o README, sem falar com você, e anote onde ela travou. Sem ninguém disponível? Feche o projeto, espere um dia, e siga seu próprio README do zero como se fosse a primeira vez.
Resultado esperado: outra pessoa (ou você mesmo, em outro dia, seguindo só o texto) consegue clonar, instalar, configurar o Client ID e ver a aplicação funcionando sem fazer nenhuma pergunta. Cada pergunta que precisou ser feita vira uma linha nova no README.
Dica
O ponto onde as pessoas mais travam é o Client ID do Google: escreva o passo a passo do Cloud Console com os nomes exatos dos menus, incluindo a origem http://localhost:3000. Se o seu projeto precisa de dados de exemplo para não abrir vazio, versione um data/ com dois ou três registros.
B4. A sessão já sobrevive ao F5 (o auth.js da Aula 15 lê o sessionStorage), mas hoje a interface acredita cegamente no que está guardado: um token expirado ou adulterado à mão pelo DevTools continua desenhando os botões de escrita, que só falham quando alguém clica. Conserte isso: ao carregar a página, confirme a sessão no servidor com GET /api/auth/eu antes de considerar o usuário logado. Se a resposta for 401, limpe o sessionStorage e volte ao estado de visitante.
Resultado esperado: logar, recarregar a página e continuar logado, com os botões de escrita visíveis; abrir uma aba anônima e continuar deslogado; esperar o token expirar e ver a aplicação voltar sozinha ao estado de visitante.
Dica
GET /api/auth/eu já existe desde a Aula 14 e passa pelo exigirLogin: ele responde 200 com o usuário quando o token é válido e 401 quando não é — exatamente a pergunta que você precisa fazer. Chame-o dentro da função async iniciar() do auth.js, antes de pintarAreaDoUsuario(), e trate o 401 como logout. Para testar, edite o token no DevTools (Application → Session Storage), troque um caractere e recarregue: a página tem de voltar ao estado de visitante sozinha.
C1. Em dupla, cada um usando a própria conta Google: cadastrem produtos, tentem editar e excluir os itens um do outro pela interface e pelo testes.http, e produzam um pequeno relatório docs/teste-autorizacao.md com as requisições feitas, os status obtidos e os status esperados. Encontrem pelo menos uma diferença entre o que a interface permite e o que o servidor permite.
Dica
A diferença mais provável é a rota GET /api/produtos/:id: ela é pública, então qualquer pessoa lê qualquer produto — inclusive os campos dono e criadoEm. Isso é um problema? Depende do domínio: num cardápio, não; numa lista de candidaturas a vagas, sim. Registrem a conclusão no relatório.
C2. Implemente detecção de edição concorrente: se duas pessoas (ou duas abas) abrirem o mesmo produto para editar e a segunda salvar depois da primeira, a segunda deve receber 409 Conflict em vez de sobrescrever silenciosamente as mudanças da primeira.
Dica
O cliente envia, junto com os dados, o atualizadoEm que ele recebeu ao carregar o produto. O servidor compara com o atualizadoEm atual: se forem diferentes, alguém editou nesse meio-tempo e a resposta é 409 com uma mensagem clara. É a versão simples do que o HTTP chama de requisição condicional (cabeçalhos ETag e If-Match) — vale procurar na MDN depois de fazer funcionar.
Dizer "minha API está protegida" é fácil; provar é outra coisa. Monte a evidência: um único arquivo com as requisições que demonstram, sem margem de dúvida, que a sua API distingue os três casos — visitante (401), pessoa logada mexendo no que não é dela (403) e pessoa logada mexendo no que é dela (200/204). Ao terminar, você vai ter o anexo mais convincente da sua entrega.
Critérios de pronto
Um arquivo docs/evidencias.md com, para cada um dos três casos: o método e a URL, os cabeçalhos relevantes (com o token abreviado), o status recebido e o corpo da resposta.
As requisições foram feitas com duas contas Google diferentes, e o relatório deixa claro qual conta fez o quê.
Uma seção final aponta, em duas linhas, qual arquivo e qual linha do servidor produzem cada status.
Nenhum token completo aparece no arquivo (mostre só os 12 primeiros caracteres seguidos de reticências).
Pistas
A aba Network do DevTools tem "Copiar como cURL" no menu de contexto de cada requisição — é a forma mais rápida de registrar cabeçalhos reais.
Para conseguir o segundo token sem uma segunda conta, use uma janela anônima e uma conta institucional da sua universidade ou escola (se ela for uma conta Google) ou qualquer outra conta Google que você tenha.
Os status ficam visíveis também no log de requisições que o seu middleware da Aula 12 imprime no terminal — vale colar esse trecho no relatório.
Se algum caso não produzir o status esperado, você acabou de encontrar um bug antes do avaliador. Essa é a graça do desafio.
O Café Cerrado cresceu e agora tem um gerente, que precisa corrigir o preço de qualquer produto — inclusive os cadastrados por outras pessoas. Sem inventar um sistema de cadastro de usuários: uma lista de e-mails administradores no .env resolve. O desafio é fazer isso sem espalhar if de permissão pelo código todo.
Critérios de pronto
Uma variável ADMINS no .env (e no .env.exemplo), com e-mails separados por vírgula, alimenta a regra.
podeAlterar (ou o middleware equivalente) passa a aceitar dono ou administrador, e continua sendo o único lugar do projeto que decide isso.
Um administrador vê os botões de editar/excluir em todos os cards; um usuário comum, só nos seus.
A resposta da API inclui, de forma explícita, se o usuário atual é administrador — a interface não adivinha lendo o .env (ela não tem acesso a ele).
O testes.http prova os quatro cenários: dono, administrador, terceiro logado e visitante.
Pistas
process.env.ADMINS?.split(",").map((e) => e.trim().toLowerCase()) ?? [] no carregamento do módulo evita refazer a conversão a cada requisição.
Compare sempre em minúsculas: e-mails não diferenciam maiúsculas na prática, e um Fulano@gmail.com no .env não pode quebrar a regra.
Para a interface saber, o endpoint POST /api/auth/google da Aula 14 pode devolver um campo admin: true/false junto com nome, e-mail e foto.
Cuidado com a tentação de mandar a lista de administradores para o cliente. O front precisa saber se este usuário é admin, não quem são todos eles.
Todo sistema sério registra quem fez o quê. Sem isso, quando um preço aparecer errado, ninguém consegue dizer se foi engano, sabotagem ou bug. Construa uma trilha de auditoria para o Café Cerrado: cada criação, alteração e exclusão vira uma linha imutável em um arquivo próprio, consultável por uma rota protegida.
Critérios de pronto
Toda escrita bem-sucedida acrescenta uma entrada em data/auditoria.jsonl (um JSON por linha) com quem, quando, qual ação, qual id e o que mudou.
O arquivo é somente acrescido: nenhuma parte do código reescreve linhas anteriores, e uma linha nova nunca sobrescreve outra mesmo com requisições simultâneas.
GET /api/auditoria devolve as últimas 50 entradas, exige login e responde 403 para quem não for administrador (ou para quem não for dono de nenhum registro, se você não fez o desafio anterior).
O registro é feito por um único ponto do código, não repetido em cada controlador.
Um teste de carga simples (20 requisições disparadas em paralelo com Promise.all num script) prova que nenhuma linha se perdeu nem saiu corrompida.
Pistas
fs.appendFile é a operação certa: ela abre no modo a, que grava no fim do arquivo — diferente do writeFile, que trunca.
O formato JSONL (um objeto JSON por linha, sem vírgulas nem colchetes) existe exatamente para arquivos que só crescem: dá para acrescentar sem reler o que já está lá.
Para não repetir código, pense em onde uma função pode observar todas as respostas: um middleware colocado antes das rotas pode registrar-se no evento finish da resposta (res.on("finish", ...)) e ler res.statusCode depois de tudo pronto.
Para saber o que mudou, compare o objeto antes e depois: Object.keys(depois).filter((k) => antes[k] !== depois[k]) já dá uma lista de campos alterados.
Para as últimas 50 linhas sem ler o arquivo inteiro na memória, readFile seguido de split("\n").slice(-50) resolve enquanto o arquivo for pequeno — e vale um comentário no código dizendo o que fazer quando não for mais.
Este é o mini-projeto que fecha a Unidade 3 e usa tudo: rotas, controladores, middlewares, autenticação, autorização, persistência e front assíncrono. O cardápio está no ar e as pessoas querem pedir. Crie um segundo recurso completo — pedidos — que se relaciona com os produtos existentes, com regras de acesso próprias: cada cliente vê e cancela apenas os seus pedidos; o dono do produto vê os pedidos que envolvem os seus itens.
Um pedido tem, no mínimo: id, produtoId, quantidade, observacao, cliente (e-mail vindo do token), situacao (recebido, preparando, entregue ou cancelado) e criadoEm.
Critérios de pronto
Um recurso pedidos completo, com data/pedidosRepositorio.js, controllers/pedidosController.js e routes/pedidos.js, seguindo exatamente a arquitetura em camadas do recurso de produtos.
POST /api/pedidos exige login, valida que o produtoId existe (400 se não existir), rejeita quantidade menor que 1 e grava o cliente a partir do token.
GET /api/pedidos exige login e devolve apenas os pedidos do usuário — nunca os dos outros. Um segundo usuário logado não consegue ver os seus pedidos de forma alguma, nem variando ids na URL.
PATCH /api/pedidos/:id/situacao muda a situação e só é permitido ao dono do produto pedido; transições inválidas (de entregue para recebido, por exemplo) respondem 400.
DELETE /api/pedidos/:id cancela o pedido, permitido só ao cliente e só enquanto a situação for recebido; caso contrário, 403 com mensagem explicando.
Uma tela nova no front, consumindo a API pela mesma fachada api.js, com os quatro estados (carregando, erro, vazio, conteúdo) e feedback acessível.
testes.http com um cenário para cada status possível (200, 201, 204, 400, 401, 403, 404) e o README atualizado com os endpoints novos.
Tudo funcionando num git clone limpo, com npm install && npm run dev.
Pistas
Comece pelo contrato, como na Aula 15: escreva a tabela de endpoints do recurso pedidos antes de programar. Metade dos problemas de integração morre aí.
O repositório de pedidos é quase idêntico ao de produtos. Resista à tentação de copiar e colar: escreva um criarRepositorio(nomeDoArquivo) que devolve { lerTodos, salvarTodos, proximoId } e use-o duas vezes.
Para descobrir se o usuário é dono do produto de um pedido, o controlador de pedidos precisa consultar o repositório de produtos — isso é normal e não quebra as camadas; o que não pode é um repositório chamar o outro.
As transições válidas de situação cabem num objeto: { recebido: ["preparando", "cancelado"], preparando: ["entregue"], entregue: [], cancelado: [] }. A validação vira uma linha, e a regra fica visível.
PATCH é o verbo certo aqui porque só um campo muda (veja a curiosidade da Aula 15 sobre PUT e PATCH). No Express 5, app.patch e router.patch existem e funcionam como os outros.
Para a tela, reaproveite a estrutura do app.js: estado, renderizar(), quatro estados. Um segundo módulo public/js/pedidos.js mantém os dois assuntos separados.
Esta é a última atividade do semestre, e ela é a própria preparação da entrega:
Aplique a autorização por dono ao projeto autoral: campo de dono vindo do token, 403 em PUT e DELETE de registros alheios, e a regra num único ponto do código.
Rode o script de migração nos seus dados antigos, para que nenhum registro fique órfão.
Atualize a interface: exiba o autor de cada registro e mostre os controles de escrita apenas para quem pode usá-los.
Atualize o testes.http com os cenários de 401, 403 e 404, usando duas contas.
Escreva o README.md completo, seguindo a estrutura da seção 6.3.
Execute o roteiro de auto-teste da seção 5, os dez passos, e o teste da pasta limpa da seção 6.2.
Faça o commit final e confirme que .env e node_modules/ não subiram.
Critério de pronto: o repositório clonado numa pasta vazia roda com npm install && npm run dev; um usuário logado só altera o que é dele; a documentação permite que outra pessoa reproduza tudo sem perguntar nada.
Guarde no seu repositório: commit + push, com o link público atualizado.
Escopo. Aplicação full-stack do projeto autoral, construída sobre o Marco 2: API em Node.js com Express 5, autenticação com Google, CRUD completo com persistência e front-end assíncrono consumindo a própria API. Este é o marco final desta trilha — reúne tudo o que o projeto acumulou desde a Aula 01.
Requisitos.
#
Requisito
Onde foi estudado
1
Servidor Express 5 servindo o front de public/ e a API em /api
Aula 11
2
Estrutura em camadas: routes/, controllers/, data/, middlewares/, server.js enxuto
Aulas 12 e 13
3
Middlewares de express.json(), log de requisições, 404 da API e tratador de erros
Aula 12
4
CRUD completo do recurso principal, com busca por query string
Aula 13
5
Validação no servidor, com 400 e mensagem útil; status corretos em todas as rotas
Aulas 13 e 15
6
Persistência em arquivo, isolada numa camada de repositório; dados sobrevivem ao reinício
Aula 15
7
Login com Google Identity Services e verificação do ID token no servidor
Aula 14
8
Segredos em .env fora do Git, com .env.exemplo versionado
Aula 14
9
401 nas rotas de escrita sem token; leitura pública funcionando
Aula 14
10
Registros com dono e 403 ao alterar registro alheio
Aula 16
11
Front consumindo a API com fetch e async/await, sem recarregar a página
Aula 15
12
Quatro estados de tela (carregando, erro, vazio, conteúdo) e feedback com aria-live
Aula 15
13
testes.http versionado, cobrindo sucesso e todos os erros previstos
Aulas 12 a 16
14
README.md completo e projeto rodando num clone limpo com npm install && npm run dev
Aula 16
O repositório precisa ser público no GitHub, com histórico de commits mostrando evolução ao longo do semestre (não um único commit final), .gitignore funcionando e nenhum segredo versionado. Ele precisa conter o front em public/, o testes.http e o README.md.
Rotas, controladores e middlewares: arquitetura em camadas de verdade, com os status HTTP corretos em cada rota.
Autenticação Google: botão funcionando, verificação do token no servidor (nunca só no cliente), segredos fora do Git.
CRUD com persistência: as quatro operações completas, com validação e dados sobrevivendo ao reinício do servidor.
Autorização:401 em toda escrita sem token, 403 em registro alheio, dono sempre vindo do token — nunca do corpo da requisição.
Integração com front-end assíncrono:fetch/async-await, os quatro estados de tela, nada recarregando a página.
README, testes.http e higiene do repositório: roda de fato num clone limpo, sem depender da sua máquina.
Um critério "pela metade" costuma significar que ele funciona no caminho feliz mas falha num caso de borda (token expirado, registro alheio, lista vazia) — teste esses casos antes de considerar o marco pronto.
Sobre IA: use como apoio para tirar dúvidas, sugerir abordagens e revisar código — não para gerar o projeto inteiro sem entender. O teste real: você precisa conseguir explicar qualquer trecho do que construiu.
Execute o roteiro de auto-teste completo da seção 5 (os dez passos) numa janela anônima, com duas contas Google diferentes.
Rode o teste da pasta limpa da seção 6.2: clone o próprio repositório numa pasta nova e confirme que npm install && npm run dev funciona sem ajustes manuais.
Confira, pelo testes.http, que cada rota de escrita devolve 401 sem token e 403 para quem não é dono do registro.
Abra o README.md como se fosse a primeira vez vendo o projeto: ele explica tecnologias, como rodar, endpoints e estrutura de pastas?
Revise o histórico de commits: ele mostra o projeto evoluindo aula a aula, não aparecendo pronto de uma vez.
Você entra nesta trilha sabendo HTML e CSS e sai com uma aplicação full-stack autenticada. O próximo passo depende do que despertou mais curiosidade ao longo do caminho — e há dois caminhos naturais dentro do próprio WebLab.
O Nível 3 — Frameworks Modernos é a continuação direta desta trilha. Lá, o ciclo estado → render que você implementou à mão na Aula 15 vira reatividade automática com o Vue 3; o criarCard com document.createElement vira um componente declarativo; a fachada api.js vira uma instância do Axios com interceptadores; o estado que você guardou em variáveis de módulo vira uma store do Pinia. No back, o Express que você já conhece ganha um banco de dados de verdade (MySQL e Supabase), autenticação com Firebase e documentação com Swagger.
O mais importante: você vai reconhecer cada ferramenta como a automação de algo que já entendeu. Quem chega ao Vue sem ter escrito um render à mão aprende a sintaxe; quem chega depois desta trilha entende o mecanismo. É uma diferença que aparece na primeira vez que algo dá errado.
O seu Café Cerrado roda em http://localhost:3000, o que significa que ele existe para exatamente uma pessoa. A trilha Deploy & Ferramentas é transversal e resolve isso; o capítulo mais direto para o que você acabou de construir é o Capítulo 05 — Publicando o back-end Node, que pega uma API Express como a sua e a coloca no ar com URL pública, variáveis de ambiente configuradas no painel do serviço e HTTPS.
Um projeto publicado muda de natureza: vira link em currículo, vira coisa que dá para mostrar num processo seletivo, vira portfólio. Um repositório que só roda na sua máquina, não.
Banco de dados de verdade. Trocar data/repositorio.js por PostgreSQL ou MySQL é o próximo salto técnico — e, graças à arquitetura em camadas, mexe em um arquivo só. É um ótimo projeto de férias.
TypeScript. Tipos sobre o JavaScript que você já domina. Em projetos que crescem, o ganho (erros detectados enquanto você escreve, autocomplete que funciona de verdade) é grande.
Testes automatizados. O testes.http é o embrião. O passo seguinte é escrever testes que rodam sozinhos a cada commit, com o node --test que já vem no Node 22.
Portfólio. Este repositório, com README caprichado e deploy no ar, já é peça de portfólio para estágio. Trate-o como cartão de visita: continue commitando, mesmo depois da nota.
QUEIRÓS, Ricardo; PORTELA, Filipe. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — releitura recomendada ao fim do curso, com a visão global agora fazendo sentido.
LOUDON, Kyle. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — segurança e evolução de aplicações que crescem.
ALVES, William P. Projetos de Sistemas Web. Érica, 2015 — organização de projetos web do início ao fim.
PUREWAL, Semmy. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — aplicação completa com Node.js, do zero ao deploy.
Fim do semestre. Em dezesseis aulas, o mesmo projeto saiu de um index.html vazio e chegou a uma aplicação full-stack com API REST, login federado, autorização por dono e persistência — e você entende cada linha dela, porque escreveu cada uma. Guarde o repositório: ele é a prova de que você sabe construir um sistema web inteiro, e não apenas usar um framework. Na próxima aula da sua trajetória — a Aula 01 do Nível 3 — esse mesmo conhecimento vira a base para Vue, Vuetify, Pinia e bancos de dados na nuvem. Bom exame a quem precisar, e bons deploys a todos.