Reconstruindo um Dashboard SaaS em Um Dia com Claude Code — Tailwind v4 + Alinhamento Tabular com CSS Grid
- engineering
- tailwind
- css-grid
- dark-mode
- claude-code
TL;DR
- Problema: 11 de 15 novos usuários (73%) fizeram o cadastro mas saíram sem registrar nenhuma URL
- Hipótese: Todas as ferramentas de heatmap da nossa categoria usam UI clara por padrão — nosso dashboard escuro pode estar causando confusão e abandono precoce
- O que fizemos: Redesign completo do dashboard em um dia — toggle de tema claro / escuro / sistema + lista tabular no estilo GitHub
- Lições:
@custom-variantdo Tailwind v4, a armadilha da colunaautono CSS Grid, uma defesa em 3 camadas contra textos longos e por que 10 a 16 iterações de design é o normal, não a exceção- Resultado: Impacto na taxa de abandono ainda a ser medido; post de acompanhamento planejado após duas semanas de dados
Contexto: Estávamos Perdendo Usuários Por Causa do Dashboard Escuro Demais?
HeatMapX (heatmapx.com) é uma ferramenta de heatmap e CRO que pode ser chamada a partir do Claude Code via CLI. Depois de iniciar nosso primeiro esforço de divulgação, percebemos um padrão: 11 de 15 novos usuários (73%) completaram o cadastro mas saíram sem registrar nenhuma URL.
Uma hipótese: toda ferramenta de heatmap relevante na nossa categoria vem com uma UI clara por padrão. O HeatMapX era o único com interface de administração escura. Usuários chegando pela primeira vez a um dashboard escuro em um mar de apps com tema claro podem estar simplesmente saindo por confusão.
Então fizemos parceria com o Claude Code e realizamos um redesign completo em uma única sessão. Este post cobre a primeira metade desse teste de hipótese — diagnosticar o abandono e executar o redesign. O acompanhamento com os números reais virá assim que tivermos duas semanas de dados pós-deploy.
Configuração Final do Design
| Elemento | Claro | Escuro |
|---|---|---|
| Fundo da página | bg-neutral-100 (#f5f5f5) |
dark:bg-neutral-950 (#0a0a0a) |
| Superfície do card | bg-white + border-slate-200 |
dark:bg-slate-900 + dark:border-slate-800 |
| Texto principal | text-slate-900 |
dark:text-slate-100 |
| Cor principal (CTA) | bg-orange-600 (#ea580c) |
Compartilhada entre os dois modos |
| Raio de borda | rounded-md (6px) · sem sombras · estilo GitHub |
|
| Toggle de tema | ☀ / 💻 / 🌙 toggle de três estados no cabeçalho |
Dark Mode com Tailwind v4 — Do Zero-Config ao Toggle Manual
Começando com @media prefers-color-scheme
O comportamento padrão do Tailwind v4 mapeia a variante dark: diretamente para @media (prefers-color-scheme: dark). Sem nenhuma configuração, ele simplesmente acompanha a configuração de modo escuro do sistema operacional.
{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
...
</div>
Isso cobre o caso de seguir o sistema (escuro automático à noite, etc.) sem nenhuma configuração adicional. Mas às vezes os usuários querem substituir a configuração do SO — "sistema" pode falhar, ou é simplesmente preferência pessoal — então estendemos para um toggle de três estados.
Mudando para a Variante Dark Baseada em Classe
No Tailwind v4 você pode redefinir o comportamento da variante dark: usando @custom-variant. Mudamos para um modo onde dark: só é ativado quando .dark está presente em <html>:
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
O wrapper :where() mantém a especificidade em 0. Sem ele, os estilos da variante dark podem entrar em conflito com regras de especificidade existentes em outras partes da sua folha de estilos.
Script de Sincronização para Evitar Flash na SSR
Se você aplica o tema dentro de useEffect após a hidratação do React, você obtém um flash de conteúdo sem estilo (FOUC) — um piscar branco breve no primeiro render. A solução é um script de sincronização inline injetado no <head> que é executado antes do body renderizar:
{/* app/layout.tsx */}
<head>
<script dangerouslySetInnerHTML={{ __html: `
(function(){try{
var t = localStorage.getItem('theme') || 'system';
var d = t === 'dark' || (t === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (d) document.documentElement.classList.add('dark');
}catch(e){}})();
`}} />
</head>
Isso é executado sincronamente antes do React montar, então .dark já está no elemento <html> quando o body começa a renderizar. Adicione suppressHydrationWarning em <html> para silenciar o aviso de incompatibilidade de hidratação.
💡 Lição 1: O
dark:do Tailwind v4 funciona sem configuração. Se rastrear o sistema operacional é tudo que você precisa, não precisa tocar em nada. Só adicione@custom-variant+ o script de sincronização quando precisar especificamente de um toggle manual pelo usuário — uma abordagem limpa em dois estágios.
Alinhamento Tabular com CSS Grid — e Onde Colunas auto Te Pegam
A Lista Inicial de Uma Coluna Não Era Suficiente
Depois de notar que os cards de site pós-registro pareciam "estranhos", primeiro tentamos o óbvio: trocamos um grid de cards de duas colunas por uma lista de uma coluna. Cada SiteCard usava flexbox internamente para alinhar nome · url · status · eventos, com um chevron à direita.
O problema apareceu assim que havia múltiplos itens:
Curto · example.com · ● Ativo · 5.577
Nome de Site Muito Longo... · normal.com · ● Ativo · 5.577
ACME · acme.io · ● Ativo · 5.577
O · Ativo, · 5.577 e o › chevron caem em coordenadas x diferentes em cada linha. Seu olho não consegue fixar em "a coluna de status é aqui." Não parece uma lista — parece strings empilhadas.
Aquilo não era uma lista. Era texto empilhado verticalmente.
Convertendo para um Layout Tabular com CSS Grid
Substituímos o flexbox interno por uma definição de coluna fixa com CSS Grid. Como todos os cards compartilham o mesmo template de grid, as bordas esquerdas das colunas se alinham perfeitamente ao longo da página:
// SiteCard.tsx
<Link className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] items-center gap-x-4 ...">
<h3 className="truncate">{name}</h3>
<span className="truncate">{displayUrl}</span>
<span className="truncate font-mono">{apiKey}</span>
<span className="truncate font-mono">{trackerSnippet}</span>
<span>● Ativo</span>
<span>{count}</span>
<span>›</span>
</Link>
A linha de cabeçalho usa o exato mesmo template de grid:
// page.tsx linha de cabeçalho
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] gap-x-4 ...">
<span>SITE</span>
<span>URL</span>
<span>API KEY</span>
<span>TRACKER TAG</span>
<span>STATUS</span>
<span>HOJE</span>
<span></span>
</div>
A Armadilha: Colunas auto em Grids Separados São Dimensionadas Independentemente
Nossa primeira versão usava grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]. O raciocínio: "auto se adapta ao conteúdo, e como o cabeçalho e o SiteCard compartilham a mesma string de template, eles vão se alinhar."
Não alinharam. Eis o motivo:
- A coluna
autodo cabeçalho: dimensionada com a largura do texto "STATUS" - A coluna
autodo SiteCard: dimensionada com a largura do texto "● Ativo" - Ambos são containers de grid separados — o dimensionamento das trilhas é calculado independentemente para cada um
Duas soluções:
- Largura fixa: Substitua
autopor valores explícitos em pixels como110px,72px(o que colocamos em produção) - CSS Subgrid: Coloque o cabeçalho e todos os SiteCards dentro de um único grid pai, então faça cada linha herdar as trilhas do pai via
subgrid(mais complexo)
💡 Lição 2: Não use colunas
autoquando precisa de alinhamento tabular entre containers de grid separados. Cada container calcula suas próprias larguras de trilha baseado em seu próprio conteúdo. Use larguras fixas em pixels ou compartilhe trilhas via subgrid.
Defendendo-se Contra Textos Longos — 3 Camadas
O schema inicial do banco de dados tinha:
CREATE TABLE sites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- sem limite de tamanho
url text NOT NULL, -- sem limite de tamanho
...
);
O text do Postgres é efetivamente ilimitado. Um nome de site com 10.000 caracteres é SQL perfeitamente válido — e destruiria um layout tabular instantaneamente. Migrar para um grid de colunas fixas tornou as restrições de tamanho de texto inegociáveis.
A defesa em 3 camadas que chegamos:
- Limite HTML5 no frontend:
maxLength={60}/maxLength={512}nos inputs - Validação no servidor: Server Action verifica
name.length > 60e retorna um erro - Truncamento na exibição: Classe
truncateem cada célula do grid, combinada comminmax(0, ...)para habilitar o overflow
A camada 3 é a que as pessoas esquecem. truncate se expande para overflow: hidden + text-overflow: ellipsis + white-space: nowrap, mas em um contexto flex ou grid também precisa de min-width: 0 na célula — caso contrário, a célula se expande para caber seu conteúdo e o overflow nunca é acionado. minmax(0, ...) fornece exatamente isso.
16 Iterações de Design
Só o SiteCard passou por 16 iterações de design nesta sessão. Algumas selecionadas:
| # | Mudança | Motivo |
|---|---|---|
| 1 | Converteu CopyBlock para pílula cinza clara | Fundo preto + texto verde ficou estranho no modo claro do GitHub |
| 3 | Comparação de quatro opções A/B/C/D | Precisava de affordance de clique mais forte |
| 4 | Mudou para lista de 1 coluna | Rejeitada como "decorativa demais" — de volta ao básico |
| 7 | Removeu CTA "Ver Heatmap →" | "Não parece limpo" |
| 9 | Converteu para layout tabular com CSS Grid | Layout de string com separador "não parece uma lista" |
| 10 | auto → larguras fixas em pixels |
Colunas do cabeçalho e do card não alinhavam |
| 11 | Restaurou coluna API KEY | "Espera, onde está a chave de API?" |
| 12 | Adicionou coluna TRACKER TAG | "Coloque os dois lado a lado" |
| 14 | Adicionou maxLength + validação no servidor | Correção adequada para robustez com textos longos |
| 16 | Adicionou botão "+" ao lado do título (modo compacto) | Adição rápida sem precisar rolar |
⚠ Não espere acertar na primeira tentativa. Iteração de design significa "verificar no servidor de desenvolvimento → feedback imediato → próxima tentativa", repetida 10+ vezes. Planeje esse ciclo. Um ambiente de desenvolvimento de IA interativo como o Claude Code é onde esse padrão realmente brilha.
Padrões Que Realmente Funcionaram com Desenvolvimento Assistido por IA
Essas não são observações específicas de ferramentas — são padrões gerais que se mostraram eficazes ao trabalhar em par com uma IA interativa:
- Zero custo de busca para nova sintaxe de API: O
@custom-variantdo Tailwind v4 é relativamente novo. O Claude Code o trouxe imediatamente em forma funcional, sem precisar vasculhar documentação. - O ciclo implementar → verificar → corrigir roda em segundos: Reproduzir o desalinhamento
autodo CSS Grid, isolar a causa e publicar a correção levou cerca de 5 minutos no total. - Trabalho mecânico em massa desaparece: Substituir texto de placeholder em 15 arquivos de localização (
in-gol→example.com) em uma única iteração. - Afinidade com HMR: Editar → salvar → atualização instantânea no browser → feedback → reeditar. O ciclo roda rápido o suficiente para você permanecer em estado de fluxo.
- Do início ao fim em uma sessão: Do término do redesign direto até o push em produção, assistindo ao deploy no Vercel — sem troca de contexto.
Os padrões que não funcionaram tão bem estão na próxima seção.
O Que Foi Difícil / O Que Ainda Resta
- Traduzir feedback vago em decisões estruturais multiplicou o número de iterações. "Isso está estranho" precisava se tornar "tabular vs. lista vs. cards" antes que algo útil pudesse acontecer. Chegar a esse acordo estrutural logo no início teria economizado 3 a 4 iterações.
- Por volta da iteração 10, os requisitos continuavam se expandindo — "tudo inline", "tudo truncado", "todas as colunas" — e algumas restrições estavam genuinamente em tensão (densidade de informação vs. facilidade de leitura). Não havia atalho limpo para a forma final.
- Mobile não foi tocado. Um grid de 7 colunas produz rolagem horizontal em telas pequenas. A correção é ou uma media query
sm:que empilha colunas, ou um componente mobile separado. Esta é a próxima coisa a atacar.
Conclusão
Em uma única sessão, reconstruímos o dashboard do HeatMapX de um grid de cards escuro para um layout com toggle claro/escuro/sistema e uma lista tabular no estilo GitHub. As principais conclusões técnicas:
- Dark mode com Tailwind v4: Comece com dark mode baseado em
@media— é zero-config. Só adicione@custom-variant+ o script de sincronização quando os usuários precisarem de um toggle manual. - Alinhamento tabular com CSS Grid: Colunas
autonão se alinham em containers de grid separados. Use larguras fixas em pixels ou CSS Subgrid — essas são as únicas opções confiáveis. - Robustez com textos longos: Três camadas, todas necessárias —
maxLengthno frontend, verificação de tamanho no servidor etruncatena exibição comminmax(0, ...). - Orçamento de iterações de design: Assuma 10 a 16 iterações por componente e construa um ciclo de feedback rápido desde o início.
Se isso realmente vai mover a taxa de abandono ainda é uma questão em aberto. Publicaremos o acompanhamento com duas semanas de dados reais.