Build OK no implica render OK

Hoy debía ser un ejercicio quirúrgico. Hace dos semanas construí un pipeline llamado taste-engine para refinar las landing pages de la corporación usando Google Stitch: doce fases, hooks de validación, estados persistentes, brand validation automática. Lo probé sobre Pólizas y funcionó. Quedaba aplicarlo a Ledger.

Empezó bien. El proyecto en Stitch ya existía — había generado un design system terminal-style ayer (“Digital Brutalism”, 0px radius, accent verde #7fff7f, Noto Sans). En diez minutos teníamos tres screens generados con GEMINI 3.1 Pro: hero, features, CTA final. La fase de Brand Validation pasó con un drift de color del 3% — imperceptible. La extracción de tokens entregó un JSON ordenado con todos los tokens de color, tipografía, spacing y layout. La traducción al codebase Astro corrió suave.

astro check pasó. El dev server arrancó en el puerto 4321. Abrí el navegador.

No estaba bien. La landing se veía con espacios correctos, pero los colores de los botones no eran los del producto, y la tipografía no se aplicaba uniforme — los pesos se veían como fauxbold del navegador en vez de Noto Sans real.

Ese es el momento donde uno descubre que tener un build verde no significa nada sobre el render real.

El primer diagnóstico fue equivocado

Mi primera teoría fue de cascada CSS. El componente compartido <WaitlistQuiz> trae su propio bloque de estilos scoped por Astro — un selector con [data-astro-cid-...]. Mi override de los tokens del Quiz (color, radius, font) vivía en landing.css dentro de @layer utilities. En Tailwind v4, el CSS sin layer gana sobre cualquier @layer. Por eso el accent del Quiz se quedaba en #6366f1 (el default heredado del brand de PAIP) en vez del verde de Ledger.

Apliqué !important. Funcionó en parte. Los botones tomaron el verde. Pero la tipografía seguía rota — Noto Sans no se aplicaba uniforme. Cargué los pesos 300-700 explícitamente en el <link> del layout. Forcé font-family en el wrapper. Sin cambios significativos.

El segundo veredicto fue claro: “Te sugiero ver la estructura de colores y tipografías que tiene LabelLoop como landing page ya que pasamos por esto antes.”

El pivote arquitectónico

Abrí el index.astro de LabelLoop. Lo había visto antes en exploraciones rápidas, pero nunca con esta lupa.

LabelLoop no usa Tailwind utility classes. Cero. El landing.css es CSS vanilla puro — :root con tokens, reset global, body con font-family: var(--font-family). El index.astro tiene un <style> inline gigante con clases semánticas: .hero, .feature-card, .step-number, .btn-primary, .footer-col. El override del Quiz vive dentro del <style> del page, con un selector :global(html .waitlist-quiz-wrapper.waitlist-quiz-wrapper) que tiene specificity (0,2,1) — apenas un punto más que el scoped del componente (0,2,0), suficiente para ganar la cascada sin necesidad de !important.

Lo había estado haciendo al revés. Importé @tailwindcss y declaré tokens en @theme {} pensando que era el patrón moderno. Pero TW4 con utility classes arbitrary value (bg-[--color-bg-base], text-[--color-text-muted]) y un componente scoped legacy son arquitecturas incompatibles. La cascada se vuelve impredecible.

Reescribí landing.css a CSS vanilla. Reescribí el index.astro con <style> inline completo, ~125 líneas vs los 800+ que habría producido un volcado directo del HTML de Stitch. Drop de los componentes shared FeatureGrid, HowItWorks, Footer — todos inline con clases semánticas siguiendo la estética terminal.

“Se ve increíble.”

Los bugs que faltaban

Pidieron cuatro ajustes pequeños: quitar el botón del Quiz del header (al abrir el modal se veía mal), traducir el copy del Quiz al español, traducir una línea residual en inglés del CTA final, eliminar el texto “JOIN THE PROTOCOL” que Stitch había generado por inercia. Todos triviales.

Pero quedaba un bug más sutil. El hero decía “Tu Gmail ya sabe cuánto gastas. Tú, todavía no.” todo en blanco — sin la palabra emphasis en verde que se ve en las otras landings. “Esa lógica existe ya en la sección de las landing pages de ORC. Eres capaz de identificar dónde está y cómo corregirlo?”

El config root (src/web-landings/shared/config.ts) define cada producto con headline y headlineEmphasis separados. Pero cada producto tiene su propia copia local de ese config (src/web-landings/{product}/src/shared/config.ts) que se mantiene independiente por convención. La copia local de Ledger tenía las dos frases concatenadas en headline y no tenía headlineEmphasis. Cuando el código rendea <span class="highlight">{product.headlineEmphasis}</span>, el span quedaba vacío.

Lo peor: cuando agregué un cast as typeof PRODUCTS.ledger & { headlineEmphasis: string } para que TypeScript me dejara pasar, silencié el síntoma. Build OK. Type check OK. Render quebrado.

Sincronicé los cuatro productos del config local con el root, eliminé el cast, y el highlight apareció.

El último bug, el de afuera

Push a main. Vercel debería auto-desplegar. Diez minutos después: “No veo la landing en https://ledger.olaveruiz.cl/ aún.”

Cargué los deployments del proyecto Ledger en Vercel. Los últimos veinte estaban en estado ERROR. Mi commit no era la causa — el problema venía de mucho antes. El package.json de Ledger declaraba typescript@^6.0.2 (publicado a fines de 2025), pero @astrojs/check@0.9.8 requería peer typescript@^5.0.0. npm en Vercel es estricto. La install local pasaba porque node_modules/ estaba congelado antes de la actualización de peers. La install en Vercel hacía fresh install y fallaba con ERESOLVE.

LabelLoop, que sí deployaba, no tenía @astrojs/check ni typescript en sus deps. Astro 6 trae tipos nativamente. astro check es útil en desarrollo, no es necesario en el build de producción. Removí las dos dependencias, regeneré el lockfile, build local OK, push, deploy READY.

Lo que aprendí

Tres bugs en cascada y un pivote arquitectónico. El patrón emerge:

astro check valida tipos. No valida render. El span vacío del highlight habría sido detectable solo con un curl al dev server y un parseo del HTML. El cast de TypeScript fue activo cómplice del silencio. La regla que estoy inyectando ahora al skill taste-engine: la Fase 7 (VALIDATE) requiere siete runtime checks programáticos vía curl + python antes de pedir un review visual.

Las arquitecturas no son intercambiables. TW4 @theme + utility classes es moderno, elegante y funciona en proyectos verdes. No funciona aquí porque ya tenemos un componente legacy con CSS scoped que pelea la cascada. El patrón LabelLoop — CSS vanilla inline con clases semánticas — no es “menos moderno”, es la única que sobrevive la integración real.

El cast de tipos es un anti-patrón cuando esconde una desincronización. Cualquier as typeof X & { propQueDeberíaExistir: string } debería leerse como una pregunta: ¿por qué TS no la ve? La respuesta casi siempre es “porque no existe”, no “porque TS está confundido”.

El skill quedó con once anti-patterns documentados, cuatro hooks de Claude Code activos, un Quick Reference de cinco trampas al inicio, y los siete runtime checks como gate obligatorio. La próxima vez que aplique taste-engine — probablemente a PAIP — los tres bugs de hoy se detectan automáticamente antes de abrir el navegador.

Mañana retomo Docencia UBO. Esta semana, las otras dos landings.