# Cómo hacer migraciones con Claude Code y no morir en el intento
## Migrar dejó de doler
Migré el diseño completo de este sitio en un par de días. Antes de Claude Code, esa misma migración me la habría comido en dos semanas, y probablemente la habría postergado seis meses por pura flojera.
Llevo años haciendo migraciones. Vue 2 a Vue 3, design systems completos, refactors arquitectónicos que tocaban cientos de archivos. Todas tenían algo en común, dolían. Eran proyectos largos, mecánicos, con riesgo alto y poca recompensa visible. La clase de tarea que sabes que hay que hacer pero que sigues postergando porque suena a infierno.
Ya no.
## El dolor histórico de migrar
Migrar de Vue 2 a Vue 3 era un proyecto trimestral. No por las dificultades técnicas reales (la mayoría era reemplazar APIs deprecadas), sino por el volumen. Cientos de componentes, cada uno con sus particularidades, todos había que tocarlos. Sumarle Pinia, Vue Router 4, los plugins de terceros que no soportaban v3 todavía. Y mientras hacías la migración, el resto del equipo seguía mergeando features en la rama principal. Cuando intentabas mergear de vuelta, era un infierno.
Migrar un design system era parecido pero peor. Cambias el sistema de tokens, los componentes base, los nombres de las props. De pronto cada `` que existía en la app necesita revisión, y cuando son cientos de ocurrencias, ningún equipo te firma un Q completo solo para eso.
El patrón siempre era el mismo, alta carga, baja motivación, postergación indefinida.
## Por qué las migraciones son el escenario perfecto para un agente
Después de hacer varias con Claude Code, identifiqué tres condiciones dónde son ideales:
1. **Alto volumen de cambios mecánicos.** El PR de oxlint en mi trabajo actual tocó más de 700 archivos. La mayoría eran ajustes triviales (orden de imports, patrones equivalentes). Es exactamente el tipo de trabajo que es humanamente tedioso y que un agente lo hace en minutos.
2. **Feedback claro y automatizable.** Linter, Typecheck, Tests, pasan o no pasan. El Build compila o no compila. Cada paso es un loop con respuesta binaria, justo el tipo de señal que un agente necesita para iterar sin supervisión continua.
3. **Documentación accesible.** La herramienta de destino casi siempre tiene un *migration guide*. Le pasas el guide al agente, le das el contexto del proyecto, y deja de inventar.
Si tu tarea cumple las tres, deja de procrastinar. Es trabajo para un agente.
## Caso 1: el toolchain de un monorepo
El equipo en mi trabajo actual operaba un monorepo con 7 aplicaciones en Cloudflare Workers, alrededor de 1.670 archivos de TypeScript gestionados con pnpm workspaces. El pipeline de calidad usaba Biome para lint y format, y tsc para type-check.
Dos cuellos de botella:
- Biome y tsc requerían `NODE_OPTIONS='--max-old-space-size=8192'` para no caer por out of memory durante CI.
- tsc, single-threaded, se demoraba 25 segundos en chequear los tipos del monorepo completo.
Llevábamos meses mirando lo que el equipo de oxc (oxlint, oxfmt, todo lo que están construyendo en Rust) iba sacando. La pregunta no era si valía la pena migrar, sino cuánto esfuerzo iba a costar.
### Migración a oxlint + oxfmt
Los pasos:
1. Reemplazar Biome por oxlint y oxfmt.
2. Configurar `.oxlintrc.json` con 129 reglas activas.
3. Configurar `.oxfmtrc.json` equivalente.
4. Actualizar scripts en `package.json`.
5. Ajustar el pipeline en GitHub Actions.
6. Ajustar la config del editor.
El bulk del trabajo no fue cambiar las herramientas. Fue ajustar el código para cumplir las nuevas reglas (la mayoría sobre orden de imports y patrones equivalentes). Claude Code hacía los cambios, corríamos las validaciones, ajustábamos, repetíamos.
| Métrica | Biome | oxlint (ene 2026) | oxlint (mar 2026) |
| ------------------- | ----- | ----------------- | ----------------- |
| Tiempo promedio | 3.70s | 1.04s | 0.49s |
| Archivos analizados | 1.227 | 1.262 | \~1.670 |
oxlint procesaba más archivos con más reglas en una fracción del tiempo. Y la herramienta sigue mejorando con cada release.
### Migración a tsgo
TypeScript 7 (tsgo) es la reescritura de tsc (TypeScript Compiler) en Go. Microsoft prometía mejoras de 7x a 10x. La migración fue todavía más directa:
1. Instalar `@typescript/native-preview`.
2. Cambiar el script de `NODE_OPTIONS='--max-old-space-size=8192' tsc` a `tsgo`.
3. Ajustar `tsconfig.json` (sacar `baseUrl`, agregar `./` como prefijo en paths).
4. Actualizar la caché en CI (de `.tsbuildinfo` a `.tsgo-cache`).
5. Corregir tipos en los lugares donde tsgo es más estricto que tsc.
El bonus, ya no necesitas la config de memoria. tsgo corre en Go sin las restricciones de Node.
| Métrica | tsc | tsgo (ene 2026) | tsgo (mar 2026) |
| -------------------------- | --------- | --------------- | --------------- |
| Tiempo (cold) | 25.34s | 5.32s | 0.76s |
| Uso CPU | \~132% | \~430% | \~430% |
| Requiere config de memoria | Sí (8 GB) | No | No |
33 veces más rápido en typecheck, sin tocar una sola línea de código TypeScript del proyecto.
### El número final
| Fase | Antes | Migración | Hoy (abril 2026) |
| --------- | ---------- | --------- | ---------------- |
| Lint | 3.70s | 1.04s | 0.49s |
| Typecheck | 25.34s | 5.32s | 0.76s |
| **Total** | **29.04s** | **6.36s** | **1.25s** |
De 29 segundos a 1.25. 23 veces más rápido. Y lo que terminó de convencer al equipo fueron precisamente esos resultados. Sin medir antes y después, la migración es solo "Sergio cambió las herramientas". Con los resultados, es "el linter y typecheck de código es 23X más rápido".
**Lección:** mide siempre. Es lo que convierte una migración en una historia que el equipo entiende y firma.
## Caso 2: screaming architecture en un frontend grande
Antes de la migración del toolchain, había hecho otra: aplicar [screaming architecture](https://sergioazocar.com/blog/screaming-architecture-la-clave-para-un-frontend-escalable) al frontend del producto principal. Reorganizar el repo desde carpetas técnicas (`components/`, `services/`, `store/`) a módulos por feature (`modules/Auth/`, `modules/Orders/`, etc).
El cambio en sí es simple: mover archivos y actualizar imports. Pero cuando son cientos de archivos en cientos de imports, es la definición de "trabajo mecánico que nadie quiere hacer un viernes".
Acá es donde Claude Code se luce. El patrón es uniforme, los cambios son verificables (compila o no), y la única regla nueva es "todo lo de feature X vive en `modules/X/`". El agente entiende el patrón al primer ejemplo y lo aplica al resto.
Lo único que hice fue definir el contrato (specs). Qué módulos crear, qué se considera shared, cómo manejar los archivos que tocaba más de una feature. Después fue iterar, validar build, validar tests, ajustar.
**Lección:** los refactors estructurales son ideales cuando el patrón es claro pero el volumen es alto. Define las reglas una vez, deja que el agente las aplique en todas partes.
## Caso 3: el rediseño de este sitio
El más reciente y el más cercano a este post. Migrar sergioazocar.com desde el diseño anterior al actual: layout edge-to-edge con BentoGrid, Nuxt UI v4, Tailwind v4, sistema de colores custom (`beacon` y `orbit`), tema dark-only, borders translúcidas con `color-mix`.
La diferencia con los casos anteriores es que el contexto del proyecto era todo mío. Tengo un `CLAUDE.md` cuidado, con las convenciones del repo, el stack, las reglas de i18n, los patrones de blog posts, los gotchas. Cuando le pido a Claude Code que migre un componente al nuevo sistema de borders, no necesito explicarle qué es `border-(muted)` ni dónde está definido. Ya lo sabe.
Eso es dogfooding. El proyecto está preparado para que el agente sea útil desde el primer prompt. Y eso paga dividendos rapidísimo, cada turno avanza en lugar de gastarse en re-explicar contexto. Estructurar el repo entero con esa intención, para personas y agentes, es lo que después llamé [Context Architecture](https://sergioazocar.com/blog/context-architecture-el-repo-entero-es-contexto).
**Lección:** invertir en un buen `CLAUDE.md` no es overhead, es la diferencia entre un agente útil y uno que alucina.
## El playbook
Después de varias migraciones, esto es lo que hago siempre:
1. **Planifica antes de generar.** Una migración mal scopeada es una migración fallida. Define el alcance, los pasos, los archivos. Si no tienes claridad antes de empezar, el agente tampoco la va a tener.
2. **PRs atómicos por fase.** En el caso del toolchain, oxlint y tsgo fueron PRs separados. Si algo se rompe, sabes exactamente qué fase lo causó.
3. **Valida en cada paso.** Lint, type-check, tests, preview. Claude Code corre los comandos, tú lees el output. Saltarte la validación porque "el cambio anterior pasó" es la receta para acumular 50 errores antes de darte cuenta.
4. **Mantén un `CLAUDE.md` actualizado.** Stack, convenciones, comandos, gotchas del proyecto. Es la diferencia entre un agente que entiende el repo y uno que inventa.
5. **Mide antes y después.** Sin números no hay historia que contar, ni argumento para venderle al equipo.
6. **Pair programming, no autopilot.** El agente no reemplaza criterio, lo amplifica. Tú decides qué se hace, él lo ejecuta y trae el feedback.
## Las herramientas que uso en una migración
Después de varias migraciones, hay un setup que repito. Son las piezas que hacen la diferencia entre Claude Code útil y Claude Code que pierde el tiempo.
**Un `CLAUDE.md` específico al contexto actual.** Convenciones, comandos, gotchas y la migración en curso. No copies el `package.json` (Claude Code ya puede leerlo), escribe lo que **no** se infiere de las deps. Ejemplo mínimo:
```md
# Project name
## Stack relevante (lo que no se infiere del package.json)
- Desplegado a Cloudflare Workers
- i18n con prefix strategy (ES/EN)
- Composition API only, sin Options API
- oxlint en vez de eslint
## Commands
- `pnpm lint` — oxlint
- `pnpm test` — vitest
## Migration in progress
Biome → oxlint + oxfmt. Reemplaza cada regla por su equivalente,
corre `pnpm lint` después de cada batch, no toques tests en este PR.
```
**`/plan` antes de generar.** Hace que el agente investigue el repo, te proponga un plan estructurado y solo ejecute después de aprobarlo. Para migraciones es perfecto, te ahorra empezar tres veces porque el primer scope estaba mal.
**Sub-agents para paralelizar.** Mientras el agente principal sigue editando, lanza un sub-agent que corra `pnpm typecheck` o `pnpm test` en otro módulo y te traiga el resultado. Para migraciones grandes, 2-3 sub-agents en paralelo dividen el trabajo y mantienen el contexto del principal limpio.
**Hooks para validación automática.** Un hook `PostToolUse` que corra `pnpm lint` después de cada `Edit` es la forma más barata de no tener que pedirle "valida" cada vez. Corre solo, si falla el agente lo ve y arregla en el siguiente turno.
## Lo que NO hagas
- **No le pidas todo de una vez.** "Migra el repo entero" es la peor instrucción posible. Divide en fases, una a la vez.
- **No te saltes la validación.** Aunque el cambio se vea trivial, corre los checks. El error que asumes que no existe es el que termina en producción.
- **No dejes que invente paths o APIs.** Si cita una función que no reconoces, verifica que existe antes de aceptar el cambio.
- **No mergees sin entender los cambios.** El agente acelera tu trabajo, no reemplaza tu responsabilidad. Si no entiendes lo que se cambió, no estás migrando, estás cruzando los dedos.
## Cierre
La barrera de entrada para migrar bajó dramáticamente. Lo que antes era un proyecto trimestral hoy es un proyecto de fin de semana. Lo que antes postergabas seis meses, lo puedes iniciar hoy mismo y terminarlo en un par de días.
Si tienes una migración estancada hace tiempo, abre Claude Code esta semana. Probablemente la termines antes de que termine el sprint.
Y aunque las métricas finales no salgan tan lindas, igual vas a entender más de tu codebase en dos días que en los seis meses que llevabas postergándolo.
# Context Architecture: el día que entendí que el repo entero es el contexto
## Tu repo ya es el contexto de tus agentes, lo hayas diseñado a propósito o no
Esa frase me costó entenderla. En este post te ahorro el camino.
Era Octubre del 2025, trabajando en el monorepo de Skyward con agentes de IA todos los días. Y todos los días la misma rutina: le decía en el prompt al agente "no uses esto", "no hagas esto así", "reutiliza el componente que ya existe". Lo escribía. Lo repetía.
El agente hacía exactamente lo que le dije que no hiciera.
No era que no me escuchara. Era que leía el código y ahí veía otra cosa.
## El agente le cree al código, no a tu prompt
Un agente sigue los patrones que **ve** en el repo, no los que tú le **dices** en el prompt. Y los subagentes son peores, porque arrancan sin el contexto de la conversación. Toda la pelea que tú diste arriba en el chat, para ellos no existió nunca.
Entonces pasaba esto. Creaba un componente nuevo aunque ya había uno que resolvía exactamente el problema. No respetaba las normas de diseño ni usaba los design tokens. Seguía documentación obsoleta porque seguía ahí, viva, sin nada que la marcara como obsoleta.
Mi primer instinto fue el de todos, meter más contexto en el prompt. Más reglas, más "por favor no hagas esto", más ejemplos pegados a mano. Funcionaba a medias, y para la siguiente tarea había que volver a agregarlo todo. Hasta que llegaba el siguiente subagente y empezaba de cero.
En algún momento, cansado de repetirme, entendí lo obvio.
El agente no me estaba desobedeciendo. Estaba leyendo el repo y haciendo caso a lo que el repo decía de sí mismo. Si conviven el componente bueno y tres versiones viejas, no tiene cómo saber cuál es el oficial. Si la doc dice una cosa y el código hace otra, le va a creer al que esté más a mano. Está haciendo justo lo que le pedí.
El mismo repo es el contexto que usan los agentes. Si está mal estructurado, las respuestas no van a ser buenas. Punto.
> No hay prompt que arregle un repo que se contradice a sí mismo.
## Screaming Architecture me llevó hasta media cancha
Lo primero que agregué al monorepo fue [Screaming Architecture](https://sergioazocar.com/blog/screaming-architecture-la-clave-para-un-frontend-escalable), sobre la que ya escribí acá. Organizar por dominio, no por tecnología: `billing/`, `payments/`, no `controllers/`, `services/`. Que la estructura grite la intención del negocio.
Y funcionó mejor de lo que esperaba con agentes. Una estructura que grita la intención es una estructura que un agente entiende sin que le pegues medio README en cada prompt. Un agente que sabe dónde vive `billing/` no se va a inventar una carpeta nueva. Con eso más buenos specs pude hacer migraciones grandes que antes habrían sido un dolor, como [pasar de Biome a oxlint y oxfmt](https://sergioazocar.com/blog/como-hacer-migraciones-con-claude-code-y-no-morir-en-el-intento) en todo el monorepo.
Pero me quedó corto.
## Dónde se quedó corto
Screaming Architecture te da una estructura legible. Te dice dónde están las cosas y qué hace cada parte. Eso es enorme.
Lo que no te da es la garantía de que lo que el repo dice de sí mismo sea cierto.
Un `AGENTS.md` que describe un flujo que cambió hace tres meses. Un comentario que apunta a una función que ya no existe. Un design token "oficial" que la mitad del código ignora. Un módulo marcado como deprecado que tres features siguen importando porque ningún mecanismo lo prohíbe.
Todo eso vive en silencio. Se lee perfecto, está bien ordenado, grita la intención correcta. Y miente.
Para una persona eso es ruido manejable. Lees alrededor, preguntas en slack, usas tu criterio. Para un agente es veneno. El agente no tiene criterio para saber que esa línea está obsoleta. La lee, le cree, y la propaga.
Ahí estaba el agujero. Me faltaba la **verificabilidad**. Que todo lo que el repo afirma sobre sí mismo sea verdad, y que cuando deje de serlo, algo se rompa y me avise.
Ese salto, de estructura legible a contexto verificable, es el núcleo de lo que terminó siendo Context Architecture.
## La regla que lo ordena todo
Si me hacen quedarme con una sola frase, es esta:
> Toda afirmación que un repositorio hace sobre sí mismo debe estar ligada a un mecanismo que falla cuando esa afirmación deja de ser verdad.
Ahí está todo.
Un README que dice "usamos este patrón" y nada explota cuando dejas de usarlo, es documentación. Se va a pudrir, es cosa de tiempo. Pero un patrón que vive en una regla del linter que falla en CI cuando lo violas, eso sí es arquitectura. No puede mentir, porque si miente, el build se rompe.
Esa es la diferencia entre algo que el agente respeta y algo que el agente ignora deliberadamente.
## Cómo se ve en la práctica
No voy a reexplicar acá la arquitectura completa, para eso está [la spec en context-architecture.dev](https://context-architecture.dev/es){rel=""nofollow""}. Pero si lo aterrizo a lo que uso día a día, es esto.
- La estructura grita la intención (esto es la herencia directa de Screaming Architecture).
- Contexto embebido: un `AGENTS.md` o `CLAUDE.md` en cada frontera del código, con lo que no se aprende leyendo el código, las decisiones, los porqués, lo que no hay que tocar.
- La intención se vuelve mecanismo: la spec precede al código y define el qué, y cuando esa intención vive en tests, tipos y lint, la spec se puede borrar porque ya está enforced.
- Las capacidades son descubribles: tools, skills y comandos en rutas predecibles, donde el agente los va a buscar.
¿Y qué hace cumplir todo eso? Mecanismos que hacen fallar lo falso, cada uno atrapando a su nivel. El compilador atrapa los tipos (typecheck). El linter atrapa la estructura. Los tests atrapan que la doc no mienta. Y el review atrapa la verdad semántica, lo que ningún agente todavía puede chequear solo. La gracia es empujar cada verdad lo más abajo posible: si algo lo puede garantizar el compilador, no lo dejes para el review humano.
Si una afirmación del repo no toca al menos uno de esos mecanismos, está colgando de un hilo.
En la práctica esto se vuelve un loop, y es lo que más me cambió el día a día. Cada vez que agrego contexto nuevo al repo, una convención, una fuente de verdad, una decisión sobre qué no tocar, en el mismo cambio le ato su mecanismo de verificación. No en el commit siguiente, no en el ticket de deuda que nunca llega. Ahí mismo. Y si no se me ocurre cómo verificarlo, casi siempre es que todavía no tengo claro qué estoy afirmando.
## De dónde viene el nombre (y de dónde viene la idea)
El nombre salió natural. El repo completo es el "contexto" que usan los agentes, y lo que había que mejorar era su estructura, es decir, su arquitectura. Context Architecture. Listo.
Donde sí quiero ser preciso es en el linaje, porque no reinventé la rueda.
Context Architecture hereda de Screaming Architecture (Robert C. Martin, 2011) y la extiende a la era de los agentes. Y su ancestro espiritual es TDD: los tests verifican que el código hace lo que dice; Context Architecture verifica que el repositorio dice la verdad sobre sí mismo. Es la misma idea, subida un nivel.
## Por qué lo cuento ahora
Porque ya lo probé y funciona.
Context Architecture es lo que usamos en el monorepo de Skyward, no es una teoría que se me ocurrió en la ducha. Y no se quedó ahí. La apliqué en mi plugin [oxlint-tailwindcss](https://sergioazocar.com/blog/oxlint-tailwindcss-el-plugin-de-linting-que-tailwindcss-necesitaba), que es puro dogfooding, y en la propia web de context-architecture.dev. Tres codebases reales, de dominios distintos, con la misma arquitectura debajo.
No te puedo tirar un número acá, y no me lo voy a inventar. La prueba es que las reglas aguantan cuando el repo crece y cuando el que escribe ya no eres tú, es un agente a las 2 AM.
Y empecé a contarlo en charlas. Di una presencial en el Duoc de Puerto Montt, "Context Architecture: cómo diseñar código para personas y agentes de IA", en el marco del SummIT de IA Aplicada que organizó Duoc. También salió en el webinar "Más allá del vibe coding: Ingeniería de software con agentes de IA". Pero las charlas pasan. Quería un lugar donde la idea quedara completa y abierta, para que cualquiera entre a verla en profundidad cuando le haga sentido. Esa es la spec, y por eso vive en su propia web y no enterrada en este post.
## El cierre
Si algo de esto te hizo sentido, dos cosas concretas.
Una, [lee la spec completa en context-architecture.dev](https://context-architecture.dev/es){rel=""nofollow""}. Ahí está la idea entera y cómo aplicarla mañana en tu propio repo. La mantengo viva, así que va a seguir creciendo.
Dos, si te sirve, déjale una estrella al [repo en GitHub](https://github.com/sergioazoc/context-architecture){rel=""nofollow""}. Me ayuda a que más gente lo encuentre. Me ayuda muchísimo a que más gente lo encuentre y le da peso como proyecto real del ecosistema.
El repo es el contexto. Haz que diga la verdad.
# Lo que aprendí construyendo Design Systems y que haría diferente
## ¿Qué es un Design System?
En simple, es una colección de **reglas**, **componentes** y **directrices** que actúan como una **única fuente de la verdad** para diseñar y construir interfaces.
Un buen Design System define desde los **colores**, **tipografías** y **espaciados**, hasta cómo deben comportarse los **botones**, **formularios**, **alertas**, y más. Pero va mucho más allá de lo visual: también establece cómo se comunican diseño y desarrollo, cómo se documentan los patrones y cómo evoluciona el producto de forma consistente.
> La idea es que todo el equipo hable el mismo idioma, diseñe más rápido y construya productos que se sientan coherentes, sin reinventar la rueda en cada pantalla.
No es solo una librería de componentes. Es una forma de trabajar que busca **alinear diseño, código y experiencia de usuario**.
Más adelante voy a entrar en detalle sobre cómo lo construí y qué aprendí, pero primero quiero contarte cómo fue enfrentarme a esta idea desde cero.
## Cómo terminé construyendo Design Systems
Después de trabajar en distintos proyectos y equipos, empecé a notar patrones que se repiten constantemente:
- El mismo botón diseñado de tres formas distintas
- Comportamientos inconsistentes en componentes similares
- Código duplicado o difícil de mantener
- Malentendidos entre diseño y desarrollo
- Interfaces que se veían bien en Figma, pero no en producción
Cada vez que un producto crecía, también lo hacía el caos visual y técnico. Y con él, el tiempo que perdíamos resolviendo los mismos problemas una y otra vez.
Ahí fue cuando entendí que necesitábamos algo más que una librería de componentes. Necesitábamos una **forma compartida de construir interfaces**, con reglas claras, decisiones centralizadas y una fuente de la verdad para todos los equipos.
## Expectativa versus realidad
¿Te acuerdas cuando en el colegio te tocaba hacer un trabajo en grupo? :br
Cada uno hacía su parte por separado, lo juntaban todo al final… y el resultado era un verdadero "Frankenstein".
Bueno, construir un Design System se siente muchas veces así.
Al principio crees que va a ordenar todo, que unirá diseño y desarrollo como por arte de magia. Pero en la práctica, aparecen los malentendidos, los atajos, las decisiones cruzadas, y lo que debería ser un sistema coherente empieza a desarmarse.
Estas fueron algunas de las diferencias más marcadas que viví entre lo que imaginaba… y lo que realmente pasó:
### Expectativa 1:
> “Voy a crear una librería de componentes reutilizables y el equipo la va a usar feliz.”
**Realidad:**:br
Algunos devs la ignoran, otros la rompen, otros no saben cómo usarla. :br
Sin documentación, onboarding y soporte interno, nadie la adopta como esperas.
### Expectativa 2:
> “El diseño está en Figma, así que solo tengo que replicarlo.”
**Realidad:**:br
El diseño no contempla estados, errores, focus, interacción, loading... :br
Terminé tomando muchas decisiones técnicas que no estaban definidas.
### Expectativa 3:
> “Una vez que los componentes están hechos, no hay que tocarlos más.”
**Realidad:**:br
El diseño evoluciona, aparecen nuevos requerimientos, y cada cambio afecta múltiples partes del sistema. :br
Un Design System necesita mantenimiento constante.
### Expectativa 4:
> “Voy a crear componentes genéricos y reutilizables para todo.”
**Realidad:**:br
Componentes ultra flexibles terminan siendo difíciles de mantener, testear o entender. :br
A veces es mejor tener variantes claras que un solo componente con 15 props.
### Expectativa 5:
> “Diseño y desarrollo van a trabajar como un solo equipo.”
**Realidad:**:br
Sin procesos claros y un lenguaje común, aparecen malentendidos todo el tiempo. :br
Colaborar no es automático: hay que construir puentes intencionalmente.
### Expectativa 6:
> “Con Tailwind puedo hacer todo sin un Design System.”
**Realidad:**:br
Tailwind ayuda, pero sin decisiones de diseño compartidas, tokens y una estructura clara, tu proyecto igual se vuelve inconsistente.
### Expectativa 7:
> “En unas semanas tengo el sistema listo.”
**Realidad:**:br
Un Design System no se termina. Es un sistema vivo que crece, se adapta y necesita evolucionar junto a tu producto.
## Necesidades de Diseño y Desarrollo
Cuando te enfrentas a crear un Design System (ya sea desde cero o a partir de una base existente) siempre aparecen dos caras de la misma moneda: lo que quiere y necesita tanto **Diseño** cómo **Desarrollo**. Es ahí donde empiezan todos los ~~problemas~~ desafíos.
Para contextualizar un poco analicemos las necesidades de cada equipo.
### Diseño
- **Consistencia visual**: Un lenguaje visual unificado (colores, tipografías, espaciados, iconografía, etc.).
- **Velocidad y eficiencia**: Poder diseñar interfaces sin reinventar los mismos componentes cada vez.
- **Escalabilidad de diseños**: Que lo que funciona en una pantalla también funcione en otras (web, mobile, responsive...).
- **Tokens de diseño claros y reutilizables**: Definición técnica de colores, tamaños, fuentes, z-index, etc.
- **Relación directa con la implementación**: Que lo que diseñan se vea igual en producción.
### Desarrollo
- **Reutilización real de componentes**: Librería estable, bien documentada y fácil de usar.
- **Consistencia funcional y técnica**: Mismos patrones de props, slots, estados, accesibilidad, validación, etc.
- **Facilidad de mantenimiento**: Código limpio, con nombres claros, tests, y sin lógica duplicada.
- **Documentación viva**: Storybook o similar, con ejemplos reales, no solo teoría.
- **Adaptabilidad a contextos distintos**: Que un botón (por ejemplo) funcione igual en cualquiera de las aplicaciones que tenemos.
Cada equipo tiene sus propias necesidades y prioridades, pero para que un Design System funcione, **deben trabajar en conjunto**, tomando decisiones basadas en definiciones compartidas. Ahí es donde deben aprovechar al máximo los puntos de unión entre ambos mundos.
### Necesidades mutuas, el punto de equilibrio
- **Lenguaje común**: Tokens, nombres de componentes, variantes, estados. Todos deben hablar el mismo idioma.
- **Proceso de diseño–desarrollo fluido**: Entregas claras desde Figma (o lo que usen), handoffs eficientes, feedback mutuo.
- **Versionado y control de cambios**: Para que los cambios en diseño se puedan implementar y comunicar sin romper el resto del sistema.
- **Adopción transversal del sistema**: Que no se quede en un equipo. Tiene que ser fácil de usar y entender por todos.
> En resumen, un Design System necesita ser lo suficientemente **cerrado** como para respetar las definiciones de diseño sin permitir cambios visuales arbitrarios, pero también lo suficientemente **flexible** como para extenderse y adaptarse sin romper nada.
## Cómo lograr la adopción del sistema
Tener un Design System técnicamente impecable no sirve de mucho si nadie lo usa.
Uno de los mayores desafíos no es construir el sistema, sino lograr que el equipo lo adopte de verdad. Y no me refiero solo a “que lo conozcan”, sino a que **lo integren en su día a día**, lo respeten, lo cuestionen y lo mantengan vivo.
Algunas cosas que me funcionaron (y otras que aprendí a la fuerza):
- **Involucrar desde el inicio**: Si el sistema se construye entre unos pocos, alejado del equipo, va a generar rechazo. Mientras más voces participen desde el comienzo, más pertenencia genera.
- **Mostrar valor rápidamente**: Un componente útil, bien documentado y fácil de usar vale más que una promesa de “algún día vamos a tener todo ordenado”.
- **Documentar con empatía**: No es solo escribir cómo usar un componente, es pensar en cómo lo entenderá alguien que llega por primera vez. Ejemplos reales, capturas, casos de uso, FAQs... todo suma.
- **Dar soporte interno**: Si alguien tiene un problema con un componente, tiene que haber una forma rápida de resolverlo. Slack, issues, pair programming... lo que sea, pero que no sientan que están solos y sobre todo, un flujo claro de como reportar los errores.
- **Celebrar el uso**: Cuando alguien adopta un componente, lo mejora o reporta un bug, hay que celebrarlo. Pequeños reconocimientos hacen que el sistema se vea como algo vivo, colaborativo y útil.
> La adopción no es un evento, es un proceso. Y requiere tiempo, paciencia y mucha comunicación.
## Lecciones aprendidas y recomendaciones
Después de todo este proceso, me quedo con varias ideas que ojalá hubiese sabido antes:
- **Un Design System no es un proyecto, es un producto**. Y como todo producto, necesita investigación, diseño, mantenimiento, comunicación y evolución.
- **Empieza por lo que más duele**: No intentes resolver todo desde el día uno. Identifica qué está causando más fricción hoy y empieza por ahí.
- **La perfección no es el objetivo**: Siempre va a haber deuda, componentes por mejorar y decisiones que se pueden cuestionar. La clave es que sea útil y evolucione.
- **Sin adopción, no hay sistema**: Si no lo usa el equipo, es solo una carpeta más en tu repo.
- **Cada equipo es distinto**: No copies un sistema de otro lado esperando que funcione igual. Inspírate, sí, pero adapta todo a tu contexto y necesidades reales.
- **Anticipa los problemas**: Tu Design System puede y va a fallar, habrán cambios de definiciones y de diseño. Ten eso en cuenta para que construyas todo de forma modular y flexible.
- **Utiliza Atomic Design**: Pero utilizalo bien, cada componente es un mundo por si mismo, mientras más atómico sea cada uno, más fácil es modificarlo o extenderlo sin romperlo todo.
> Un Design System es una inversión a largo plazo. Puede ser desafiante, incluso frustrante a veces, pero cuando empieza a funcionar, **todo el equipo gana en velocidad, consistencia y confianza**.
# oxlint-tailwindcss: el plugin de linting que Tailwind v4 necesitaba
## Del problema al open-source: la oportunidad perfecta para contribuir
El día que el equipo de oxc abrió el alpha de plugins nativos para oxlint, fue la excusa perfecta para solucionar un problema que llevaba tiempo teniendo en mi trabajo actual, lintear las clases de Tailwind CSS con oxlint.
Si usas Tailwind CSS v4 con oxlint, las opciones de linting existentes no están pensadas para ese combo. `eslint-plugin-tailwindcss` es sólido pero vive en el mundo de ESLint y su soporte de v4 todavía es parcial. `eslint-plugin-better-tailwindcss` funciona en oxlint a través de la capa de compatibilidad jsPlugins, y hace el trabajo, pero no es un plugin nativo y sus reglas son más acotadas. Ninguno fue diseñado específicamente para oxlint + Tailwind CSS v4.
Así que lo construí.
## Qué es oxlint-tailwindcss
Un plugin **nativo** de oxlint con 23 reglas de linting diseñadas exclusivamente para Tailwind CSS v4. No es un port de ESLint ni un wrapper, usa directamente la API de `@oxlint/plugins`. Solo dos dependencias en runtime, `@tailwindcss/node` y `tailwindcss`.
Esto importa porque al ser nativo, comparte el mismo ciclo de parseo que oxlint. No hay overhead de interoperabilidad, no hay capa de traducción. Es tan rápido como oxlint mismo y se nota.
### Determinista por diseño
Para validar tus clases, el plugin lee tu design system real. Y para eso necesita una sola cosa, el entry point de tu CSS, el archivo con `@import "tailwindcss"` y tus tokens de `@theme`. Lo declaras una vez en `settings.tailwindcss.entryPoint` y las 23 reglas validan contra el mismo design system.
```jsonc
{
"jsPlugins": ["oxlint-tailwindcss"],
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css", // tu CSS con @import "tailwindcss"
},
},
"rules": {
"tailwindcss/no-unknown-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/enforce-sort-order": "warn",
},
}
```
¿Por qué declararlo a mano en vez de adivinarlo? Porque adivinar depende del filesystem, y eso significa que el mismo código puede dar resultados distintos en tu máquina y en CI. Declarar el entry point explícito es determinista, mismo input, mismo output, en cualquier máquina. Es el mismo principio que sigue oxlint, y prefiero un plugin que sea predecible antes que uno que intente ser mágico.
Por la misma razón, si te equivocas en la config el plugin no se queda callado saltándose reglas. Tira un único diagnóstico `designSystemUnavailable` con una pista de qué arreglar. Falla fuerte y claro.
El plugin lee tu CSS llamando directamente a `@tailwindcss/node`, así que entiende tus tokens de `@theme`, las variables de shadcn y plugins como `@tailwindcss/typography` o `tailwindcss-animate`. El design system se computa una vez y se cachea en disco con content-hash, sin recalcular en cada corrida.
En un monorepo con varios design systems mapeas globs a entry points, y el primer match gana:
```jsonc
{
"settings": {
"tailwindcss": {
"entryPoint": [
{ "files": "packages/ui/**", "use": "packages/ui/src/styles.css" },
{ "files": "packages/web/**", "use": "packages/web/src/app.css" },
{ "files": "**", "use": "src/global.css" },
],
},
},
}
```
El `"**"` al final es el fallback para todo lo que quede fuera de los globs explícitos. Si prefieres, también puedes tener un `.oxlintrc.json` por paquete, las dos formas son 100% deterministas.
## 23 reglas en cuatro categorías
### Correctness: evitar errores reales
Las reglas de correctness atrapan bugs antes de que lleguen al navegador.
**`no-unknown-classes`** detecta clases que no existen en tu design system y sugiere correcciones para typos:
```jsx
// ^^^^^^^^^^^
// "itms-center" is not a valid Tailwind CSS class.
// Did you mean "items-center"?
```
Soporta `allowlist` para permitir clases custom que no están en tu design system e `ignorePrefixes` para saltarse prefijos que no son clases de Tailwind.
**`no-conflicting-classes`** te dice exactamente qué propiedad CSS está en conflicto y cuál clase gana:
```jsx
// "text-red-500" and "text-blue-500" affect "color".
// "text-blue-500" takes precedence (appears later).
```
**`no-dark-without-light`** detecta cuando usas `dark:` sin una clase base, algo que suele causar estilos faltantes en light mode:
```jsx
// ❌ — ¿qué fondo tiene en light mode?
// ✅
```
**`no-dark-without-light`** chequea `dark:` por defecto, pero la opción `variants` permite aplicar el mismo patrón a cualquier variante, útil si tu proyecto usa variantes custom.
**`no-contradicting-variants`** atrapa variantes redundantes donde la clase base ya aplica incondicionalmente:
```jsx
// ❌ — dark:flex es redundante, flex ya aplica siempre
```
**`no-deprecated-classes`** reemplaza automáticamente las clases deprecadas en v4:
```jsx
// ❌ v3
// ✅ v4 (autofix)
```
También `flex-shrink` → `shrink` y `decoration-clone` → `box-decoration-clone`.
Y las clásicas **`no-duplicate-classes`** (con autofix) y **`no-unnecessary-whitespace`**.
### Style: consistencia del equipo
**`enforce-sort-order`** ordena las clases según el orden oficial de Tailwind CSS (con autofix), compatible con oxfmt y prettier-plugin-tailwindcss. Su modo strict agrupa las clases por prefijo de variante, ordena dentro de cada grupo y ordena los grupos por prioridad de variante.
**`enforce-shorthand`** convierte `mt-2 mr-2 mb-2 ml-2` en `m-2`, `w-full h-full` en `size-full`, y muchas más combinaciones. Todo con autofix.
**`enforce-logical`** convierte propiedades físicas en lógicas para soporte LTR/RTL: `ml-4` → `ms-4`, `left-0` → `start-0`. Su inversa, **`enforce-physical`**, hace lo contrario para proyectos que son solo LTR y prefieren consistencia con propiedades físicas. Ambas con autofix.
**`enforce-consistent-variable-syntax`** normaliza la sintaxis de variables CSS entre `bg-[var(--primary)]` y la shorthand de v4 `bg-(--primary)`.
**`enforce-canonical`** convierte valores arbitrarios a clases nativas cuando existen: `p-[2px]` → `p-0.5` (usa `rootFontSize` de 16px por defecto para la conversión). Funciona directo con la API de canonicalización de Tailwind.
**`enforce-consistent-important-position`** (default suffix, la forma canónica de v4), **`enforce-negative-arbitrary-values`** (`-top-[5px]` → `top-[-5px]`) y **`consistent-variant-order`** completan las reglas de estilo.
### Complexity: mantener el código manejable
**`max-class-count`** avisa cuando un elemento supera las 20 clases (configurable). Es la señal de que es hora de extraer un componente.
**`enforce-consistent-line-wrapping`** controla el largo del string de clases por print width o por cantidad de clases por línea.
### Restrictions: reglas del design system
**`no-hardcoded-colors`** prohíbe colores hardcodeados como `bg-[#ff5733]` en brackets arbitrarios, el típico atajo que erosiona tu design system.
**`no-arbitrary-value`** y **`no-unnecessary-arbitrary-value`** (con autofix) controlan el uso de valores arbitrarios. La segunda detecta cuando usas `h-[auto]` pero existe `h-auto`.
**`prefer-theme-tokens`** detecta cuando referencias una variable CSS a mano (`bg-[var(--primary)]` o la shorthand `bg-(--primary)`) y existe la utilidad nombrada del token en tu design system, y la reescribe a `bg-primary`. Respeta los modificadores de opacidad, las variantes y el `!important`. Con autofix.
**`no-restricted-classes`** permite bloquear clases específicas por nombre o regex, con mensajes custom.
## Extracción de clases
El parser es lo que hace que todo esto funcione de manera confiable. No es un regex que busca `className=` y reza. Extrae clases de:
- Atributos JSX (`className`, `class`)
- Atributos JSX con objetos, p. ej. el prop `classNames` de Mantine: ``
- Template literals con interpolación
- Ternarios
- Funciones de utilidad: `cn()`, `clsx()`, `cx()`, `cva()`, `twMerge()`, `twJoin()`, y más
- `cva()` completo: base, variants, compoundVariants
- `tv()` completo: base, slots, variants con objetos de slots, compoundSlots
- `classed()` (tw-classed): ignora el tipo de elemento, extrae clases y config estilo cva
- Tagged templates (`tw\`...\`\`)
- Variables por nombre (`className`, `classes`, `style`, `styles`)
- Clases de componentes definidas con `@layer components { .btn {} }` en tu CSS
Maneja nested brackets, calc anidado, arbitrary variants, quoted values, important modifier, negative values y named groups/peers. Los edge cases que rompen otros parsers.
### Detección customizable
Por defecto el plugin detecta clases en atributos comunes, 14 funciones de utilidad, tagged templates `tw`, y variables con nombres como `className`/`classes`/`style`. Puedes extender estos defaults vía `settings.tailwindcss`, todos los valores son aditivos:
```jsonc
{
"settings": {
"tailwindcss": {
"attributes": ["overlayClassName"],
"callees": ["myHelper"],
"tags": ["css"],
"variablePatterns": ["^tw"],
},
},
}
```
Esto aplica a las 23 reglas de una vez. Si necesitas quitar un default built-in, usa `exclude` en el mismo bloque de settings.
## La historia detrás
Partí planificando qué quería, el stack que iba a usar y cómo quería que funcionara todo. Después de planificar la implementación con **Claude Code**, arrancó la iteración hasta conseguir las 23 reglas actuales. El repo incluye un `CLAUDE.md` y skills configuradas que permiten a cualquier contribuidor usar el mismo workflow para escribir reglas nuevas, la misma herramienta con la que se construyó el plugin. Si quieres agregar una regla, Claude Code ya sabe cómo hacerlo en este proyecto.
El proyecto corre completamente sobre el ecosistema de herramientas de VoidZero. [tsdown](https://tsdown.dev/){rel=""nofollow""} para el build, [oxfmt](https://oxc.rs/#feature-formatter){rel=""nofollow""} para el formateo, [vitest](https://vitest.dev){rel=""nofollow""} para testing, [tsgo](https://github.com/microsoft/typescript-go){rel=""nofollow""} (TypeScript 7 nativo en Go) para el type checking, y por supuesto [oxlint](https://oxc.rs/#feature-linter){rel=""nofollow""} para el linting del propio plugin. Cada herramienta en la cadena está construida sobre Rust u optimizada para velocidad.
No fue una decisión cosmética, fue dogfooding deliberado. Si vas a hacer un plugin para oxlint, tiene sentido que todo el toolchain sea del mismo ecosistema. Y si vas a desarrollar con un agente de IA, tiene sentido que el repo esté preparado para ello. Ese repo preparado para agentes es justo lo que después terminé sistematizando en [Context Architecture](https://sergioazocar.com/blog/context-architecture-el-repo-entero-es-contexto).
## Cómo empezar
Necesitas oxlint ≥ 1.43.0, Tailwind CSS v4 y Node.js ≥ 20. Instala el plugin:
```bash
pnpm add -D oxlint-tailwindcss
```
Agrega el plugin, tu entry point y las reglas a tu `.oxlintrc.json`:
```jsonc
{
"jsPlugins": ["oxlint-tailwindcss"],
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css", // tu CSS con @import "tailwindcss"
},
},
"rules": {
"tailwindcss/no-unknown-classes": "error",
"tailwindcss/no-duplicate-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/no-deprecated-classes": "error",
"tailwindcss/no-unnecessary-whitespace": "error",
"tailwindcss/enforce-sort-order": "warn",
"tailwindcss/enforce-shorthand": "warn",
"tailwindcss/no-hardcoded-colors": "warn",
//...
},
}
```
Ejecuta oxlint. Eso es todo.
## Pruébalo
El plugin es funcional, testeado y usado en producción. Pero un linter se hace mejor con feedback real de proyectos reales.
Si lo pruebas y encuentras un caso que no maneja bien, abre un [issue](https://github.com/sergioazoc/oxlint-tailwindcss/issues){rel=""nofollow""}. Si quieres contribuir una regla, el repo ya está preparado para que iteres con Claude Code desde el primer minuto. Y si simplemente te resultó útil, una estrella en [GitHub](https://github.com/sergioazoc/oxlint-tailwindcss){rel=""nofollow""} ayuda a que más gente lo encuentre.
---
**[GitHub](https://github.com/sergioazoc/oxlint-tailwindcss){rel=""nofollow""}** · **[npm](https://www.npmjs.com/package/oxlint-tailwindcss){rel=""nofollow""}**
# Screaming Architecture: la clave para un frontend escalable
## El patrón que vi en todos lados
En varias empresas donde trabajé me encontré con el mismo patrón. Los equipos sabían que organizar por features era importante, la intención estaba ahí. Pero en la práctica, la feature terminaba desparramada por todo el proyecto: `components/auth/`, `services/auth/`, `store/auth/`, `views/auth/`. El nombre de la feature aparecía en cada carpeta técnica, pero nunca en un solo lugar.
```text
src/
├── components/
│ ├── auth/ # LoginButton.vue, RegisterForm.vue
│ ├── products/ # ProductCard.vue, ProductList.vue
│ └── orders/ # OrderSummary.vue
├── views/
│ ├── auth/ # LoginView.vue, RegisterView.vue
│ ├── products/ # ProductsView.vue
│ └── orders/ # OrdersView.vue
├── services/
│ ├── auth.service.ts
│ ├── products.service.ts
│ └── orders.service.ts
├── store/
│ ├── auth.store.ts
│ ├── products.store.ts
│ └── orders.store.ts
└── utils/
```
A primera vista parece ordenado. Pero cuando necesitas cambiar algo de Auth, tocas 4 carpetas. Cuando un dev nuevo llega, tiene que reconstruir mentalmente qué archivos pertenecen a qué feature. Y cuando la app crece, cada carpeta técnica se convierte en un cajón de sastre con 30+ archivos.
El problema no es la falta de organización. Es que la organización grita "tecnología" en vez de gritar "negocio".
## La analogía que lo explica
Imagina el plano de una casa. No ves una sección de "ladrillos", otra de "cemento" y otra de "ventanas". Ves "cocina", "dormitorio", "baño". Los planos gritan el propósito de cada espacio, no los materiales con los que está construido.
Screaming Architecture busca lo mismo para tu código. Que la estructura de carpetas de alto nivel grite las features de tu aplicación, no las tecnologías que usas.
## Qué ganas con este enfoque
- **Entendimiento instantáneo**: abres el proyecto y de un vistazo sabes qué hace el negocio.
- **Onboarding veloz**: nuevos devs entienden la estructura del proyecto en minutos, no días.
- **Cambios localizados**: modificar una feature no te obliga a tocar 4 carpetas. Todo está en un lugar.
- **Escalabilidad real**: agregar una nueva feature es crear una carpeta, no insertar archivos en 6 lugares distintos.
- **Testing más fácil**: cada módulo es una unidad aislada que se puede testear independiente.
- **Refactorizaciones seguras**: mover o eliminar una feature entera es mover o eliminar una carpeta.
## Aplicando Screaming Architecture
La idea es simple: agrupar por features o dominios de negocio. Cada carpeta contiene todo lo necesario para esa funcionalidad.
```text
src/
├── modules/
│ ├── Auth/
│ │ ├── components/ # LoginButton.vue, RegisterForm.vue
│ │ ├── views/ # LoginView.vue, RegisterView.vue
│ │ ├── routes/ # Rutas de este módulo
│ │ ├── store/ # auth.store.ts
│ │ └── services/ # auth.service.ts
│ ├── Products/
│ │ ├── components/ # ProductCard.vue, ProductList.vue
│ │ ├── views/ # ProductsView.vue, ProductDetailView.vue
│ │ ├── store/ # products.store.ts
│ │ └── services/ # products.service.ts
│ └── Orders/
│ ├── components/
│ ├── views/
│ ├── store/
│ └── services/
├── shared/
│ ├── ui/
│ │ ├── components/ # BaseButton.vue, ModalBase.vue
│ │ └── composables/ # useModal.ts, useDarkMode.ts
│ ├── composables/ # useDebounce.ts, useLocalStorage.ts
│ ├── utils/ # formatDate.ts, validateEmail.ts
│ └── assets/ # Imágenes globales, estilos base
├── layouts/ # DefaultLayout.vue, AuthLayout.vue
├── router/ # Router general
├── app.vue
└── main.ts
```
No todos los módulos necesitan la misma estructura interna. Auth puede tener `store/` y `services/`, pero un módulo de Landing puede ser solo `components/` y `views/`. Cada módulo define lo que necesita.
## De la teoría a la práctica: migrando una feature
Tomemos el ejemplo de Auth en la estructura desagregada y veamos cómo queda la migración:
**Antes** (4 carpetas):
```text
src/components/auth/LoginButton.vue
src/components/auth/RegisterForm.vue
src/views/auth/LoginView.vue
src/views/auth/RegisterView.vue
src/services/auth.service.ts
src/store/auth.store.ts
```
**Después** (1 carpeta):
```text
src/modules/Auth/components/LoginButton.vue
src/modules/Auth/components/RegisterForm.vue
src/modules/Auth/views/LoginView.vue
src/modules/Auth/views/RegisterView.vue
src/modules/Auth/services/auth.service.ts
src/modules/Auth/store/auth.store.ts
```
Los imports cambian, el código no. Si tu módulo de Auth necesita exponer algo al resto de la app (un guard, un composable, el estado del usuario), puedes crear un `index.ts` que haga de API pública del módulo:
```ts
// src/modules/Auth/index.ts
export { useAuthStore } from './store/auth.store'
export { useCurrentUser } from './composables/useCurrentUser'
export { authGuard } from './routes/guards'
```
Así el resto de la app importa desde `@/modules/Auth`, no desde las carpetas internas. Si refactorizas la estructura interna del módulo, los imports externos no se rompen.
## Convivencia con frameworks
Si usas Nuxt, Next o cualquier framework con convenciones de carpetas (`pages/`, `composables/`, `components/`), la pregunta obvia es: ¿cómo convive esto con Screaming Architecture?
Las convenciones del framework se encargan del routing y el auto-import. Screaming Architecture se encarga de la lógica de negocio.
En Nuxt, por ejemplo, `pages/` define las rutas, pero la página puede ser un wrapper liviano que importa el módulo real:
```vue
```
Nuxt se encarga del routing (`/auth/login`), y tu módulo Auth se encarga de la lógica. `pages/` queda liviana, solo como punto de entrada.
Lo mismo aplica para `composables/`. Los composables globales (auto-imported por Nuxt) van en la carpeta convencional del framework. Los composables específicos de una feature van dentro del módulo:
```text
composables/ # Auto-imported por Nuxt (globales)
├── useTheme.ts
└── useBreakpoint.ts
modules/Auth/composables/ # Específicos de Auth (import manual)
├── useCurrentUser.ts
└── useAuthRedirect.ts
```
## Cross-cutting concerns: qué va en shared
La pregunta más común cuando aplicas esto: ¿qué pasa cuando dos módulos necesitan lo mismo?
La regla es simple. Si algo es específico de una feature, va en su módulo. Si lo usan dos o más features, va en `shared/`.
`shared/ui/` es para componentes de UI genéricos sin lógica de negocio: botones, modales, inputs. Son los building blocks, no las features.
`shared/composables/` es para lógica reutilizable sin estado de negocio: `useDebounce`, `useLocalStorage`, `useIntersectionObserver`.
`shared/utils/` es para funciones puras: `formatDate`, `slugify`, `validateEmail`.
¿Y el estado compartido? Si Auth y Orders necesitan datos del usuario, Auth es dueño del estado. Orders importa `useCurrentUser` desde `@/modules/Auth`. Si con el tiempo más módulos necesitan el mismo dato, puedes mover ese composable a `shared/`. Pero no lo hagas preventivamente. Empieza con el módulo dueño y mueve solo cuando hay evidencia real de que es cross-cutting.
## Cuándo sí y cuándo no
Este enfoque funciona mejor en proyectos medianos a grandes, con lógica de negocio real y equipos de varios devs. Si tu app tiene 5+ features que crecen de forma independiente, Screaming Architecture te va a ordenar la vida.
Para MVPs, landing pages o CRUDs simples, quizás es excesivo. No necesitas una estructura de módulos para 3 vistas y un formulario.
Si tu proyecto tiene potencial de crecer, adoptarlo temprano te ahorra la migración dolorosa después. Y si ya tienes un proyecto grande con estructura genérica, puedes migrar de forma progresiva, un módulo a la vez, empezando por la feature que más crece.
La próxima vez que arranques un proyecto, piensa en cómo se verá la estructura cuando el equipo crezca. Si tus carpetas solo dicen `components/` y `utils/`, ya sabes por dónde empezar.
Eso lo escribí pensando en equipos humanos. Hoy el recién llegado que abre tu repo cada vez más es un agente de IA, que lee la estructura igual que un dev nuevo pero sin intuición para rellenar los huecos. Llevar esta misma idea hasta el final, que el repo no solo grite la intención sino que tampoco pueda mentir sobre sí mismo, es de lo que se trata [Context Architecture](https://sergioazocar.com/blog/context-architecture-el-repo-entero-es-contexto).
# Context Architecture: the day I realized the whole repo is the context
## Your repo is already your agents' context, whether you designed it on purpose or not
That sentence took me a while to understand. In this post I'll save you the trip.
It was October 2025, working in Skyward's monorepo with AI agents every day. And every day the same routine: I'd tell the agent in the prompt "don't use this", "don't do it this way", "reuse the component that already exists". I wrote it down. I repeated it.
The agent did exactly what I told it not to do.
It wasn't that it didn't listen to me. It was that it read the code and saw something else there.
## The agent believes the code, not your prompt
An agent follows the patterns it **sees** in the repo, not the ones you **tell** it in the prompt. And subagents are worse, because they start without the conversation's context. The whole fight you put up earlier in the chat, for them it never happened.
So this is what kept happening. It created a new component even though one already existed that solved exactly that problem. It didn't respect the design rules or use the design tokens. It followed stale docs because they were still there, alive, with nothing flagging them as outdated.
My first instinct was everyone's instinct, cram more context into the prompt. More rules, more "please don't do this", more examples pasted in by hand. It half worked, and for the next task you had to add it all again. Until the next subagent showed up and started from scratch.
At some point, tired of repeating myself, I understood the obvious thing.
The agent wasn't disobeying me. It was reading the repo and listening to what the repo said about itself. If the good component lives alongside three old versions, it has no way to know which one is the official one. If the docs say one thing and the code does another, it'll believe whichever is closest at hand. It's doing exactly what I asked.
The repo itself is the context agents use. If it's badly structured, the answers won't be good. Period.
> No prompt fixes a repo that contradicts itself.
## Screaming Architecture got me to midfield
The first thing I added to the monorepo was [Screaming Architecture](https://sergioazocar.com/en/blog/screaming-architecture-the-key-to-scalable-frontend), which I already wrote about here. Organize by domain, not by technology: `billing/`, `payments/`, not `controllers/`, `services/`. Let the structure scream the business intent.
And it worked better than I expected with agents. A structure that screams intent is a structure an agent understands without you pasting half a README into every prompt. An agent that knows where `billing/` lives won't invent a new folder. With that plus good specs I was able to do large migrations that would have been painful before, like [moving from Biome to oxlint and oxfmt](https://sergioazocar.com/en/blog/how-to-migrate-with-claude-code-and-not-die-trying) across the whole monorepo.
But it fell short.
## Where it fell short
Screaming Architecture gives you a readable structure. It tells you where things are and what each part does. That's huge.
What it doesn't give you is the guarantee that what the repo says about itself is true.
An `AGENTS.md` describing a flow that changed three months ago. A comment pointing to a function that no longer exists. An "official" design token that half the code ignores. A module marked as deprecated that three features keep importing because no mechanism forbids it.
All of that lives in silence. It reads perfectly, it's well organized, it screams the right intent. And it lies.
For a person that's manageable noise. You read around it, you ask in Slack, you use your judgment. For an agent it's poison. The agent has no judgment to know that line is stale. It reads it, believes it, and propagates it.
That was the hole. I was missing **verifiability**. That everything the repo claims about itself is true, and that when it stops being true, something breaks and tells me.
That jump, from readable structure to verifiable context, is the core of what ended up being Context Architecture.
## The rule that orders everything
If you make me keep one single sentence, it's this:
> Every claim a repository makes about itself must be tied to a mechanism that fails when that claim stops being true.
That's all of it.
A README that says "we use this pattern" and nothing blows up when you stop using it, that's documentation. It's going to rot, it's just a matter of time. But a pattern that lives in a lint rule that fails in CI when you violate it, that one is architecture. It can't lie, because if it lies, the build breaks.
That's the difference between something the agent respects and something the agent ignores deliberately.
## What it looks like in practice
I'm not going to re-explain the whole architecture here, that's what [the spec at context-architecture.dev](https://context-architecture.dev/){rel=""nofollow""} is for. But if I boil it down to what I use day to day, it's this.
- The structure screams intent (this is the direct inheritance from Screaming Architecture).
- Embedded context: an `AGENTS.md` or `CLAUDE.md` at every code boundary, with what you don't learn by reading the code, the decisions, the whys, what not to touch.
- Intent becomes mechanism: the spec precedes the code and defines the what, and once that intent lives in tests, types and lint, the spec can be deleted because it's already enforced.
- Capabilities are discoverable: tools, skills and commands in predictable paths, where the agent will go looking for them.
And what enforces all of it? Mechanisms that make the false stuff fail, each catching at its own level. The compiler catches the types (typecheck). The linter catches the structure. The tests catch the docs lying. And review catches semantic truth, the thing no agent can check on its own yet. The point is to push every truth as far down as possible: if the compiler can guarantee something, don't leave it for human review.
If a repo claim doesn't touch at least one of those mechanisms, it's hanging by a thread.
In practice this turns into a loop, and it's the part that changed my day-to-day the most. Every time I add new context to the repo, a convention, a source of truth, a decision about what not to touch, I tie its verification mechanism in the same change. Not in the next commit, not in the tech-debt ticket that never comes. Right there. And if I can't think of how to verify it, it almost always means I don't yet have a clear idea of what I'm claiming.
## Where the name comes from (and where the idea comes from)
The name came out naturally. The whole repo is the "context" agents use, and what needed improving was its structure, that is, its architecture. Context Architecture. Done.
Where I do want to be precise is the lineage, because I didn't reinvent the wheel.
Context Architecture inherits from Screaming Architecture (Robert C. Martin, 2011) and extends it into the age of agents. And its spiritual ancestor is TDD: tests verify that the code does what it says; Context Architecture verifies that the repository tells the truth about itself. Same idea, one level up.
## Why I'm telling this now
Because I already tested it and it works.
Context Architecture is what we use in Skyward's monorepo, it's not a theory I came up with in the shower. And it didn't stop there. I applied it in my plugin [oxlint-tailwindcss](https://sergioazocar.com/en/blog/oxlint-tailwindcss-the-linting-plugin-tailwind-v4-needed), which is pure dogfooding, and in context-architecture.dev's own site. Three real codebases, from different domains, with the same architecture underneath.
I can't throw a number at you here, and I'm not going to make one up. The proof is that the rules hold up when the repo grows and when the one writing is no longer you, it's an agent at 2 AM.
And I started telling people about it in talks. I gave an in-person one at Duoc in Puerto Montt, "Context Architecture: how to design code for people and AI agents", at the SummIT de IA Aplicada that Duoc ran. It also showed up in the webinar "Beyond vibe coding: software engineering with AI agents". But talks pass. I wanted a place where the idea stays complete and open, so anyone can go look at it in depth whenever it makes sense to them. That's the spec, and that's why it lives on its own site and not buried in this post.
## The close
If any of this made sense to you, two concrete things.
One, [read the full spec at context-architecture.dev](https://context-architecture.dev/){rel=""nofollow""}. The whole idea is there, and how to apply it tomorrow in your own repo. I keep it alive, so it'll keep growing.
Two, if it's useful to you, drop a star on the [repo on GitHub](https://github.com/sergioazoc/context-architecture){rel=""nofollow""}. It helps me get it in front of more people. It helps me a ton to get it in front of more people, and gives it weight as a real ecosystem project.
The repo is the context. Make it tell the truth.
# How to migrate with Claude Code and not die trying
## Migrating no longer hurts
I migrated the entire design of this site in a couple of days. Before Claude Code, that same migration would have eaten up two weeks of my time, and I'd probably have put it off for six months out of pure laziness.
I've been doing migrations for years. Vue 2 to Vue 3, full design systems, architectural refactors that touched hundreds of files. They all had one thing in common, they hurt. Long, mechanical projects with high risk and little visible reward. The kind of work you know has to be done but keep putting off because it sounds like hell.
Not anymore.
## The historical pain of migrating
Migrating from Vue 2 to Vue 3 was a quarter-long project. Not because of real technical difficulty (most of it was replacing deprecated APIs), but because of sheer volume. Hundreds of components, each with its own quirks, all of them needed touching. Add Pinia, Vue Router 4, the third-party plugins that didn't yet support v3. And while you were migrating, the rest of the team kept merging features into main. By the time you tried to merge back, it was hell.
Migrating a design system was similar but worse. You change the token system, the base components, the prop names. Suddenly every `` in the app needs review, and when you have hundreds of those, no team signs off on a full quarter just for that.
The pattern was always the same, high load, low motivation, indefinite procrastination.
## Why migrations are the perfect scenario for an agent
After doing several with Claude Code, I've identified three conditions where they're ideal:
1. **High volume of mechanical changes.** The oxlint PR at my current job touched more than 700 files. Most were trivial adjustments (import order, equivalent patterns). Exactly the kind of work that is humanly tedious and that an agent does in minutes.
2. **Clear, automatable feedback.** Linter, Typecheck, Tests, they pass or they don't. The Build compiles or it doesn't. Every step is a loop with binary response, exactly the kind of signal an agent needs to iterate without continuous supervision.
3. **Accessible documentation.** The target tool almost always has a *migration guide*. Pass the guide to the agent, give it the project context, and it stops making things up.
If your task meets all three, stop procrastinating. It's work for an agent.
## Case 1: a monorepo's toolchain
The team at my current job operated a monorepo with 7 applications on Cloudflare Workers, around 1,670 TypeScript files managed with pnpm workspaces. The quality pipeline used Biome for lint and format, and tsc for type-check.
Two bottlenecks:
- Biome and tsc required `NODE_OPTIONS='--max-old-space-size=8192'` to avoid out of memory crashes during CI.
- tsc, single-threaded, took 25 seconds to typecheck the entire monorepo.
We'd been watching for months what the oxc team (oxlint, oxfmt, everything they're building in Rust) was shipping. The question wasn't whether the migration was worth it, but how much effort it was going to cost.
### Migration to oxlint + oxfmt
The steps:
1. Replace Biome with oxlint and oxfmt.
2. Configure `.oxlintrc.json` with 129 active rules.
3. Configure an equivalent `.oxfmtrc.json`.
4. Update scripts in `package.json`.
5. Adjust the GitHub Actions pipeline.
6. Adjust the editor config.
The bulk of the work wasn't switching tools. It was adjusting code to meet the new rules (mostly about import order and equivalent patterns). Claude Code made the changes, we ran the validations, adjusted, repeated.
| Metric | Biome | oxlint (Jan 2026) | oxlint (Mar 2026) |
| -------------- | ----- | ----------------- | ----------------- |
| Average time | 3.70s | 1.04s | 0.49s |
| Files analyzed | 1,227 | 1,262 | \~1,670 |
oxlint was processing more files with more rules in a fraction of the time. And the tool keeps improving with every release.
### Migration to tsgo
TypeScript 7 (tsgo) is the rewrite of tsc (TypeScript Compiler) in Go. Microsoft promised 7x to 10x improvements. The migration was even more straightforward:
1. Install `@typescript/native-preview`.
2. Change the script from `NODE_OPTIONS='--max-old-space-size=8192' tsc` to `tsgo`.
3. Adjust `tsconfig.json` (drop `baseUrl`, add `./` as prefix in paths).
4. Update the CI cache (from `.tsbuildinfo` to `.tsgo-cache`).
5. Fix types in places where tsgo is stricter than tsc.
The bonus, you no longer need the memory config. tsgo runs in Go without Node's constraints.
| Metric | tsc | tsgo (Jan 2026) | tsgo (Mar 2026) |
| ---------------------- | ---------- | --------------- | --------------- |
| Time (cold) | 25.34s | 5.32s | 0.76s |
| CPU usage | \~132% | \~430% | \~430% |
| Requires memory config | Yes (8 GB) | No | No |
33 times faster on typecheck, without touching a single line of TypeScript code in the project.
### The final number
| Phase | Before | Migration | Today (Apr 2026) |
| --------- | ---------- | --------- | ---------------- |
| Lint | 3.70s | 1.04s | 0.49s |
| Typecheck | 25.34s | 5.32s | 0.76s |
| **Total** | **29.04s** | **6.36s** | **1.25s** |
From 29 seconds to 1.25. 23 times faster. And what finally convinced the team were precisely those results. Without measuring before and after, the migration is just "Sergio changed the tools". With the results, it's "lint and typecheck are 23X faster".
**Lesson:** always measure. That's what turns a migration into a story the team understands and signs off on.
## Case 2: screaming architecture in a large frontend
Before the toolchain migration, I'd done another one: applying [screaming architecture](https://sergioazocar.com/en/blog/screaming-architecture-the-key-to-scalable-frontend) to the main product's frontend. Reorganizing the repo from technical folders (`components/`, `services/`, `store/`) into feature modules (`modules/Auth/`, `modules/Orders/`, etc).
The change itself is simple: move files and update imports. But when it's hundreds of files in hundreds of imports, it's the textbook definition of "mechanical work nobody wants to do on a Friday".
This is where Claude Code shines. The pattern is uniform, changes are verifiable (compiles or doesn't), and the only new rule is "everything in feature X lives in `modules/X/`". The agent gets the pattern from the first example and applies it everywhere else.
All I had to do was define the contract (specs). Which modules to create, what counts as shared, how to handle files that touched more than one feature. After that it was iterate, validate build, validate tests, adjust.
**Lesson:** structural refactors are ideal when the pattern is clear but the volume is high. Define the rules once, let the agent apply them everywhere.
## Case 3: the redesign of this site
The most recent one and the closest to this post. Migrating sergioazocar.com from the previous design to the current one: edge-to-edge layout with BentoGrid, Nuxt UI v4, Tailwind v4, custom color system (`beacon` and `orbit`), dark-only theme, translucent borders with `color-mix`.
The difference with the previous cases is that the project context was entirely mine. I have a well-kept `CLAUDE.md`, with the repo conventions, the stack, the i18n rules, the blog post patterns, the gotchas. When I ask Claude Code to migrate a component to the new border system, I don't need to explain what `border-(muted)` is or where it's defined. It already knows.
That's dogfooding. The project is ready for the agent to be useful from the first prompt. And that pays dividends fast, every turn moves forward instead of being spent re-explaining context. Structuring the whole repo with that intent, for people and agents alike, is what I later called [Context Architecture](https://sergioazocar.com/en/blog/context-architecture-the-whole-repo-is-the-context).
**Lesson:** investing in a good `CLAUDE.md` isn't overhead, it's the difference between a useful agent and one that hallucinates.
## The playbook
After several migrations, this is what I always do:
1. **Plan before generating.** A poorly scoped migration is a failed migration. Define the scope, the steps, the files. If you don't have clarity before starting, the agent won't either.
2. **Atomic PRs per phase.** In the toolchain case, oxlint and tsgo were separate PRs. If something breaks, you know exactly which phase caused it.
3. **Validate every step.** Lint, type-check, tests, preview. Claude Code runs the commands, you read the output. Skipping validation because "the previous change passed" is the recipe for piling up 50 errors before you notice.
4. **Keep a `CLAUDE.md` up to date.** Stack, conventions, commands, project gotchas. The difference between an agent that understands the repo and one that makes things up.
5. **Measure before and after.** Without numbers there's no story to tell, nor argument to pitch to the team.
6. **Pair programming, not autopilot.** The agent doesn't replace judgment, it amplifies it. You decide what gets done, it runs and brings the feedback.
## The tools I use in a migration
After several migrations, there's a setup I repeat. These are the pieces that make the difference between useful Claude Code and Claude Code that wastes your time.
**A `CLAUDE.md` specific to the current context.** Conventions, commands, gotchas, and the migration in progress. Don't copy `package.json` (Claude Code can read it already), write what **isn't** inferable from the deps. Minimal example:
```md
# Project name
## Relevant stack (what isn't inferable from package.json)
- Deployed to Cloudflare Workers
- i18n with prefix strategy (ES/EN)
- Composition API only, no Options API
- oxlint instead of eslint
## Commands
- `pnpm lint` — oxlint
- `pnpm test` — vitest
## Migration in progress
Biome → oxlint + oxfmt. Replace each rule with its equivalent,
run `pnpm lint` after every batch, don't touch tests in this PR.
```
**`/plan` before generating.** Makes the agent investigate the repo, propose a structured plan, and only execute after you approve it. Perfect for migrations, it saves you from starting three times because the first scope was wrong.
**Sub-agents to parallelize.** While the main agent keeps editing, launch a sub-agent that runs `pnpm typecheck` or `pnpm test` on another module and brings back the result. For large migrations, 2-3 sub-agents in parallel split the work and keep the main agent's context clean.
**Hooks for automatic validation.** A `PostToolUse` hook that runs `pnpm lint` after every `Edit` is the cheapest way to stop having to ask "validate" every time. It runs on its own, if it fails the agent sees it and fixes it on the next turn.
## What NOT to do
- **Don't ask for everything at once.** "Migrate the whole repo" is the worst possible instruction. Split into phases, one at a time.
- **Don't skip validation.** Even if the change looks trivial, run the checks. The error you assume doesn't exist is the one that ends up in production.
- **Don't let it invent paths or APIs.** If it cites a function you don't recognize, verify it exists before accepting the change.
- **Don't merge without understanding the changes.** The agent accelerates your work, it doesn't replace your responsibility. If you don't understand what changed, you're not migrating, you're crossing your fingers.
## Closing
The barrier to entry for migrating dropped dramatically. What used to be a quarterly project is now a weekend project. What you used to push back six months, you can start today and finish in a couple of days.
If you have a migration that's been stuck for a while, open Claude Code this week. You'll probably finish it before the sprint ends.
And even if the final metrics don't come out that pretty, you'll still understand more about your codebase in two days than in the six months you'd been putting it off.
# oxlint-tailwindcss: the linting plugin Tailwind v4 needed
## From problem to open-source: the perfect opportunity to contribute
The day the oxc team opened the alpha for native oxlint plugins, it was the perfect excuse to solve a problem I'd been having at my current job for a while: linting Tailwind CSS classes with oxlint.
If you use Tailwind CSS v4 with oxlint, the existing linting options aren't built for that combo. `eslint-plugin-tailwindcss` is solid but lives in the ESLint world and its v4 support is still partial. `eslint-plugin-better-tailwindcss` works in oxlint through the jsPlugins compatibility layer, and it gets the job done, but it's not a native plugin and its rules are more limited. Neither was designed specifically for oxlint + Tailwind CSS v4.
So I built it.
## What is oxlint-tailwindcss
A **native** oxlint plugin with 23 linting rules designed exclusively for Tailwind CSS v4. It's not an ESLint port or a wrapper, it uses the `@oxlint/plugins` API directly. Only two runtime dependencies, `@tailwindcss/node` and `tailwindcss`.
This matters because being native means it shares the same parsing cycle as oxlint. There's no interoperability overhead, no translation layer. It's as fast as oxlint itself, and it shows.
### Deterministic by design
To validate your classes, the plugin reads your real design system. And for that it needs just one thing, your CSS entry point, the file with `@import "tailwindcss"` and your `@theme` tokens. You declare it once in `settings.tailwindcss.entryPoint` and all 23 rules validate against the same design system.
```jsonc
{
"jsPlugins": ["oxlint-tailwindcss"],
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css", // your CSS with @import "tailwindcss"
},
},
"rules": {
"tailwindcss/no-unknown-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/enforce-sort-order": "warn",
},
}
```
Why declare it by hand instead of guessing it? Because guessing depends on the filesystem, and that means the same code can produce different results on your machine and in CI. Declaring the entry point explicitly is deterministic, same input, same output, on every machine. It's the same principle oxlint follows, and I'd rather have a plugin that's predictable than one that tries to be magic.
For the same reason, if you get the config wrong the plugin doesn't stay quiet and skip rules. It throws a single `designSystemUnavailable` diagnostic with a hint on what to fix. It fails loud and clear.
The plugin reads your CSS by calling `@tailwindcss/node` directly, so it understands your `@theme` tokens, your shadcn variables, and plugins like `@tailwindcss/typography` or `tailwindcss-animate`. The design system is computed once and cached on disk with a content hash, no recomputing on every run.
In a monorepo with several design systems you map globs to entry points, and the first match wins:
```jsonc
{
"settings": {
"tailwindcss": {
"entryPoint": [
{ "files": "packages/ui/**", "use": "packages/ui/src/styles.css" },
{ "files": "packages/web/**", "use": "packages/web/src/app.css" },
{ "files": "**", "use": "src/global.css" },
],
},
},
}
```
The trailing `"**"` is the fallback for everything outside the explicit globs. If you prefer, you can also keep one `.oxlintrc.json` per package, both ways are 100% deterministic.
## 23 rules in four categories
### Correctness: catch real bugs
Correctness rules catch bugs before they reach the browser.
**`no-unknown-classes`** detects classes that don't exist in your design system and suggests fixes for typos:
```jsx
// ^^^^^^^^^^^
// "itms-center" is not a valid Tailwind CSS class.
// Did you mean "items-center"?
```
It supports `allowlist` to allow custom classes not in your design system and `ignorePrefixes` to skip prefixes that aren't Tailwind classes.
**`no-conflicting-classes`** tells you exactly which CSS property is conflicting and which class wins:
```jsx
// "text-red-500" and "text-blue-500" affect "color".
// "text-blue-500" takes precedence (appears later).
```
**`no-dark-without-light`** detects when you use `dark:` without a base class, something that often causes missing styles in light mode:
```jsx
// ❌ — what background does it have in light mode?
// ✅
```
**`no-dark-without-light`** checks `dark:` by default, but the `variants` option lets you enforce the same pattern for any variant, useful if your project uses custom variants.
**`no-contradicting-variants`** catches redundant variants where the base class already applies unconditionally:
```jsx
// ❌ — dark:flex is redundant, flex already applies always
```
**`no-deprecated-classes`** automatically replaces classes deprecated in v4:
```jsx
// ❌ v3
// ✅ v4 (autofix)
```
Also `flex-shrink` → `shrink` and `decoration-clone` → `box-decoration-clone`.
Plus the usual **`no-duplicate-classes`** (with autofix) and **`no-unnecessary-whitespace`**.
### Style: team consistency
**`enforce-sort-order`** sorts classes according to the official Tailwind CSS order (with autofix), compatible with oxfmt and prettier-plugin-tailwindcss. Its strict mode groups classes by variant prefix, sorts within each group, and orders groups by variant priority.
**`enforce-shorthand`** converts `mt-2 mr-2 mb-2 ml-2` to `m-2`, `w-full h-full` to `size-full`, and many more combinations. All with autofix.
**`enforce-logical`** converts physical properties to logical ones for LTR/RTL support: `ml-4` → `ms-4`, `left-0` → `start-0`. Its inverse, **`enforce-physical`**, does the opposite for projects that are LTR-only and prefer consistency with physical properties. Both with autofix.
**`enforce-consistent-variable-syntax`** normalizes CSS variable syntax between `bg-[var(--primary)]` and v4's shorthand `bg-(--primary)`.
**`enforce-canonical`** converts arbitrary values to native classes when they exist: `p-[2px]` → `p-0.5` (uses `rootFontSize` of 16px by default for conversion). It plugs directly into Tailwind's canonicalization API.
**`enforce-consistent-important-position`** (default suffix, v4's canonical form), **`enforce-negative-arbitrary-values`** (`-top-[5px]` → `top-[-5px]`), and **`consistent-variant-order`** round out the style rules.
### Complexity: keep code manageable
**`max-class-count`** warns when an element exceeds 20 classes (configurable). It's the signal that it's time to extract a component.
**`enforce-consistent-line-wrapping`** controls the class string length by print width or by number of classes per line.
### Restrictions: design system rules
**`no-hardcoded-colors`** forbids hardcoded colors like `bg-[#ff5733]` in arbitrary brackets, the typical shortcut that erodes your design system.
**`no-arbitrary-value`** and **`no-unnecessary-arbitrary-value`** (with autofix) control the use of arbitrary values. The latter detects when you use `h-[auto]` but `h-auto` exists.
**`prefer-theme-tokens`** detects when you reference a CSS variable by hand (`bg-[var(--primary)]` or the shorthand `bg-(--primary)`) and the named token utility exists in your design system, and rewrites it to `bg-primary`. It preserves opacity modifiers, variants, and `!important`. With autofix.
**`no-restricted-classes`** allows blocking specific classes by name or regex, with custom messages.
## Class extraction
The parser is what makes all of this work reliably. It's not a regex that looks for `className=` and hopes for the best. It extracts classes from:
- JSX attributes (`className`, `class`)
- Object-valued JSX attributes, e.g. Mantine's ``
- Template literals with interpolation
- Ternaries
- Utility functions: `cn()`, `clsx()`, `cx()`, `cva()`, `twMerge()`, `twJoin()`, and more
- Full `cva()`: base, variants, compoundVariants
- Full `tv()`: base, slots, variants with slot objects, compoundSlots
- `classed()` (tw-classed): skips element type, extracts classes and cva-like config
- Tagged templates (`tw\`...\`\`)
- Variables by name (`className`, `classes`, `style`, `styles`)
- Component classes defined with `@layer components { .btn {} }` in your CSS
It handles nested brackets, nested calc, arbitrary variants, quoted values, important modifier, negative values, and named groups/peers. The edge cases that break other parsers.
### Custom class detection
By default the plugin detects classes in common attributes, 14 utility functions, `tw` tagged templates, and variables named `className`/`classes`/`style`. You can extend these defaults via `settings.tailwindcss`, all values are additive:
```jsonc
{
"settings": {
"tailwindcss": {
"attributes": ["overlayClassName"],
"callees": ["myHelper"],
"tags": ["css"],
"variablePatterns": ["^tw"],
},
},
}
```
This applies to all 23 rules at once. If you need to remove a built-in default, use `exclude` in the same settings block.
## The story behind it
I started by planning what I wanted, the stack I was going to use, and how I wanted everything to work. After planning the implementation with **Claude Code**, the iteration began until reaching the current 23 rules. The repo includes a `CLAUDE.md` and configured skills that allow any contributor to use the same workflow to write new rules, the same tool the plugin was built with. If you want to add a rule, Claude Code already knows how to do it in this project.
The project runs entirely on the VoidZero tool ecosystem. [tsdown](https://tsdown.dev/){rel=""nofollow""} for the build, [oxfmt](https://oxc.rs/#feature-formatter){rel=""nofollow""} for formatting, [vitest](https://vitest.dev){rel=""nofollow""} for testing, [tsgo](https://github.com/microsoft/typescript-go){rel=""nofollow""} (native TypeScript 7 in Go) for type checking, and of course [oxlint](https://oxc.rs/#feature-linter){rel=""nofollow""} for linting the plugin itself. Every tool in the chain is built on Rust or optimized for speed.
It wasn't a cosmetic decision, it was deliberate dogfooding. If you're going to make a plugin for oxlint, it makes sense that the entire toolchain is from the same ecosystem. And if you're going to develop with an AI agent, it makes sense that the repo is prepared for it. That repo-ready-for-agents is exactly what I later systematized in [Context Architecture](https://sergioazocar.com/en/blog/context-architecture-the-whole-repo-is-the-context).
## Getting started
You need oxlint ≥ 1.43.0, Tailwind CSS v4, and Node.js ≥ 20. Install the plugin:
```bash
pnpm add -D oxlint-tailwindcss
```
Add the plugin, your entry point, and the rules to your `.oxlintrc.json`:
```jsonc
{
"jsPlugins": ["oxlint-tailwindcss"],
"settings": {
"tailwindcss": {
"entryPoint": "src/styles.css", // your CSS with @import "tailwindcss"
},
},
"rules": {
"tailwindcss/no-unknown-classes": "error",
"tailwindcss/no-duplicate-classes": "error",
"tailwindcss/no-conflicting-classes": "error",
"tailwindcss/no-deprecated-classes": "error",
"tailwindcss/no-unnecessary-whitespace": "error",
"tailwindcss/enforce-sort-order": "warn",
"tailwindcss/enforce-shorthand": "warn",
"tailwindcss/no-hardcoded-colors": "warn",
//...
},
}
```
Run oxlint. That's it.
## Try it
The plugin is functional, tested, and used in production. But a linter gets better with real feedback from real projects.
If you try it and find a case it doesn't handle well, open an [issue](https://github.com/sergioazoc/oxlint-tailwindcss/issues){rel=""nofollow""}. If you want to contribute a rule, the repo is already set up so you can iterate with Claude Code from minute one. And if it was simply useful to you, a star on [GitHub](https://github.com/sergioazoc/oxlint-tailwindcss){rel=""nofollow""} helps more people find it.
---
**[GitHub](https://github.com/sergioazoc/oxlint-tailwindcss){rel=""nofollow""}** · **[npm](https://www.npmjs.com/package/oxlint-tailwindcss){rel=""nofollow""}**
# Screaming Architecture: the key to a scalable frontend
## The pattern I saw everywhere
Across multiple companies where I worked, I kept running into the same pattern. Teams knew that organizing by features was important, the intention was there. But in practice, the feature ended up scattered across the entire project: `components/auth/`, `services/auth/`, `store/auth/`, `views/auth/`. The feature name appeared in every technical folder, but never in a single place.
```text
src/
├── components/
│ ├── auth/ # LoginButton.vue, RegisterForm.vue
│ ├── products/ # ProductCard.vue, ProductList.vue
│ └── orders/ # OrderSummary.vue
├── views/
│ ├── auth/ # LoginView.vue, RegisterView.vue
│ ├── products/ # ProductsView.vue
│ └── orders/ # OrdersView.vue
├── services/
│ ├── auth.service.ts
│ ├── products.service.ts
│ └── orders.service.ts
├── store/
│ ├── auth.store.ts
│ ├── products.store.ts
│ └── orders.store.ts
└── utils/
```
At first glance it looks organized. But when you need to change something in Auth, you touch 4 folders. When a new dev joins, they have to mentally reconstruct which files belong to which feature. And as the app grows, each technical folder becomes a junk drawer with 30+ files.
The problem isn't a lack of organization. It's that the organization screams "technology" instead of screaming "business."
## The analogy that explains it all
Imagine a house blueprint. You don't see a "bricks" section, a "cement" section, and a "windows" section. You see "kitchen," "bedroom," "bathroom." The blueprints scream the purpose of each space, not the materials it's built with.
Screaming Architecture seeks the same for your code. Your top-level folder structure should scream the features of your application, not the technologies you use.
## What you gain with this approach
- **Instant understanding**: you open the project and at a glance you know what the business does.
- **Fast onboarding**: new devs understand the project structure in minutes, not days.
- **Localized changes**: modifying a feature doesn't force you to touch 4 folders. Everything is in one place.
- **Real scalability**: adding a new feature means creating a folder, not inserting files in 6 different places.
- **Easier testing**: each module is an isolated unit you can test independently.
- **Safe refactoring**: moving or deleting an entire feature means moving or deleting a folder.
## Applying Screaming Architecture
The idea is simple: group by features or business domains. Each folder contains everything needed for that functionality.
```text
src/
├── modules/
│ ├── Auth/
│ │ ├── components/ # LoginButton.vue, RegisterForm.vue
│ │ ├── views/ # LoginView.vue, RegisterView.vue
│ │ ├── routes/ # Routes for this module
│ │ ├── store/ # auth.store.ts
│ │ └── services/ # auth.service.ts
│ ├── Products/
│ │ ├── components/ # ProductCard.vue, ProductList.vue
│ │ ├── views/ # ProductsView.vue, ProductDetailView.vue
│ │ ├── store/ # products.store.ts
│ │ └── services/ # products.service.ts
│ └── Orders/
│ ├── components/
│ ├── views/
│ ├── store/
│ └── services/
├── shared/
│ ├── ui/
│ │ ├── components/ # BaseButton.vue, ModalBase.vue
│ │ └── composables/ # useModal.ts, useDarkMode.ts
│ ├── composables/ # useDebounce.ts, useLocalStorage.ts
│ ├── utils/ # formatDate.ts, validateEmail.ts
│ └── assets/ # Global images, base styles
├── layouts/ # DefaultLayout.vue, AuthLayout.vue
├── router/ # General router
├── app.vue
└── main.ts
```
Not every module needs the same internal structure. Auth may have `store/` and `services/`, but a Landing module might just be `components/` and `views/`. Each module defines what it needs.
## From theory to practice: migrating a feature
Let's take the Auth example from the disaggregated structure and see what the migration looks like:
**Before** (4 folders):
```text
src/components/auth/LoginButton.vue
src/components/auth/RegisterForm.vue
src/views/auth/LoginView.vue
src/views/auth/RegisterView.vue
src/services/auth.service.ts
src/store/auth.store.ts
```
**After** (1 folder):
```text
src/modules/Auth/components/LoginButton.vue
src/modules/Auth/components/RegisterForm.vue
src/modules/Auth/views/LoginView.vue
src/modules/Auth/views/RegisterView.vue
src/modules/Auth/services/auth.service.ts
src/modules/Auth/store/auth.store.ts
```
The imports change, the code doesn't. If your Auth module needs to expose something to the rest of the app (a guard, a composable, the user state), you can create an `index.ts` that serves as the module's public API:
```ts
// src/modules/Auth/index.ts
export { useAuthStore } from './store/auth.store'
export { useCurrentUser } from './composables/useCurrentUser'
export { authGuard } from './routes/guards'
```
The rest of the app imports from `@/modules/Auth`, not from internal folders. If you refactor the module's internal structure, external imports don't break.
## Framework coexistence
If you use Nuxt, Next, or any framework with folder conventions (`pages/`, `composables/`, `components/`), the obvious question is: how does this coexist with Screaming Architecture?
Framework conventions handle routing and auto-imports. Screaming Architecture handles business logic.
In Nuxt, for example, `pages/` defines routes, but the page can be a thin wrapper that imports the actual module:
```vue
```
Nuxt handles routing (`/auth/login`), and your Auth module handles the logic. `pages/` stays thin, just an entry point.
The same applies to `composables/`. Global composables (auto-imported by Nuxt) go in the framework's conventional folder. Feature-specific composables go inside the module:
```text
composables/ # Auto-imported by Nuxt (global)
├── useTheme.ts
└── useBreakpoint.ts
modules/Auth/composables/ # Auth-specific (manual import)
├── useCurrentUser.ts
└── useAuthRedirect.ts
```
## Cross-cutting concerns: what goes in shared
The most common question when applying this: what happens when two modules need the same thing?
The rule is simple. If something is specific to a feature, it goes in its module. If two or more features use it, it goes in `shared/`.
`shared/ui/` is for generic UI components with no business logic: buttons, modals, inputs. They're the building blocks, not the features.
`shared/composables/` is for reusable logic without business state: `useDebounce`, `useLocalStorage`, `useIntersectionObserver`.
`shared/utils/` is for pure functions: `formatDate`, `slugify`, `validateEmail`.
What about shared state? If Auth and Orders both need user data, Auth owns the state. Orders imports `useCurrentUser` from `@/modules/Auth`. If over time more modules need the same data, you can move that composable to `shared/`. But don't do it preemptively. Start with the owning module and only move when there's real evidence it's cross-cutting.
## When to use it and when not to
This approach works best for medium to large projects with real business logic and multi-dev teams. If your app has 5+ features that grow independently, Screaming Architecture will bring order to your life.
For MVPs, landing pages, or simple CRUDs, it might be overkill. You don't need a module structure for 3 views and a form.
If your project has the potential to grow, adopting it early saves you the painful migration later. And if you already have a large project with a generic structure, you can migrate progressively, one module at a time, starting with the feature that grows the most.
Next time you start a project, think about what the structure will look like when the team grows. If your folders only say `components/` and `utils/`, you know where to start.
I wrote that thinking about human teams. Today the newcomer who opens your repo is increasingly an AI agent, one that reads the structure just like a new dev but without the intuition to fill in the gaps. Taking this same idea all the way, so the repo not only screams its intent but also can't lie about itself, is what [Context Architecture](https://sergioazocar.com/en/blog/context-architecture-the-whole-repo-is-the-context) is about.
# What I learned building Design Systems and what I would do differently
## What is a Design System?
Simply put, it's a collection of **rules**, **components**, and **guidelines** that act as a **single source of truth** for designing and building interfaces.
A good Design System defines everything from **colors**, **typography**, and **spacing**, to how **buttons**, **forms**, **alerts**, and more should behave. But it goes far beyond the visual: it also defines how design and development communicate, how patterns are documented, and how the product evolves consistently.
> The goal is for the entire team to speak the same language, design faster, and build products that feel coherent, without reinventing the wheel on every screen.
It’s not just a component library. It’s a way of working that seeks to **align design, code, and user experience**.
I'll go into more detail about how I built mine and what I learned, but first I want to tell you what it was like to face this idea from scratch.
## How I Ended Up Building Design Systems
After working on different projects and teams, I began to notice patterns that kept repeating:
- The same button designed three different ways
- Inconsistent behavior in similar components
- Duplicated or hard-to-maintain code
- Misunderstandings between design and development
- Interfaces that looked great in Figma, but not in production
Every time a product grew, so did the visual and technical chaos. And with it, the time we lost solving the same problems over and over again.
That’s when I realized we needed more than just a component library. We needed a **shared way to build interfaces**, with clear rules, centralized decisions, and a source of truth for all teams.
## Expectations vs. Reality
Remember when you had to do group projects at school?
Everyone did their part separately, put it all together at the end… and the result was a total "Frankenstein."
Well, building a Design System often feels like that.
At first, you think it’s going to organize everything, magically uniting design and development. But in practice, misunderstandings, shortcuts, and crossed decisions appear, and what should be a coherent system starts to fall apart.
Here are some of the biggest differences I experienced between what I imagined… and what actually happened:
### Expectation 1:
> “I’ll create a reusable component library and the team will happily use it.”
**Reality:**
Some devs ignore it, others break it, and others don’t know how to use it.
Without documentation, onboarding, and internal support, no one adopts it as you expected.
### Expectation 2:
> “The design is in Figma, I just have to replicate it.”
**Reality:**
The design doesn’t consider states, errors, focus, interaction, loading...
I ended up making a lot of technical decisions that weren’t defined.
### Expectation 3:
> “Once the components are done, we won’t need to touch them again.”
**Reality:**
Design evolves, new requirements come up, and each change affects multiple parts of the system.
A Design System needs constant maintenance.
### Expectation 4:
> “I’ll create generic and reusable components for everything.”
**Reality:**
Overly flexible components end up being hard to maintain, test, or understand.
Sometimes it’s better to have clear variants than a single component with 15 props.
### Expectation 5:
> “Design and development will work as one team.”
**Reality:**
Without clear processes and a common language, misunderstandings happen all the time.
Collaboration isn’t automatic. You have to build those bridges intentionally.
### Expectation 6:
> “With Tailwind, I can do everything without a Design System.”
**Reality:**
Tailwind helps, but without shared design decisions, tokens, and a clear structure, your project still becomes inconsistent.
### Expectation 7:
> “I’ll have the system ready in a few weeks.”
**Reality:**
A Design System is never finished. It’s a living system that grows, adapts, and needs to evolve alongside your product.
## Design and Development Needs
When you set out to build a Design System (whether from scratch or based on an existing foundation) there are always two sides of the same coin: what **Design** and **Development** want and need. That’s where all the ~~problems~~ challenges begin.
Let’s take a look at what each team needs.
### Design
- **Visual consistency**: A unified visual language (colors, typography, spacing, iconography, etc.).
- **Speed and efficiency**: The ability to design interfaces without reinventing the same components every time.
- **Scalable designs**: What works on one screen should work on others (web, mobile, responsive...).
- **Clear, reusable design tokens**: Technical definitions for colors, sizes, fonts, z-index, etc.
- **Direct alignment with implementation**: What’s designed should look the same in production.
### Development
- **True component reuse**: A stable, well-documented, and easy-to-use library.
- **Functional and technical consistency**: Same prop patterns, slots, states, accessibility, validation, etc.
- **Ease of maintenance**: Clean code, clear naming, tests, and no duplicated logic.
- **Living documentation**: Storybook (or similar), with real examples, not just theory.
- **Adaptability across contexts**: A button (for example) should work the same in all our applications.
Each team has its own priorities, but for a Design System to work, they must **collaborate**, making decisions based on shared definitions. That’s where the real intersection happens.
### Shared Needs: The Balance Point
- **Common language**: Tokens, component names, variants, states. Everyone should speak the same language.
- **Smooth design–development process**: Clear handoffs from Figma (or whatever tool is used), efficient handovers, mutual feedback.
- **Versioning and change control**: So that design changes can be implemented and communicated without breaking the system.
- **Cross-team adoption**: It shouldn’t be stuck in one team. It has to be easy to understand and use by everyone.
> In short, a Design System needs to be strict enough to uphold design definitions and prevent undocumented visual changes, but flexible enough to extend and adapt without breaking things.
## How to Drive System Adoption
Having a technically perfect Design System doesn’t mean much if no one uses it.
One of the biggest challenges isn’t building the system, it’s getting the team to actually adopt it. And I don’t just mean “being aware it exists,” I mean **integrating it into their daily workflow**, respecting it, questioning it, and keeping it alive.
Here’s what helped me (and what I learned the hard way):
- **Involve people from the start**: If the system is built in isolation, it will generate resistance. The more voices involved early on, the more ownership it creates.
- **Show value quickly**: A useful, well-documented, easy-to-use component is worth more than a promise of “someday we’ll have everything tidy.”
- **Document with empathy**: It’s not just about how to use a component, it’s about how someone new will understand it. Real examples, screenshots, use cases, FAQs, all of it helps.
- **Provide internal support**: If someone has a problem with a component, there should be a quick way to solve it. Slack, issues, pair programming… whatever it takes, just don’t let people feel alone, and have a clear way to report issues.
- **Celebrate usage**: When someone adopts a component, improves it, or reports a bug, celebrate it. Small recognitions help the system feel alive, collaborative, and valuable.
> Adoption isn’t an event, it’s a process. It takes time, patience, and lots of communication.
## Lessons Learned and Final Recommendations
After all this, here are some takeaways I wish I had known earlier:
- **A Design System isn’t a project, it’s a product**. And like any product, it needs research, design, maintenance, communication, and evolution.
- **Start with what hurts the most**: Don’t try to solve everything from day one. Identify your biggest pain points and start there.
- **Perfection isn’t the goal**: There will always be debt, components to improve, and decisions to revisit. The goal is usefulness and evolution.
- **No adoption, no system**: If the team doesn’t use it, it’s just another folder in your repo.
- **Every team is different**: Don’t copy someone else’s system expecting it to work the same. Get inspired, sure, but adapt it to your context.
- **Anticipate problems**: Your Design System will break. Definitions will change. Plan for that. Build things modular and flexible.
- **Use Atomic Design**: But use it wisely. Every component is a world of its own. The more atomic each one is, the easier it is to update or extend without breaking everything.
> A Design System is a long-term investment. It can be challenging, even frustrating at times, but once it starts working, **the whole team benefits from speed, consistency, and confidence**.