www.meucaderno.digital Abr a Ago 2026 React · TypeScript · Postgres

Meu Caderno Digital

Como construí um app de vendas em que registrar uma venda cabe em dois toques, mais rápido que rabiscar no papel. E o que aprendi tentando provar que ele funciona.

Vendas: 0
R$ 0,00
Espetinho de carne R$ 8,00
0

Toque no +. Depois em confirmar. É isso.

A tela inicial do app, funcionando aqui

6+toques para uma venda nos apps que baixei
12concorrentes instalados e testados
A origem

A barraquinha de espetinho

Eu estava numa barraquinha de espetinho, dessas de calçada, e o cara fazia tudo sozinho. Atendia, anotava o pedido, virava a carne, cobrava, dava troco, e no meio disso tudo rabiscava num caderninho o que tinha saído. Cinco funções, um par de mãos.

O caderno estava encardido de gordura, com a espiral entortada. E funcionava perfeitamente.

Fiquei com aquilo na cabeça. Estamos em 2026. Desde o Egito antigo o ser humano registra coisas em suporte físico, e depois de todos esses milênios, e de uns bons quarenta anos de software comercial, o vendedor de rua continua escolhendo papel e caneta. Não por nostalgia. Por eficiência.

Isso ou é um mistério, ou é um diagnóstico. Eu apostei que era diagnóstico.

O que a pesquisa devolveu

Antes de escrever uma linha de código, fiz duas coisas.

Primeiro, conversei. Fui a feira, calçada, barraca de pastel, carrinho de lanche. Perguntei quem anota, quem não anota, e a pergunta que interessa: quem já tinha tentado anotar no celular e desistido.

O padrão apareceu rápido. Quase todo mundo que tinha tentado tinha desistido. E o motivo nunca era “o app é ruim”. Era sempre uma variação da mesma frase: “dá muito trabalho”.

Depois, baixei os concorrentes. Uma dúzia deles. Instalei, cadastrei, tentei registrar uma venda de um espetinho de R$ 8 como se eu fosse o cara da barraquinha, com fila na frente.

Não consegui em nenhum deles em menos de seis toques.

Abrir → Nova venda → escolher cliente → buscar o produto → escolher a quantidade →
escolher forma de pagamento → escolher categoria fiscal → confirmar → voltar

Nenhum desses apps é mal feito. Vários são muito bem feitos. O problema é outro: eles foram desenhados para um comércio que tem estoque, fornecedor, contador e CNPJ, e depois foram vendidos para quem tem uma churrasqueira e um isopor. Cada campo daquele existe porque alguém, em algum lugar, precisa dele. Só que a soma de todos os campos que alguém precisa é um labirinto para quem precisa de dois.

O custo de um app não é o preço da assinatura. É o número de decisões que ele exige por venda.

Com fila na frente e a carne queimando, seis decisões custam mais caro que um rabisco. Então o rabisco ganha. Sempre ganhou.

O caderno não vence por ser bom. Vence porque a alternativa é pior.

2toques, do dedo à venda gravada
6features que a restrição matou
A decisão

O critério que virou arquitetura

Saí da pesquisa com um número, não com uma lista de features:

Registrar uma venda tem que caber em dois toques.

Um toque no produto. Um toque em confirmar. Acabou.

Isso parece uma meta de design. Não é. É uma restrição de arquitetura, e ela mata features antes de elas nascerem. Vamos ver o que ela derruba:

  • Não tem tela de “nova venda”. Se você precisa abrir a venda antes de fazer a venda, já gastou um toque com burocracia. A tela inicial é a venda.
  • Não tem campo de busca. Busca é digitação, digitação é teclado, teclado é a metade da tela sumindo. Os produtos ficam todos numa lista só, visíveis.
  • Não tem seletor de quantidade. Tem [-] e [+] do lado de cada produto, com o número no meio. Três espetinhos são três toques no +, que é exatamente o gesto de quem conta na mão.
  • Não tem forma de pagamento. Dinheiro, Pix e cartão caem todos no mesmo caixa. O vendedor sabe qual foi, o caderno dele nunca perguntou, e o faturamento do dia é o mesmo nos três casos.
  • Não tem fechamento de caixa. O dia vira sozinho. O relógio do aparelho já sabe que dia é hoje; pedir isso ao vendedor é pedir que ele faça o trabalho do computador.
  • Não tem cadastro para começar. Você abre e usa. Conta é opcional.

Cada uma dessas ausências foi uma decisão explícita, tomada contra a intuição de “e se alguém precisar?”. Sim, alguém vai precisar. E aí ele usa um dos outros doze apps, que fazem isso muito bem. Este aqui faz uma coisa.

12telas no app inteiro
1lista, sem busca e sem categoria
A interface

A tela

A tela principal é uma lista.

Tela de venda com três produtos selecionados e o total de R$ 26,50
A tela de venda com o pedido montado. Três linhas verdes, o contador do dia no topo, e a barra de confirmação embaixo. Todo o resto é lista.

