marceloacevedo
hablemos
Lanzamiento

Canon CSS: una sola forma correcta de hacer cada cosa

Por qué los LLMs escriben UI inconsistente, qué pasó cuando le di a uno un framework sin alternativas, y qué pasó cuando dejé que agentes refactorizaran este sitio entero con él.

MAMarcelo Acevedo · agosto 2026

Pídele a un modelo que centre un div con un framework de utilidades y obtienes una respuesta distinta cada vez. No una respuesta equivocada: una distinta. Hay cientos de combinaciones válidas, y un modelo de lenguaje elige entre ellas por distribución estadística, no por razonamiento. Pregunta cinco veces, publica cinco interfaces ligeramente distintas.

El problema no es técnico. Es epistemológico: nuestros frameworks tienen demasiados grados de libertad. Así que construí uno que casi no tiene ninguno, y este blog está escrito con él.

Restricción semántica

Canon es un framework de CSS puro con un vocabulario cerrado. Seis valores de espaciado. Siete tamaños de texto. Siete layouts, doce componentes, una forma canónica por patrón. La API son atributos data que declaran lo que algo es, nunca cómo se ve:

<div data-layout="grid" data-cols="3" data-gap="lg">
  <article data-component="card">
    <div data-slot="header">Title</div>
    <div data-slot="body">Content</div>
  </article>
</div>

Sin build step, cero JavaScript, un solo archivo de 4kb con gzip. Si un valor no tiene token, no existe. Y la documentación se distribuye como un system prompt de ~750 tokens que inyectas en cualquier modelo, porque la documentación que un modelo lee es documentación que se cumple.

¿Funciona de verdad?

La afirmación es comprobable, así que la comprobé. A lo largo de tres iteraciones de entrenamiento, LLMs con contexto limpio, con solo el prompt, generaron 20 páginas: dashboards, landings, un blog, un catálogo de e-commerce y un producto completo de 7 páginas escrito por 7 agentes que nunca vieron el output de los demás.

  • Cero violaciones de reglas en todas las generaciones, verificadas mecánicamente por canon-lint.
  • La iteración 1 expuso 5 vacíos de vocabulario. La iteración 2, dos cosméticos. La iteración 3, ninguno.
  • Los 7 agentes a ciegas produjeron un producto coherente: la misma identidad en cada página, dark mode exactamente donde correspondía.

Los fallos interesantes nunca fueron por romper reglas. Fueron ambigüedad: números de métricas que salían diminutos en una corrida y enormes en otra porque no había una forma canónica de escribir "un número grande". La solución nunca fue mejor prompting. Fue cerrar el vacío en el vocabulario, para que el output feo se volviera difícil de producir.

La prueba real: este sitio

Los benchmarks son una cosa. Apostar tu propia marca es otra, así que eso hice: seis agentes LLM, trabajando en paralelo, portaron cada página y cada componente de este sitio de Tailwind a Canon. Los tres idiomas, los formularios, el SEO, con las ilustraciones intactas.

La marca sobrevivió dentro de un theme file de unas 60 líneas: overrides de tokens para la tinta (#0a0a0a), el acento teal, la tipografía display GeneralSans, el cuerpo de 17px, una escala display con clamp() y una medida de lectura más ancha, más un bloque @layer canon.theme de tres reglas para botones pill y el tracking del display. Resultado neto: 770 líneas menos y cero Tailwind en el código.

Lo que se rompió fue la parte interesante, porque todo lo que se rompió se volvió vocabulario. El port expuso vacíos, y cada uno de ellos es hoy parte de Canon:

  • Las animaciones no tenían expresión, así que ahora existe data-motion={rise|float|pulse|lift}, que respeta prefers-reduced-motion automáticamente.
  • El menú burger móvil se volvió un patrón <details> con cero JS en el topbar.
  • Las etiquetas de marca en monospace se volvieron data-mono.
  • La visibilidad responsive se volvió data-hide.
  • Los anchos de lectura hardcodeados se volvieron tokens --width que un theme puede sobreescribir.

El ciclo fue el mismo cada vez: el port choca contra un muro, el muro se convierte en una propuesta de vocabulario, el framework, el prompt, la skill y el linter se actualizan juntos, y la página se regenera. Esa es toda la doctrina en un solo movimiento:

No basta con que el markup válido sea posible. El markup feo tiene que ser difícil.

Lo que Canon no es

No es un reemplazo de Tailwind para humanos que quieren control a nivel de píxel: cuando te importa cada píxel de un diseño con marca, las utilidades siguen siendo la herramienta correcta. Pero el contraargumento más fuerte a "un vocabulario cerrado tiene que verse genérico" es el sitio que estás leyendo. Esto era un portafolio en Tailwind hecho a mano, y agentes LLM lo refactorizaron entero a Canon conservando la marca. Canon es para el caso en el que un modelo escribe la mayor parte de la UI y quieres que cada página que produce se vea como un solo producto: herramientas internas, dashboards, MVPs, apps generadas por agentes. Y, por lo visto, un sitio personal con identidad propia.

Pruébalo

<link rel="stylesheet"
  href="https://cdn.jsdelivr.net/gh/marcelodevelop/canonframework@main/dist/canon.css">

Toma el system prompt del repo, pégalo en tu modelo, pide una página y valida el resultado con npx canon-lint. Todo el ciclo toma un minuto: github.com/marcelodevelop/canonframework.