Saltar al contenido principal

SilsX sin Bootstrap: cómo migramos el frontend al kit propio

· 16 min de lectura
Franco R.
El chico de software @ sils.tech

Actualizado 2026-10-10: la migración quedó terminada en SilsX. Se cerraron la Fase 3 y la Fase 4 (Bootstrap y bootstrap-vue desinstalados, sin prefijo tw: y sin important), y se sumaron aprendizajes nuevos.

El frontend de SilsX (dashboard-k2) nació sobre Bootstrap 4 + bootstrap-vue 2. Desde hace un tiempo convive con un kit de componentes propio en src/components/ui/ (Tailwind 4, puerto Vue 2.7 del design system de SilsX, galería en /#/ui). En septiembre/octubre de 2026 armamos un plan por fases para sacar Bootstrap del todo, y hoy está ejecutado completo: no quedan componentes de bootstrap-vue, ni el paquete bootstrap, ni el prefijo de Tailwind.

Este post cuenta la estrategia, qué se hizo en cada fase, cómo verificamos que nada cambiara visualmente, y los problemas que aparecieron en el camino para que nadie se los vuelva a comer.

Estado general​

FaseQuéEstado
0Groundwork: toasts, allowlist de @source, auditoría de colisiones, sacar @silstech/sils-design✅ Hecho
1Primitivos que faltaban en el kit: Modal, Collapse, Progress, v-tooltip, Carousel✅ Hecho
2Wrappers de alto apalancamiento: Table.vue, shared/Table.vue, ABMComponent.vue✅ Hecho
3Migración módulo por módulo✅ Hecho (todos los módulos)
4Limpieza final: desinstalar Bootstrap, sacar el prefijo tw: y el important✅ Hecho

Lo que queda son seguimientos chicos, no bloqueantes:

  • QA manual con el plan de QA-migracion-bootstrap.md (casos por sección, cambios intencionales y bugs conocidos que no hay que reportar).
  • Scopear algunas reglas CSS globales que se filtran entre vistas (.scroll-container, .search-videoplayback-box, .trapecio-0, .label-detail, .btn-buscar, .style-titleTab-2). Hoy se compensan con el modificador ! en los lugares afectados.
  • Bugs que ya existían antes de la migración, encontrados por los specs y anotados en el plan para corregirlos aparte.
  • Limpiezas menores: overrides sin markup en _custom.scss, algunos archivos huérfanos y comentarios viejos que describen la cascada anterior.

Las fuentes de verdad, todas en el repo dashboard-k2:

  • El plan: qu-cambios-habr-a-que-vivid-tome.md (fases, estado fechado de cada punto, bugs encontrados, verificaciones con sus números).
  • La guía del kit: src/components/ui/README.md, secciones "Migrar desde Bootstrap: tabla de equivalencias" (decisiones D1–D11, tablas y checklist por archivo) y "!important: quién le gana a quién" (las capas de CSS actuales).
  • El QA manual: QA-migracion-bootstrap.md.

La estrategia: convivir, no big-bang​

Al arrancar la Fase 3 quedaban 328 archivos con <b-*> y no había tests visuales, así que reescribir todo de una era la forma más segura de romper layouts en silencio. En cambio:

  • Prefijo tw: + important mientras durara la convivencia. Tailwind se importaba como @import "./tw-index.css" source(none) prefix(tw) important;: las utilidades del kit se escribían tw:flex, tw:mt-2… y no chocaban con las clases de Bootstrap; al ser !important le ganaban a cualquier regla de Bootstrap, del _custom.scss o de un <style>. Era andamiaje: en la Fase 4 se sacaron las dos cosas (ver abajo).
  • Allowlist con source(none). Tailwind sólo escanea lo listado con @source en src/assets/css/ui.css. Un archivo fuera de la lista no recibe ninguna utilidad, sin error ni warning. Hoy views, components, containers y layouts están cubiertos como carpetas enteras.
  • Sin preflight global. El reset vive scopeado a .silsx-root; el resto de la app sigue con el reboot de Bootstrap (que es lo único que quedó de Bootstrap, ver Fase 4).
  • Tests de caracterización antes de tocar. Antes de migrar cada módulo se escriben specs e2e con Playwright (tests/e2e/) que fijan el comportamiento actual, y se corren antes y después. Los specs usan un mutation guard (tests/e2e/utils/mutation-guard.js) que bloquea toda escritura contra staging, y un login por worker para no agotar la cuota de Firebase.
  • Diff de estilos computados para los cambios globales. Cuando el cambio no es de un módulo sino del CSS de toda la app (sacar un paquete, el prefijo, el important), los e2e no alcanzan. Ahí comparamos los estilos computados de cada elemento visible entre el build de antes y el de después (detalle en "Lo que aprendimos").
  • Un módulo a la vez, siguiendo una única tabla de equivalencias. Si un caso no está en la tabla, se agrega ahí antes de inventarlo en una vista.

Fase 0 — Groundwork ✅​

Commits Fase 0: cambios en tooltips + notificaciones y Fase 0: segunda parte.

  • Toasts unificados. $bvToast y <b-toast> quedaron en cero. La API oficial es eventBus.$emit('show-toast', { type, message }, extra), que DefaultContainer.vue traduce a vue-toastification. De paso se arreglaron dos bugs: el handler de show-toast-once usaba una variable que no recibía (esos toasts nunca se mostraban) y el deduplicado de main.js mezclaba toasts sin customId.
  • El allowlist de @source pasó a ser real. Hallazgo: el import no tenía source(none), así que Tailwind autodetectaba todo el repo y la lista era decorativa. Se verificó que el CSS emitido fuera el mismo antes y después.
  • Auditoría de colisiones. Se borraron un bloque de Tailwind inerte en style.scss y un parche muerto de .collapse. La fuente real de colisiones era otra:
  • Se sacó @silstech/sils-design. Su bundle.css era un volcado viejo de estilos (con una copia de font-awesome adentro) que pisaba a Bootstrap sin prefijo. Las pocas reglas que realmente ganaban se pasaron a _custom.scss con los tokens del kit y el paquete se desinstaló. Hoy no hay componentes globales: un <Button> sin su import del kit queda como <button> nativo.

Fase 1 — Primitivos ✅​

Commit Fase 1: modales y sils-design. Se construyeron en el kit, con el patrón ya establecido (<script setup>, uiClass, cva, sin registro global), las piezas que faltaban:

  • Modal (ui/modal/): v-model, portal propio, foco atrapado, lock de scroll y ModalEvent cancelable como el de bootstrap-vue. Por defecto no cierra con click en el fondo y viene centrado.
  • Collapse / CollapseTrigger / CollapseGroup (ui/collapse/): estado local, sin bus de eventos ni ids globales.
  • Progress / ProgressBar (ui/progress/).
  • Directiva v-tooltip: reemplazo drop-in de v-b-tooltip, con los mismos modificadores. Es la única pieza registrada global.
  • Carousel / CarouselSlide (ui/slider/), que se terminó de construir en la Fase 3 para Kilómetros.

Cada pieza nueva entra a la galería /#/ui con todos sus estados, y tiene specs unitarios (Vitest) y e2e (design-system-*.spec.js).

Fase 2 — Wrappers de alto apalancamiento ✅​

Commit Fase 2. Migrar primero los componentes que usan decenas de vistas:

  • KitDataTable (src/components/shared/KitDataTable.vue): mismo contrato que <b-table> (slots cell(key)/head(key), row-clicked, row-selected, sort con .sync…) sobre los primitivos Table* del kit. El comparador de bootstrap-vue se replicó en src/shared/sorting-tools.js.
  • ABMComponent.vue: sus 14 <b-modal> pasaron al Modal del kit, con el estado en un objeto modals.* en vez de v-b-modal/$bvModal/bv::hide::modal.

Antes de tocarlos se escribieron abm-table-modals.spec.js y shared-table-infractions.spec.js. Esos specs encontraron bugs que ya existían (ver abajo).

Fase 3 — Módulo por módulo ✅​

Un módulo por vez, con su spec de caracterización antes:

  • views/ABMS (commits Fase 3: abms y Fase 3 migración de abms finalizada), en cuatro grupos (vehículos/catálogos, viajes/tramos/hoja de ruta, alertas/reglas, usuarios/roles).
  • views/Reports + components/reports (commit Reports), y <b-progress> en cero en toda la app.
  • Kilómetros (commit kilometers): el último consumidor de @silstech/sils-design.
  • components/shared y el resto de components/: filtros desplegables sobre Popover, GoogleMaps.vue, TableWithTimeline.vue, selectores de fecha, rankings, etc.
  • Vistas finales (commits Sigo con fase 3 … Fin fase 3), en seis grupos en paralelo: turnos y calendario de mantenimiento; mantenimiento online; índices (ICM, ISP, PC, IJL, ISF y sus detalles); incidentes y avisos; TechnicalMonitoring, Dashboard, Configuraciones y tableros; infracciones, video y el shell (DefaultContainer, DefaultViewLayout).

En el pase de cierre, las ~25 entradas sueltas de @source para vistas se reemplazaron por carpetas recursivas. Ese cambio destapó 6 utilidades que vistas ya migradas usaban y nunca se habían emitido (por ejemplo un gap-[14px] de RecorteVideos): justo el fallo silencioso del que avisa el allowlist.

Fase 4 — Limpieza final ✅​

Con la Fase 3 cerrada, lo que quedaba eran restos puntuales y el andamiaje de la convivencia. Commits Saco typehead, Fase 3 y 4, casi final, fase 4 parte 1, Eliminación completa de bootstrap, primer paso del último paso, fase 4 cont, Reemplazo de los prefijos tw, Fase 4 , cont y Fin paso 3. En orden:

PasoQué se hizo
Typeahead fueraSearchAutocomplete (components/shared/) reemplaza a vue-bootstrap-typeahead y vue-typeahead-bootstrap en 15 consumidores; el Autocomplete del kit cubre las listas en memoria. Librerías desinstaladas
Últimos restos de bootstrap-vueAdiciones al kit: Checkbox con indeterminate, TagsInput (reemplaza <b-form-tags>), Popover trigger="hover" y Modal que queda sin transform una vez abierto (así un hijo position: fixed vuelve a ir contra la ventana). Migrados el navbar del header, el último <b-modal>, el switch de ABMGroups y el cartel de capas de GoogleMaps
Archivos muertos42 archivos + 1 spec borrados, re-grepeando cada uno (imports, rutas, <component :is>, tests)
bootstrap-vue desinstaladoFuera de main.js, de los specs unitarios y de package.json
bootstrap desinstaladoEl CSS que la app todavía usaba quedó como subset compilado en src/assets/scss/_bootstrap-legacy.scss, en la misma posición de la cascada
Achicar el subsetDe 579 a 138 selectores, en grupos A–E (grilla, header, componentes, utilidades, colores de tema). La grilla y las cards de shared/Card.vue pasaron a _kit-grid.scss y _kit-card.scss; los colores de $theme-colors sin token exacto, a una paleta app-*. Hoy el parcial sólo tiene el reboot, :root y el print de elementos: ninguna clase
Sacar el prefijo tw:Primero se renombraron las clases propias que chocaban con utilidades; después un codemod sacó 24.172 tw: en 458 archivos
Sacar importantEl CSS de la app pasó a capas (base / components / utilities) y se resolvió cada diferencia hasta cero

El CSS total del build de producción pasó de 1.218.400 B (antes de sacar bootstrap-vue) a 880.650 B (después de sacar el prefijo).

Cada paso se verificó con npm run test:unit, build de producción y la suite e2e (smoke y completa); desde la desinstalación de bootstrap-vue, además, con un diff de estilos computados contra el build anterior. El resultado fue 0 diferencias reales en todos; lo único que aparece es ruido conocido (spinners en distinto punto de la animación, teselas de Google Maps, orden de llegada de los toasts).

Suite smoke de e2e​

Para poder correr los e2e después de cada paso sin esperar la suite entera, se marcó un subconjunto con el tag @smoke (commit Agrego suite smoke): 19 tests, 1–2 por área crítica, rápidos, sin depender de datos en vivo ni de test.skip condicionales. Corre en unos 4 minutos (3.6–3.7 min en las corridas de la Fase 4).

  • Local: npm run test:e2e:smoke (= playwright test --grep @smoke).
  • Jenkins: Jenkinsfile.e2e tiene el parámetro SUITE (smoke por defecto, full para la suite completa), además de E2E_ENV (testing, prod, preview).

Lo que aprendimos (y conviene saber antes de migrar)​

Breakpoints: los de Bootstrap, no los de Tailwind. Tailwind usa 640/1024/1280 y Bootstrap 576/768/992/1200. En código migrado se usan variantes arbitrarias (min-[576px]:, min-[768px]:, min-[992px]:, min-[1200px]:, y max-[992px]: para "mobile"). Nunca sm:/md:/lg:. max-[992px]: coincide exacto con el isMobile del store.

Gutters exactos. Un <b-row> es flex flex-wrap -mx-[15px] y cada columna relative w-full px-[15px] + su ancho en %. Reemplazarlo por gap-* "porque se ve igual" hace que la última columna salte de línea en otros anchos. Ojo también con el espaciado: mt-3 de Bootstrap (1rem) no es el mt-3 de Tailwind, es mt-4.

Caracterización e2e antes de cada módulo. Los specs escritos antes de migrar detectaron regresiones propias (por ejemplo, las tarjetas de Reportes que dejaron de mostrar "Ver Reporte" por cómo Vue 2 pasa atributos al Button del kit, o el ranking de ICM/ISP que dejó de encogerse dentro de su card porque .card-body traía min-height: 1px) y también bugs que ya existían: la búsqueda de Transportistas distinguía mayúsculas, el Histórico quedaba con el spinner colgado ante un 403, la "X" de borrar en los modales de ICM/ISP/Aprobadas nunca aparecía, la fecha de ISP ordenaba como texto, y había ids de collapse repetidos entre componentes. Los que no eran de la migración quedaron anotados en el plan para corregirlos aparte.

Los specs también se engañan solos. Un toHaveCount(0) sobre .spinner-border o .text-warning pasa siempre cuando esa clase deja de existir. Conviene buscar por rol o texto visible (getByRole('status'), getByText('*')), que es lo que ve el usuario.

Para el CSS global, la red es el diff de estilos computados. Sacar Bootstrap, el prefijo o el important no rompe ningún test: cambia un margen o un color en alguna vista. Lo que funcionó fue servir los dos builds (antes y después) en paralelo con vite preview, recorrer /login + 43 rutas a 1440px y 10 a 390px con el usuario de e2e, y comparar unas 30 propiedades computadas de cada elemento visible (62 al achicar el subset de Bootstrap, incluyendo ::before/::after). En los pasos más delicados también con desplegables y modales abiertos y con hover/foco sobre ~200 controles. Cada diferencia que no fuera ruido conocido había que explicarla.

El diff destapa fugas que dependen de la sesión. Dos ejemplos: ISPDetail.vue y PCDetail.vue tenían en un <style> sin scope .bg-alarmas { background-color: transparent !important }, que después de visitar esas rutas apagaba todos los bg-alarmas de la app; y el .block global de WDBlock le cambiaba el ancho a unos <label> de ABMCustomAlerts después de pasar por Conducción Nocturna. Con sesión limpia no se ven; por eso conviene correr el diff en el mismo orden antes y después, y sospechar de todo <style> sin scoped.

Sin prefijo, ninguna clase propia puede llamarse como una utilidad. Con tw: daba igual tener una clase propia relative, block, grow, cursor-pointer o text-ellipsis. Sin prefijo, Tailwind ve esos nombres en los archivos escaneados y emite también la utilidad, que se suma (o pelea) con el CSS propio. Antes de sacar el prefijo se midieron los choques con el mismo compilador de Tailwind que usa el build y se renombraron (cursor-pointer → clickable-cursor, relative → pos-relative, block-N → wd-block-N…). Caso especial: KitDataTable acepta en thClass/tdClass clases de Bootstrap (text-center, pl-2, w-50…) como API; las sigue traduciendo a utilidades, pero ya no las emite al DOM, porque sin prefijo pl-2 sería una utilidad real con otro valor.

Sacar important: el CSS sin capa le gana a las capas. En CSS con cascade layers, una regla sin capa le gana a cualquier regla en capa, sin importar la especificidad. Con las utilidades !important eso no se notaba; sacando el important sin más aparecían 20.718 diferencias. El orden quedó theme < base (reboot, reset de .silsx-root, Font Awesome) < components (_kit-grid, _kit-card, _layout) < utilities < sin capa < inline: con eso bajaron a 1.512, y el resto se resolvió caso por caso (declaraciones muertas en <style> que la utilidad ya pisaba, reglas de componente movidas a @layer components, y el modificador ! sólo contra CSS que no se puede tocar). Regla práctica: no duplicar en un <style> lo que ya fija una utilidad del mismo nodo, porque ahora gana el <style>.

v-show + utilidad de display en el mismo nodo. Con las utilidades !important, v-show ponía display: none inline, pero tw:flex ganaba y el bloque nunca se ocultaba, sin ningún error. Desde que se sacó important el inline vuelve a ganar, pero la regla del README se mantiene: un flex! o un display !important de otro CSS lo vuelve a romper. El v-show va en un wrapper, o se alterna flex/hidden.

Las directivas de permisos tenían el mismo problema. v-allowed-roles, v-required-views y compañía ocultaban con el.style.display = "none", que perdía contra un Button del kit: botones como "Eliminar infracciones" quedaban visibles sin permiso. Se arregló en src/directives.js con setProperty('display', 'none', 'important'), que sigue ganando con la cascada nueva.

.silsx-root button vs. los botones de Bootstrap. Mientras convivían, el reset del wrapper (padding 0, radio 0) le ganaba a .btn por especificidad: envolver un contenedor que todavía tenía <b-button> dejaba esos botones sin padding ni bordes redondeados. Lo mismo con h1–h6, p y label crudos, que pierden margen adentro del wrapper (eso sigue vigente).

e2e contra la rama, no contra staging. Los specs apuntan a staging por defecto (.env.e2e.local usa https://test.silsx.com). Para validar un cambio que todavía no está desplegado hay que pisarlo con E2E_BASE_URL=http://localhost:8080: varios tests del smoke (Aprobación de Infracciones, video wall, modal de la galería, toasts) fallaban o se salteaban contra staging y pasaban contra la rama migrada. Un smoke rojo contra staging puede significar sólo que el cambio todavía no está desplegado ahí.

Cómo migrar un archivo (resumen)​

La guía completa está en src/components/ui/README.md ("Migrar una vista" y "Migrar desde Bootstrap"). Con las convenciones de hoy:

  1. Antes de tocar, asegurarse de que haya un spec e2e que cubra el comportamiento de la vista. Si no hay, escribirlo y correrlo contra el código actual.
  2. Las utilidades de Tailwind van sin prefijo (flex, mt-4, bg-accent). Un tw: copiado de una rama vieja no emite nada.
  3. Verificar que el archivo esté bajo un @source de src/assets/css/ui.css: views, components, containers y layouts ya están cubiertos; cualquier otro archivo que use utilidades (por ejemplo un .js de shared/) se agrega ahí.
  4. No nombrar clases propias como utilidades (block, relative, grow, text-center, transition…): Tailwind emitiría también la utilidad.
  5. Respetar las capas: una regla de <style> sin capa le gana a una utilidad del mismo nodo. Si una clase propia tiene que perder contra las utilidades que le pasa el consumidor, va en @layer components { … }; si repite lo que ya pone una utilidad, se borra. El ! (flex!) sólo como último recurso, con un comentario de contra qué compite.
  6. Envolver en class="silsx-root bg-transparent" e importar explícitamente cada componente del kit que aparezca en el template.
  7. Convertir con la tabla de equivalencias: grilla con los gutters y breakpoints de Bootstrap, colores siempre a tokens (nunca hex), class para layout y ui-class para la pinta. Una clase de Bootstrap escrita hoy no pinta nada.
  8. No romper hooks: conservar id, ref, data-* y clases que usen otros archivos o los specs.
  9. Correr los greps del "Checklist por archivo" del README, npm run build, npm run test:unit y los e2e del módulo (como mínimo npm run test:e2e:smoke), contra la rama local.
  10. Revisión visual en 1440px y 800px (los dos lados del corte de mobile). Si el cambio toca CSS global, diff de estilos computados antes/después.