Cada linha tem o nome do produto em fonte monoespaçada, o preço embaixo, e à direita os três elementos que importam: [-], a quantidade, [+]. A linha inteira muda de cor quando a quantidade passa de zero: fundo verde claro, borda verde, número verde. Você vê o pedido montado sem ler nada.

Quando há algo no pedido, aparece a barra de confirmação com o total. Um toque nela e a venda está registrada, a lista zera, e o contador no cabeçalho sobe.

A lista se reordena sozinha. O produto mais vendido sobe para o topo. Ninguém “caça” um item; o item vai até o dedo. Se o cara vende espetinho de carne, espetinho de frango e refrigerante, e o de carne é 60% das vendas, ele fica na primeira linha por construção, não por configuração.

Isso tem um efeito colateral que eu não tinha previsto e precisei resolver: com 20 ou 30 produtos, a ordenação por popularidade intercala os campeões de venda no meio do alfabeto, e achar um item raro vira rolagem. A solução não foi trocar a ordenação. Foi um botão de ordem alfabética que é estado, não navegação. Ele muda a lista no lugar e a escolha morre no reload.

O cabeçalho conta o dia. Duas linhas: Vendas: 12 em cima, R$ 148,00 embaixo, em corpo grande. É a informação que o vendedor olha de esguelha entre um cliente e outro, e ela está sempre lá, sem precisar abrir nada.

E o dia se fecha sozinho

Não existe botão de fechar o caixa. Tocar no contador abre o resumo do dia, e o dia vira sozinho quando vira. O histórico filtra por hora, dia, semana ou mês, e exporta o período em um toque, direto para a gaveta de compartilhamento do celular. Sem relatório em PDF que ninguém pediu.

Resumo do dia com sete itens e total de R$ 61,00Histórico com filtros por hora, dia, semana e mês
À esquerda, o resumo do dia, que aparece ao tocar no contador. À direita, o histórico com os filtros de período e o botão de exportar.
0bytes de rede para gravar uma venda
Σ−Σa dívida do fiado nunca é armazenada
A arquitetura

O que acontece quando o 4G cai

Na feira, o 4G cai. Em barraca de rua com estrutura de metal, cai mais ainda. Um app de vendas que precisa de rede para registrar uma venda é um app que vai falhar exatamente no dia de maior movimento.

Então a arquitetura é local-first, e isso não é um detalhe de implementação. É a promessa central. Todo o estado operacional mora no próprio aparelho. A venda é gravada localmente, no ato, sem rede. O servidor é sincronização oportunista: quando há conexão, o que está pendente sobe.

Não é “o app mostra o que o servidor tem”. É “o aparelho é a verdade, e o servidor é a cópia”.

Toda a complexidade que sobra dessa escolha, ou seja, o que fazer quando dois aparelhos discordam, foi empurrada para o desenho dos dados, não para a interface. O exemplo mais bonito disso é o fiado.

O caderno de fiado

Vender fiado é o caso mais comum de todos, e o mais mal resolvido por software. O vendedor entrega o produto, não recebe, anota “Seu Zé, 2 espetinhos” numa página do fim do caderno, e acerta na sexta.

A tentação óbvia é criar um campo saldo_devedor no cliente e somar e subtrair. É a tentação errada, e o motivo é sincronização: dois aparelhos do mesmo vendedor vão brigar por aquele campo, e quando brigarem alguém vai perder dinheiro em silêncio.

A escolha foi outra: a dívida nunca é armazenada. Ela é sempre derivada.

  • O produto entregue sem pagamento vira uma linha de cobrança, com o preço congelado no momento da entrega.
  • O dinheiro que entra na sexta vira uma linha de acerto.
  • A dívida é Σ cobranças − Σ acertos, calculada na hora de mostrar.

Não existe campo de saldo. Então não existe campo para dois aparelhos brigarem. Eles convergem porque as duas listas de eventos convergem, e a conta é a mesma dos dois lados.

E, decisão que parece pequena e não é: a venda fiada nunca entra no faturamento do dia. Ela não é receita quando o produto sai; é receita quando o dinheiro entra. O caixa de terça mostra o que entrou na terça, não o que foi prometido. É exatamente o que o caderno já fazia, e é a única versão que não mente.

2caminhos do cliente até o pedido
1código, e ninguém precisa dizê-lo
O modo fila

Quando o vendedor prepara

O espetinho não sai na hora. O pastel também não. Existe uma categoria inteira de vendedor cujo problema não é registrar, é lembrar de quem é o pedido que está na chapa.

Para eles existe o modo fila, e ele inverte a arquitetura de propósito. Aqui o pedido precisa de um número único entre aparelhos, e precisa ser legível por um cliente que não tem conta nem app. Local-first não resolve isso. Então neste modo, e só neste, o servidor é a fonte da verdade.

O vendedor monta o pedido do jeito de sempre, confirma, e o pedido ganha um número. Ele vê os pedidos em cartões e avança cada um pelos estados: preparando, pronto, entregue, pago.

Os dois caminhos do cliente, e por que eles mostram coisas diferentes

Aqui está a parte que eu acho a mais bem resolvida do produto, e ela nasceu de um problema de segurança.

