Reconstruyendo un Dashboard SaaS en un Día con Claude Code — Tailwind v4 + Alineación Tabular con CSS Grid

HeatMapX Engineering Team15 min read
  • engineering
  • tailwind
  • css-grid
  • dark-mode
  • claude-code

TL;DR

  • Problema: 11 de 15 usuarios nuevos (73%) se registraron pero se fueron sin registrar una URL
  • Hipótesis: Todas las herramientas de heatmap de nuestra categoría usan una UI clara por defecto — nuestro dashboard oscuro puede estar generando confusión y abandono temprano
  • Qué hicimos: Rediseño completo del dashboard en un día — toggle de tres estados claro / oscuro / sistema + lista tabular estilo GitHub
  • Lecciones: @custom-variant de Tailwind v4, la trampa de columnas auto en CSS Grid, una defensa de 3 capas contra texto largo, y por qué 10–16 iteraciones de diseño es la norma, no la excepción
  • Resultado: El impacto en la tasa de abandono está por medirse; post de seguimiento planificado tras dos semanas de datos

Contexto: ¿Estábamos perdiendo usuarios porque el dashboard era demasiado oscuro?

HeatMapX (heatmapx.com) es una herramienta de heatmap y CRO que puedes invocar desde Claude Code mediante CLI. Tras arrancar nuestra primera campaña de promoción, notamos un patrón: 11 de 15 usuarios nuevos (73%) completaron el registro pero se fueron sin registrar ni una sola URL.

Una hipótesis: todas las herramientas de heatmap importantes de nuestra categoría ofrecen una UI clara por defecto. HeatMapX era la excepción con una interfaz de administración oscura. Los usuarios nuevos que aterrizan en un dashboard oscuro en un mar de apps de tema blanco pueden simplemente abandonar por confusión.

Así que nos asociamos con Claude Code y realizamos un rediseño completo en una sola sesión. Este post cubre la primera mitad de esa prueba de hipótesis — diagnosticar el abandono y ejecutar el rediseño. El seguimiento con números reales llegará cuando tengamos dos semanas de datos post-despliegue.

La Configuración de Diseño Final