Modal com o número do pedido, o QR e os botões Pago e Não pago
Os dois caminhos numa tela só. O vendedor vira o celular e o cliente escaneia. Embaixo, em letra menor, o plano B: “Sem câmera? O cliente digita o número 1 no cartaz do QR.”
Caminho 1

O vendedor vira a tela

Mostra tudo

Ao criar o pedido, o app gera um QR daquele pedido. O cliente aponta a câmera. Não fala número, não digita, não escuta errado no barulho. O QR carrega o número e um código do banco, então o servidor sabe que quem escaneou estava no balcão: itens, quantidades, total, se já foi pago, e o status.

Caminho 2

O cartaz da barraca

Mostra só o status

O QR grande, impresso, colado na banca, gerado pelo próprio app. Leva à página da barraca, onde o cliente digita o número. Funciona, e mostra o status. Não mostra os itens nem o valor. A tela diz: “Para ver os itens, peça o QR do seu pedido no balcão.”

Essa diferença não é uma limitação. É a defesa inteira.

Tela do cliente mostrando pedido 1 preparando, com itens e totalTela do cliente mostrando pedido 1 pronto para retirar, já pago
O que o cliente vê depois de escanear o QR do pedido: itens, total e status, atualizando sozinho. À direita, o mesmo pedido quando o vendedor marca pronto.

O endereço da barraca é público por construção, já que está impresso num cartaz onde qualquer um pode ver. Se digitar um número bastasse para ver o pedido, qualquer pessoa poderia sentar na praça, testar 1, 2, 3, 4, e ler o que os outros pediram e quanto pagaram. Foi exatamente isso que uma auditoria interna apontou.

A correção óbvia seria dar um código secreto a cada pedido, e o vendedor falaria duas coisas: “pedido 42, código 7391”. O cliente digitaria duas coisas. Numa barraca com fila e barulho, isso dobra o que se fala, dobra o que se digita e dobra o erro.

A correção que entrou faz o oposto: ela tira trabalho dos dois lados.

O código continua existindo, com força total, mas ninguém precisa dizê-lo nem digitá-lo, porque ele viaja dentro do QR. O vendedor vira a tela, que é menos esforço do que falar o número. O cliente aponta a câmera, que é menos esforço do que digitar. E o cartaz continua vivo como plano B, para câmera quebrada ou cliente sem jeito com QR, mostrando só o que é seguro mostrar sem prova de que a pessoa esteve no balcão.

Duas notas honestas sobre o desenho

O vínculo do QR é exclusivo. Um pedido reivindicado por um celular não é reivindicável por outro; o segundo recebe um aviso e é orientado a falar com o vendedor. Isso protege o dono do pedido.

O caminho do cartaz não é exclusivo, e isso é deliberado. Duas pessoas da mesma mesa podem acompanhar o mesmo número. Como ali só se vê o status, não há o que proteger.

O faturamento, esse continua local. No instante em que o vendedor marca “pago”, o app cria as linhas de venda no aparelho pelo mesmo caminho de sempre. Duas arquiteturas convivendo, cada uma onde faz sentido, encontrando-se num ponto só.

Cartão do pedido na fila do vendedor, com os botões de avançarModal do cartaz de QR da fila, pronto para imprimir
À esquerda, a fila do lado do vendedor. O selo “Cliente acompanhando pelo celular” diz que alguém está do outro lado esperando aquele número. À direita, o cartaz da barraca, gerado pelo app e pronto para imprimir, que é a porta do caminho 2. O QR e o endereço estão borrados de propósito.
2modos de cadastrar, no mesmo diálogo
1chamada de IA, e nenhuma de áudio
1,77 mso portão que roda antes dela
O dia zero

A decisão de engenharia de que eu mais gosto

O maior obstáculo de qualquer app de vendas não é o uso diário. É o cadastro inicial. Ninguém quer digitar 18 produtos com nome e preço num formulário de celular antes de vender o primeiro espetinho.

Então existem dois modos de cadastrar, e os dois estão no mesmo diálogo, como abas.

Modo manual. Nome e preço, um produto por vez. É o caminho previsível, funciona sem conta, sem internet e sem IA, e continua disponível para sempre. Nada no app depende de ele não ser usado.

Modo texto corrido. Uma caixa de texto onde a pessoa escreve do jeito que fala:

“pastel de carne por 5, brigadeiro quatro reais, caldo de cana 10, suco oito...”

Um modelo de linguagem no servidor transforma isso em catálogo, e o resultado cai numa tela de revisão editável antes de virar cadastro. Nome e preço, linha por linha, prontos para corrigir. A IA propõe; a pessoa decide.

Modal de adicionar produtos na aba Inteligente, com um texto ditado sem pontuação
A fala do vendedor, transcrita pelo teclado do próprio celular e colada no campo sem edição nenhuma. Repare em “coração de frango é oito reais”: o preço veio por extenso, como quem fala. É por isso que o portão conhece os numerais escritos.

Por que isso é texto, e nunca áudio

Aqui está a decisão que eu levaria para qualquer projeto.

O caminho natural, o que quase todo mundo faz, seria colocar um botão de gravar. A pessoa fala, o app manda o áudio para um modelo, o modelo transcreve, e aí um segundo modelo interpreta o texto. Duas chamadas, duas contas, dois pontos de falha, e um upload de áudio saindo do celular do vendedor num 4G ruim de feira.

Eu não faço nada disso. O campo é uma caixa de texto comum, e a frase que aparece em cima dela diz:

Digite livremente seus produtos e preços ou use o 🎤 microfone do teclado para transcrever.

O teclado de todo celular, em toda plataforma, já tem um botão de microfone. Ele transcreve no aparelho, de graça, com o motor de voz do próprio sistema, que é treinado no idioma do usuário e já entende o sotaque dele.

Eu terceirizo a parte cara para uma peça que já está instalada em todo celular do mundo, e fico só com a parte que é de fato o meu problema: interpretar o texto.

O ganho é em quatro frentes ao mesmo tempo:

  1. Custo. Uma chamada em vez de duas, e sem tokens de áudio, que são os caros.
  2. Rede. Sobe texto, não áudio. Numa feira com 4G ruim, isso é a diferença entre funcionar e não funcionar.
  3. Privacidade. A voz do vendedor nunca sai do aparelho dele.
  4. Superfície de código. Não existe upload de mídia, nem formato de áudio, nem permissão de microfone para pedir, nem gravação interrompida para tratar. Tudo isso é código que eu não escrevi e não preciso manter.

E o custo é zero, porque para quem prefere digitar, digitar continua sendo o caminho. É o mesmo campo. Não há dois fluxos, há um só, e o usuário escolhe como preenchê-lo sem que o app precise saber.

O portão que roda antes da IA

Tem mais uma peça, e ela é barata e cortante. Antes de gastar uma chamada ao modelo, uma expressão regular pergunta se aquele texto tem cara de cardápio: existe pelo menos uma palavra de duas letras, e existe pelo menos um número, seja em dígito ou por extenso.

Ela conhece os numerais por extenso do português inteiro, de “um” a “novecentos”, com “meia”, “meio”, “mil” e as grafias alternativas como “catorze” e “cincoenta”. Porque quem dita fala por extenso e quem digita não, e os dois têm que funcionar.

Se o texto não passa nesse portão, a pessoa recebe um aviso na hora dizendo o que faltou, e nenhuma chamada é feita. Antes disso o app dizia “A IA falhou ao processar seu texto”, que é mentira: a IA não tinha nem rodado.

Nove produtos encontrados pela IA, cada um editável antes de salvarOs produtos já na lista de venda, com nome e preço
Nove produtos encontrados, todos editáveis antes de salvar, incluindo o coração de frango a R$ 8,00 que veio de “oito reais”. À direita, o catálogo já na lista de venda.

Duas medições ficaram no código, porque este é o tipo de coisa que volta a morder:

  • A expressão tem dois lookaheads que começam com .*, então o tempo cresce ao quadrado do tamanho da entrada. Medi com a pior entrada que consegui construir: 1,77 ms em 1000 caracteres. É seguro porque 1000 é o teto, imposto nos dois lados, no cliente e no servidor. Subir esse teto custa quadrático, e o comentário no código diz isso para quem for mexer.
  • O portão erra para o lado permissivo de propósito. “Cento de salgado” passa sem preço nenhum, e passar custa um dos usos diários. Aceito, porque o erro contrário, recusar um preço legítimo, é o bug que já voltou três vezes.
1.75:1contraste do verde da logo sobre branco
4.5:1mínimo legível
2azuis, porque um só não cabia
+5,6 pxa mesma fonte, larguras diferentes
O design

Cor medida, não escolhida

Aqui é onde o projeto virou outra coisa.

O app tem tema claro e escuro, e um sistema de tokens. Nenhum componente conhece uma cor; todos conhecem um papel: surface, fg, success, danger, warning. A troca de tema não passa por nenhum if no código de tela.

Isso já é comum. O que não é comum é o que veio depois: eu parei de escolher as cores e passei a medi-las.

A tela de venda no tema claroA mesma tela de venda no tema escuro
A mesma tela nos dois temas. Nenhum componente conhece uma cor; todos conhecem um papel.

O verde da minha logo é #00E175. É bonito. Sobre branco, ele dá 1.75:1 de contraste, e o mínimo legível é 4.5:1. Ou seja: aquele verde nunca pode ser texto. Ele preenche; a tinta é uma versão escurecida dele, no mesmo matiz. (Esta página segue a mesma regra: o verde aparece em toda parte e nunca como texto.)

O azul da marca não podia ser o azul dos botões, e o motivo é aritmético, não estético. Para o texto azul passar no critério de acessibilidade em cima do próprio tinte, o tinte precisaria ser claro demais; para o tinte se ver sobre o fundo do app, precisaria ser escuro demais. A interseção é vazia. Não existe. Então há dois azuis:

#004DFF#2466FF
texto branco em cima5.994.75
sobre o fundo claro4.973.94
sobre o fundo escuro3.103.90
sobre o painel de modal escuro2.032.55