Elemento Claro Oscuro
Fondo de página bg-neutral-100 (#f5f5f5) dark:bg-neutral-950 (#0a0a0a)
Superficie de tarjeta bg-white + border-slate-200 dark:bg-slate-900 + dark:border-slate-800
Texto principal text-slate-900 dark:text-slate-100
Color clave (CTA) bg-orange-600 (#ea580c) Compartido entre ambos modos
Radio de borde rounded-md (6px) · sin sombras · estilo GitHub
Toggle de tema ☀ / 💻 / 🌙 toggle de tres estados en el header

Modo Oscuro en Tailwind v4 — De Cero Configuración a Toggle Manual

Empezando con @media prefers-color-scheme

El comportamiento por defecto de Tailwind v4 mapea la variante dark: directamente a @media (prefers-color-scheme: dark). De serie, sin ninguna configuración, simplemente sigue el modo oscuro del sistema operativo.

{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
  ...
</div>

Esto cubre el caso de seguir el sistema operativo (oscuro automático de noche, etc.) sin configuración adicional. Pero los usuarios a veces quieren sobreescribir la configuración del SO — "sistema" puede fallar, o simplemente es preferencia personal — así que lo extendimos a un toggle de tres estados.

Cambiando a la variante dark basada en clase

En Tailwind v4 puedes redefinir el comportamiento de la variante dark: usando @custom-variant. Cambiamos a un modo donde dark: solo se activa cuando .dark está presente en <html>:

/* globals.css */
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

El wrapper :where() mantiene la especificidad en 0. Sin él, los estilos de la variante dark pueden entrar en conflicto con las reglas de especificidad existentes en tu hoja de estilos.

Script de sincronización para prevenir el flash SSR

Si aplicas el tema dentro de useEffect después de la hidratación de React, obtienes un flash-of-unstyled-content (FOUC) — un breve parpadeo blanco en el primer renderizado. La solución es un script de sincronización inline inyectado en <head> que se ejecuta antes de que el body se renderice:

{/* 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>

Esto se ejecuta de forma síncrona antes de que React se monte, por lo que .dark ya está en el elemento <html> cuando el body comienza a renderizarse. Añade suppressHydrationWarning a <html> para silenciar la advertencia de desajuste de hidratación.

💡 Lección 1: El dark: de Tailwind v4 funciona sin configuración. Si solo necesitas seguir el SO, no tienes que tocar nada. Solo añade @custom-variant + el script de sincronización cuando específicamente necesites un toggle manual de usuario — un enfoque limpio en dos etapas.

Alineación Tabular con CSS Grid — y Dónde las Columnas auto Te Traicionan

La lista inicial de una sola columna no era suficiente

Tras notar que las tarjetas de sitio post-registro se sentían "raras", primero intentamos lo obvio: cambiamos un grid de tarjetas de dos columnas por una lista de una columna. Cada SiteCard usaba flexbox internamente para alinear nombre · url · estado · eventos, con un chevron a la derecha.

El problema apareció en cuanto había varios elementos:

Corto · ejemplo.com · ● Activo · 5,577
Nombre de Sitio Muy Largo Que... · normal.com · ● Activo · 5,577
ACME · acme.io · ● Activo · 5,577

El · Activo, · 5,577, y › chevron aterrizan en coordenadas x diferentes en cada fila. El ojo no puede anclar "la columna de estado está aquí". No se escanea como una lista — se lee como cadenas apiladas.

Esto no era una lista. Era texto, apilado verticalmente.

Convirtiendo a un layout tabular con CSS Grid

Reemplazamos el flexbox interno con una definición de columnas fijas en CSS Grid. Como cada tarjeta comparte la misma plantilla de grid, los bordes izquierdos de las columnas se alinean perfectamente a lo largo de la 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>● Activo</span>
  <span>{count}</span>
  <span>›</span>
</Link>

La fila de cabecera usa la misma plantilla de grid exacta:

// page.tsx fila de cabecera
<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>SITIO</span>
  <span>URL</span>
  <span>API KEY</span>
  <span>TRACKER TAG</span>
  <span>STATUS</span>
  <span>HOY</span>
  <span></span>
</div>

La trampa: las columnas auto en grids separados se dimensionan de forma independiente

Nuestro primer intento usaba grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]. La lógica: "auto se adapta al contenido, y como la cabecera y SiteCard comparten la misma cadena de plantilla, se alinearán."

No lo hicieron. El motivo:

  • La columna auto de la cabecera: dimensionada al ancho del texto de "STATUS"
  • La columna auto de SiteCard: dimensionada al ancho del texto de "● Activo"
  • Ambas son contenedores de grid separados — el dimensionado de pistas se calcula de forma independiente para cada uno

Dos soluciones:

  1. Ancho fijo: Reemplazar auto con valores en píxeles explícitos como 110px, 72px (lo que enviamos a producción)
  2. CSS Subgrid: Poner la cabecera y todas las SiteCards dentro de un único grid padre, luego hacer que cada fila herede las pistas del padre via subgrid (más complejo)

💡 Lección 2: No uses columnas auto cuando necesitas alineación tabular entre contenedores de grid separados. Cada contenedor calcula el ancho de sus propias pistas basándose en su propio contenido. Usa anchos en píxeles fijos o comparte pistas via subgrid.

Defensa Contra Texto Largo — 3 Capas

El esquema inicial de BD tenía:

CREATE TABLE sites (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,  -- sin límite de longitud
  url text NOT NULL,   -- sin límite de longitud
  ...
);

El tipo text de Postgres es efectivamente ilimitado. Un nombre de sitio de 10,000 caracteres es SQL perfectamente válido — y destruiría un layout tabular al instante. Pasar a un grid de columnas fijas hizo que las restricciones de longitud de texto fueran innegociables.

La defensa de 3 capas a la que llegamos:

  1. Límite HTML5 en frontend: maxLength={60} / maxLength={512} en los inputs
  2. Validación del lado del servidor: La Server Action verifica name.length > 60 y devuelve un error
  3. Truncado en la visualización: clase truncate en cada celda del grid, combinada con minmax(0, ...) para habilitar el desbordamiento

La capa 3 es la que la gente suele omitir. truncate se expande a overflow: hidden + text-overflow: ellipsis + white-space: nowrap, pero en un contexto flex o grid también necesita min-width: 0 en la celda — de lo contrario la celda se expande para ajustarse a su contenido y el desbordamiento nunca se activa. minmax(0, ...) proporciona exactamente eso.

16 Iteraciones de Diseño

El SiteCard por sí solo pasó por 16 iteraciones de diseño en esta sesión. Algunas seleccionadas:

# Cambio Motivo
1 Convirtió CopyBlock a píldora gris claro Fondo negro + texto verde se veía mal en el modo claro de GitHub
3 Comparación de cuatro opciones A/B/C/D Necesitaba mayor affordance de clic
4 Cambió a lista de 1 columna Rechazada como "demasiado decorativa" — vuelta a lo básico
7 Eliminó el CTA "Ver Heatmap →" "No se ve limpio"
9 Convirtió a layout tabular con CSS Grid El layout de cadenas con separadores "no parece una lista"
10 auto → anchos en píxeles fijos La cabecera y las columnas de tarjeta no se alineaban
11 Restauró la columna API KEY "Espera, ¿dónde está la API key?"
12 Añadió la columna TRACKER TAG "Pon ambas una al lado de la otra"
14 Añadió maxLength + validación en servidor Solución correcta para robustez con texto largo
16 Añadió botón "+" junto al encabezado (modo compacto) Añadir rápido sin hacer scroll hacia abajo

No esperes acertar a la primera. Iterar en diseño significa "revisar en el servidor de dev → feedback inmediato → siguiente intento", repetido 10+ veces. Planifica ese bucle. Un entorno de desarrollo con IA interactiva como Claude Code es donde este patrón realmente brilla.

Patrones que Realmente Funcionaron con el Desarrollo Asistido por IA

Estas no son observaciones específicas de la herramienta — son patrones generales que resultaron efectivos al trabajar en pareja con una IA interactiva:

  • Costo cero de consulta para nueva sintaxis de API: El @custom-variant de Tailwind v4 es relativamente nuevo. Claude Code lo presentó de inmediato en forma funcional, sin necesidad de hurgar en la documentación.
  • El bucle implementar → verificar → corregir se ejecuta en segundos: Reproducir el desajuste de auto en CSS Grid, aislar la causa y entregar la corrección tardó unos 5 minutos en total.
  • El trabajo mecánico en masa desaparece: Reemplazar texto de marcador de posición en 15 archivos de localización (in-golexample.com) en una sola iteración.
  • Afinidad con HMR: Editar → guardar → actualización instantánea del navegador → feedback → re-editar. El bucle es lo suficientemente rápido como para mantenerse en estado de flujo.
  • De principio a fin en una sola sesión: Desde terminar el rediseño hasta el push a producción y ver el despliegue de Vercel — sin cambios de contexto.

Los patrones que no funcionaron tan bien están en la siguiente sección.

Lo que Fue Difícil / Lo que Queda Pendiente

  • Traducir feedback vago en decisiones estructurales multiplicó el número de iteraciones. "Esto se siente raro" tenía que convertirse en "tabular vs. lista vs. tarjetas" antes de que pudiera ocurrir algo útil. Llegar a ese acuerdo estructural de antemano habría ahorrado 3–4 iteraciones.
  • Alrededor de la iteración 10, los requisitos seguían expandiéndose — "todo inline", "todo truncado", "todas las columnas" — y algunas restricciones estaban genuinamente en tensión (densidad de información vs. escaneabilidad). No había un atajo limpio hacia la forma final.
  • El móvil no está tocado. Un grid de 7 columnas produce scroll horizontal en pantallas pequeñas. La solución es o bien una media query sm: que apila columnas, o un componente móvil separado. Esto es lo siguiente a resolver.

Conclusión

En una sola sesión, reconstruimos el dashboard de HeatMapX de un grid de tarjetas oscuras a un layout con toggle claro/oscuro/sistema y lista tabular estilo GitHub. Los puntos técnicos clave:

  1. Modo oscuro en Tailwind v4: Empieza con modo oscuro basado en @media — es de cero configuración. Solo añade @custom-variant + el script de sincronización cuando los usuarios necesiten una anulación manual.
  2. Alineación tabular con CSS Grid: Las columnas auto no se alinean entre contenedores de grid separados. Usa anchos en píxeles fijos o CSS Subgrid — esas son las únicas opciones fiables.
  3. Robustez con texto largo: Tres capas, todas necesarias — maxLength en frontend, verificación de longitud en el servidor, y truncate en la visualización con minmax(0, ...).
  4. Presupuesto de iteración de diseño: Asume 10–16 iteraciones por componente y construye un bucle de feedback rápido desde el principio.

Si esto realmente mueve la tasa de abandono sigue siendo una pregunta abierta. Publicaremos el seguimiento con dos semanas de datos reales.

Heatmaps desde Claude Code — gratis para empezar.

Pega una etiqueta de tracking y recibe análisis y sugerencias CRO desde la CLI.