O forte fica na faixa do cabeçalho, que é superfície. O mais claro vira o azul de todo botão: perde um pouco no tema claro e melhora justamente onde o outro era mais fraco.

Os botões são “teclas”: material sólido com uma aresta escura embaixo, que dá relevo físico. A regra é uma linha:

A aresta é o próprio preenchimento multiplicado por ⅔, canal a canal, no mesmo matiz.

Aresta de outra família lê como duas peças coladas, não como uma tecla. E o que se iguala entre os botões é a razão de relevo (~2.20:1), não o multiplicador: o mesmo ⅔ dá 2.20:1 no verde e só 1.70:1 no azul, porque a face azul parte de uma claridade menor.

A decisão que eu perdi para a régua

Eu queria uma cor própria para o fiado. Fui medir. Todo âmbar que passa no critério de contraste nos dois temas cai a 8 ou 15 graus de matiz do amarelo de “atenção” que já existia, perto demais para o olho distinguir.

Não existe amarelo de fiado. E, pensando bem, nem precisa: atenção e fiado são a mesma coisa. Os dois são pendência.

E tudo isso é um teste que roda. Ele lê o CSS real e reprova o build se um matiz sair da família, se um texto não passar no contraste sobre a superfície em que ele de fato pousa, ou se a rampa de elevação do tema escuro inverter. Não é um documento dizendo como deveria ser. É um portão.

Ele existe porque a alternativa falhou: uma superfície ficou em matiz 43 (marrom) no tema escuro enquanto o resto da paleta vivia entre 213 e 222 (azul-ardósia), pintando o cartão da lista de produtos de uma cor que não pertencia ao sistema. Passou por tudo. tsc não lê string. eslint não lê string. O build gerou a regra sem reclamar.

A fonte, e a lição que ela deu

O app usa duas fontes próprias, auto-hospedadas: Google Sans Flex para a interface e Geist Mono para os nomes de produto e os endereços do QR. Os arquivos vivem no repositório, em woff2, fatiados por faixa de caracteres, com licença e procedência registradas ao lado. Auto-hospedar não é purismo: é para o vendedor não depender de um terceiro no meio da feira.

Antes disso o app não escolhia fonte nenhuma. Valia o padrão de cada sistema, o que dá San Francisco no iPhone, Roboto no Android e uma terceira coisa no servidor de integração. O mesmo desenho medindo três coisas diferentes.

E aí veio a parte que eu não esperava. Depois de a fonte entrar no repositório, dois testes de geometria continuavam falhando no servidor e passando na minha máquina. A hipótese óbvia era que a fonte não estava carregando lá. Medi:

                       minha máquina   servidor
largura do texto          105.375       105.375   ← idêntica: a fonte carregou
largura dos botões        169.172       174.766   ← +5,6px, e é daqui que vem

A fonte tinha chegado. A largura não. O motor de rasterização do Linux aplica um ajuste de contorno diferente do da minha máquina, e o mesmo arquivo produz medidas diferentes.

Fonte igual não garante largura igual.

O que fechou foi pedir ao navegador a métrica geométrica pura, sem ajuste do sistema operacional. Depois disso, 69.375 contra 69.375. E teve um segundo capítulo: a primeira correção aplicou isso só na raiz do documento, e o botão não herdava. Era justamente o botão que inchava.

Outros detalhes que viraram teste em vez de intenção

  • Todo campo de texto tem no mínimo 16px. Abaixo disso, o Safari do iPhone dá zoom sozinho ao focar, e a tela pula. Um scanner varre o JSX e reprova qualquer campo menor.
  • Todo diálogo é um diálogo de verdade: se anuncia como tal para leitores de tela, fecha no Escape, prende o foco dentro dele e trava a rolagem de trás. Antes disso havia 16 overlays montados à mão em 14 arquivos, e nenhum fazia nada disso.
  • O nome acessível contém o texto visível (WCAG 2.5.3). Para quem controla o app por voz, o que está escrito no botão tem que ser o que funciona quando você fala.
  • O confirm() do navegador foi embora, nas nove chamadas que existiam, e virou um diálogo do próprio app, com a mesma trava de foco de todos os outros.

O som que nunca mente

O app toca um som quando a venda é registrada. Parece detalhe. É a regra mais rígida do código inteiro:

O som marca o desfecho, nunca o toque.

Dos seis pontos que tocam som, quatro vão à rede e podem falhar. Se a chamada estivesse no onClick do botão, com 4G ruim na feira o vendedor ouviria “registrado”, o pedido falharia, e ele chamaria o próximo cliente. O app teria mentido para ele no único momento em que ele confiava no app sem olhar.

Então a chamada mora sempre depois do await, no ramo que já sabe o resultado. E existe um teste que lê o código-fonte e reprova qualquer som que apareça num onClick.

Tem um corolário que eu gosto ainda mais. Quando o vendedor toca duas vezes em confirmar, o que acontece o tempo todo, com a mão suja e pressa, o segundo toque não toca som nenhum. Não houve venda para anunciar. Um “confirmado” ali seria a mesma mentira, só que na direção contrária.

E o som é sempre redundância, nunca a única confirmação. No iPhone com o silencioso ligado ele simplesmente não existe, e o Safari não implementa vibração em versão nenhuma. Existem contornos conhecidos para forçar o som mesmo assim. Nenhum deles entrou: respeitar o interruptor físico é o contrato que o dono do aparelho espera.

9defeitos que a suíte antiga não pegava
502testes automatizados hoje
14 sverificação completa
85 : 82arquivos de teste por arquivo de código
A verificação

Como eu sei que funciona

Aqui está a parte de que eu mais me orgulho, e é onde o projeto deixou de ser um app e virou um exercício de engenharia.

Comecei com o de sempre: TypeScript, ESLint, testes unitários. Verde em tudo. E então, numa revisão, encontrei nove defeitos, e nenhum dos nove era pego pela suíte que eu tinha. Todos os nove já estavam no ar.

O diagnóstico foi desconfortável e útil: eu tinha quatro camadas de gente (e de máquina) lendo código, e nenhuma executando comportamento. Verificação de forma pega erro de forma. Toda a classe de defeito que é sequência, ou seja, estado que aparece na ordem errada, foco que vai para o lugar errado, ramo que não renderiza, passa por typecheck, lint, revisão e implementação sem tocar em nada.

Então hoje são quatro camadas, e a escolha entre elas não é gosto:

CamadaO que ela consegue provar
Lógica pura e forma do códigoA regra está certa; o arquivo declara X e o chamador usa Y
Runtime (DOM real)O que renderiza em qual estado, para onde o foco vai, o que entra na árvore depois do commit
Geometria (Chromium)Para que lado o painel se move, o que o CSS resolve, o que vira bloco contentor
Banco (SQL real)As regras de dados valem sob as permissões de um usuário comum

E o comando que roda a maior parte disso leva 14 segundos. Isso é o número que importa: uma verificação que demora dois minutos é uma verificação que você deixa de rodar.

# camada 1: lógica e forma do código
$ npm test
ℹ tests 392
ℹ pass 392
ℹ fail 0
ℹ duration_ms 1271.589542

# camada 2: comportamento, em DOM real
$ npm run test:runtime
ℹ tests 110
ℹ pass 110
ℹ fail 0
ℹ duration_ms 3035.397875

# camada 3: geometria, em Chromium
$ npm run test:visual
  41 passed (48.0s)

# o que roda sozinho a cada turno
$ time npm run verify
real 14.59
user 40.61
sys   4.29
As quatro linhas, rodadas em 27/08/2026. 502 testes nas duas primeiras camadas, 41 na terceira, e a verificação inteira em 14,59 s. Nenhum número deste artigo foi estimado.

Ela roda sozinha. Um hook dispara a verificação ao fim de cada turno de trabalho em que algum arquivo de código mudou, e bloqueia o encerramento se falhar. Regra escrita pede; hook executa. A diferença entre as duas é a diferença entre um processo e um bilhete na geladeira.

O exemplo que justifica a quarta camada

Eu quase não construí a camada de geometria. Parecia caro demais subir um Chromium de verdade para testar CSS. Aqui está o caso que me convenceu, e ele é literal.

Existe uma regra no painel dos modais: transform: none. Troque por transform: scale(1).

Isso passa no typecheck. Passa no lint. Passa nas duas primeiras camadas de teste. Passa até no teste que roda o Tailwind de verdade. E é invisível em qualquer inspeção, porque matrix(1, 0, 0, 1, 0, 0) é a matriz identidade, ou seja, o elemento não se move um pixel.

Só que qualquer transform diferente de none faz o elemento virar bloco contentor dos descendentes posicionados de forma fixa. E o calendário que abre dentro do modal de pagamento, que deveria ocupar a largura da tela, encolhe para a largura do painel.

Esperado: 1280  ·  Recebido: 384

Nenhuma releitura de código pega isso. Só o navegador sabe.

A regra que mudou como eu escrevo teste

Toda correção ganha um teste. Isso é padrão. O que não é padrão é a segunda metade:

Escreva o teste. Desfaça a correção. Veja o teste ficar vermelho pelo motivo certo. Restaure.

Sem esse passo você não sabe se o teste cobre o bug ou se ele só passeia perto dele. E isso não é teoria: eu escrevi um teste de diálogo aninhado que passava com e sem a correção. Ele foi removido, e a nota do porquê ficou no arquivo.

Um teste que nunca pôde ficar vermelho é pior que nenhum teste. Ele consome a atenção que o verde deveria merecer.

O mesmo raciocínio pegou uma armadilha na camada de geometria. Um test.only esquecido faz a suíte inteira virar “1 passed”: verde, bonito, com sete testes pulados e nenhuma linha de aviso. Hoje isso é erro fatal no servidor de integração, nomeando o teste culpado. Verde mentiroso é o pior estado possível de um projeto, porque ele é indistinguível do verde verdadeiro exatamente até o dia em que não é.

5 → 3lugares respondendo, três respostas
R$ 22gravado enquanto a tela mostrava R$ 10
O achado

O bug de que eu mais gosto

Uma pergunta simples: quanto vale o carrinho que está montado agora?

Cinco lugares do app respondiam isso. E davam três respostas diferentes para o mesmo carrinho, no mesmo instante, quando um produto do carrinho tinha sido apagado do cadastro no meio do caminho. Um zerava o item. Outro usava o preço congelado. Outro descartava.

Carrinho de R$ 22 → a tela mostrava R$ 10 → a venda gravava R$ 22
Com desconto: o vendedor cobrava R$ 2 → o caixa registrava R$ 14

Esse é o tipo de defeito que não cabe em nenhum diff. Nenhum dos cinco arquivos está errado sozinho. A contradição só existe entre eles. Revisão olha diff, então é estruturalmente cega para isso, e olhar mais vezes não ajuda.

A correção óbvia seria uma função única que os cinco passassem a chamar. Não foi essa. A correção que entrou é menor e ataca a causa: as duas portas que tiram um produto do cadastro passaram a limpar o carrinho junto, estabelecendo o invariante “todo item do carrinho existe no cadastro”. Com ele, os cinco leitores ficam corretos por construção.

Toda pergunta de negócio precisa de um dono nomeado. Quando cinco lugares respondem, a divergência não é questão de se, é de quando.

Guarde essa frase. Ela volta na próxima seção, e da segunda vez o assunto não é dinheiro na tela. É quem você é.

3 → 2objetos decidindo identidade, dois critérios
8defeitos no próprio conserto
0achados por leitura de código
A segurança

Sem a planta baixa

Não vou detalhar as defesas, porque dizer exatamente onde estão as fechaduras não ajuda ninguém a dormir melhor. Mas a postura vale, e o processo vale mais ainda.

O que se sustentou sob teste: política de conteúdo sem execução de script inline, transporte estrito com pré-carga, listas de origem permitida que falham fechado e são separadas por função (porque o raio de dano de cada uma é diferente), dependências sem alerta conhecido, isolamento por linha ativado e forçado em todas as tabelas, nenhuma permissão concedida a visitante anônimo, e toda função de banco com o caminho de busca fixado.

Mas o achado que importa é outro, e ele é a mesma lição do carrinho, aplicada em cima de identidade.

Três objetos do banco respondiam a pergunta “qual conta é esta sessão?”, com dois critérios diferentes.

Uma auditoria anterior tinha corrigido dois deles. O terceiro era justamente o que escreve o vínculo, e ninguém tinha percebido, porque cada um dos três, lido sozinho, parece certo. A contradição só existe entre eles. É literalmente o bug do carrinho, com outro nome e outro estrago possível.

A correção é a mesma forma: uma função passa a ser a dona única do critério, e os três consumidores passam a chamá-la. O critério é o que o provedor de login verificou de fato, nunca um campo que o usuário controla. E o resultado foi medido depois, com o ataque rodado contra o banco real: ele agora não grava nada e não concede nada.

Na mesma leva, as ações administrativas que faltavam (conceder administrador, ativar conta, lançar pagamento) passaram a deixar rastro numa trilha que só aceita inserção. Não dá para apagar o próprio caminho.

A parte mais útil: a revisão do próprio conserto

Depois de corrigir, eu revisei a correção. Ela tinha oito defeitos.

E aqui está o dado que eu levo para o resto da carreira: nenhum dos oito foi encontrado lendo o código. Todos saíram de comando executado, e três deles vieram da ferramenta de análise da plataforma e da própria suíte falhando, não de olho humano. Alguns exemplos:

  • A nova dona do critério falhava aberto quando o campo vinha vazio, porque em SQL uma string vazia se compara igual e um nulo fecha a comparação. Achado por uma prova em transação com rollback.
  • A migração dizia “não muda comportamento” e mudava: um limit 1 esquecido estreitou “existe alguma identidade verificada que afirma este e-mail” para “a mais recente afirma”. Isso trancaria o primeiro login de alguém, em silêncio.
  • Uma instrução que atualizava dois campos de poder de uma vez gravava só o primeiro evento na auditoria.
  • Uma nova função interna nasceu com permissão de execução para visitante anônimo, e a prova de permissões que eu tinha escrito excluía justamente aquele tipo de objeto por filtro. Quem viu foi a ferramenta da plataforma.

Esse último é o mais instrutivo, porque ele apareceu por causa da correção anterior. Consertar cria superfície nova. A lição que ficou escrita no repositório é curta: a suíte não substitui o analisador da plataforma, porque os dois olham coisas diferentes.

O repositório guarda 13 auditorias, e a última se chama, literalmente, “os oito defeitos que a review achou no próprio conserto”. Não é autodepreciação. É o registro de que o processo funcionou.

72documentos, ~45 mil linhas de prosa
13auditorias registradas
O processo

A documentação que não apodrece

O projeto tem 72 documentos e cerca de 45 mil linhas de prosa: especificações de design, planos de implementação, auditorias. E uma regra que mudou tudo:

Nenhum fato que expira entra escrito. Entra o comando que o redescobre.

Documento é permanente; o mundo não é. “A consulta devolve 19” vira 18 no dia seguinte. “O arquivo tem 909 linhas” vira 919. Um número de linha citado numa doc apodrece na primeira edição alheia e passa a apontar para outra coisa com cara de certo, que é o pior tipo de errado.

Então as specs não trazem a resposta, trazem a query, o grep, o comando que a reencontra. Uma referência móvel vira um identificador imutável. Isso converte a taxa de decaimento da documentação em zero, em vez de depender de alguém lembrar de reconferir.

O corolário incomoda e é verdadeiro: prosa é afirmação sobre o código, e é onde a afirmação errada sobrevive, porque nada a compila. Typecheck, lint, teste e build passam todos verdes com a documentação mentindo.

O código segue a mesma disciplina. Os comentários deste projeto quase nunca dizem o que o código faz. Dizem por que ele é assim, o que foi tentado antes, e o que quebrou. Um exemplo real, sobre por que um componente de 12 linhas existe separado:

Componente próprio por um motivo de teste, e não de reuso, já que ele tem um só chamador. Enquanto o markup morava inline, a bancada de testes tinha que copiá-lo, e cópia drifta em silêncio: medido, a suíte visual seguia “15 passed” medindo um layout que já não existia.

Isso não é comentário. É a prova de por que a alternativa mais simples não serve.

Há até um plano no repositório cujo cabeçalho avisa: “o executado difere do escrito, leia isto antes”, e lista as três coisas que a execução desmentiu. Plano não é profecia. É registro datado do que se pensava, e a execução tem o direito de contradizê-lo, desde que a contradição fique escrita.

524commits em quatro meses
210 KBna rede, comprimido
O balanço

O que os números dizem

14.055linhas de código, em 82 arquivos
12telas
73migrações de banco
502testes automatizados passando
41testes de geometria em Chromium
14provas de invariante em SQL
~14 sverificação completa
~210 KBJS + CSS comprimidos

O último número é o que fecha o círculo. 210 KB. É menos que uma foto de celular. Um app que precisa funcionar num aparelho barato, com 4G ruim, numa calçada, não pode custar quatro megabytes para abrir.

O que eu levo daqui

  1. A restrição certa mata mais features do que qualquer roadmap. “Dois toques” não foi uma meta de usabilidade, foi um filtro. Toda vez que apareceu uma ideia boa, a pergunta não foi “isso é útil?”, foi “isso cabe em dois toques?”. A maioria das ideias boas não cabia. Elas continuam boas e continuam de fora.
  2. A melhor solução de IA costuma ser a que usa menos IA. O caminho óbvio seria mandar áudio para um modelo e pagar a transcrição. O caminho certo foi perceber que o teclado do celular já faz isso de graça, melhor, e sem tirar a voz do usuário do aparelho. Sobrou para a IA exatamente a parte que só ela resolve. Antes de escolher um modelo, vale perguntar quanto do problema já está resolvido por algo que o usuário tem no bolso.
  3. Medir é diferente de escolher. Metade das minhas decisões estéticas foram derrubadas por uma conta de contraste, e o resultado ficou melhor do que a minha intenção original. Inclusive quando eu perdi a discussão para a régua. A fonte deu a mesma lição em outro eixo: carregar o mesmo arquivo não garante a mesma largura.
  4. Ler código não é verificar código. Ler diz o que o código diz; só executar diz o que ele faz. A pergunta a fazer cedo em qualquer projeto é: qual comando faz o software rodar e falha sozinho quando o comportamento quebra? Se a resposta for “typecheck e lint”, não existe verificação de comportamento em lugar nenhum. Existe leitura em quatro camadas.
  5. Toda pergunta importante precisa de um dono nomeado. Isso apareceu duas vezes no mesmo projeto: “quanto vale o carrinho” respondido por cinco lugares, e “quem é esta sessão” respondido por três. Nos dois casos, nenhum arquivo estava errado sozinho, e nos dois casos a contradição custava caro.
  6. Verde precisa ser confiável, senão é pior que vermelho. Um teste que nunca pôde falhar, uma suíte que reporta “1 passed” com sete testes pulados, um documento que afirma o que era verdade há três meses: todos os três são a mesma doença. Eles gastam a sua confiança sem entregar nada em troca.
  7. O papel é um concorrente sério, e merece respeito. Ele não tem tela de carregamento, não pede login, não atualiza sozinho no meio do expediente e funciona com 0% de bateria. Qualquer software que pretenda substituí-lo tem que ganhar em atrito, não em recursos. Perder para o caderno com um app cheio de features não é azar. É ter competido na dimensão errada.

O cara da barraquinha ainda usa o caderno dele, e está tudo certo. Eu não construí isso para convencê-lo. Construí para descobrir se dava. Dá. Duas batidas de dedo, o dia fecha sozinho, e quando a rede cai ninguém percebe.

O resto, os 502 testes, as 73 migrações, a paleta medida em contraste, existe só para que essas duas batidas nunca falhem.

Voltar ao site