# Bundle audit & optimization — activeUIX
> Medición realizada el **2026-06-05** sobre el build de producción real
> (`npm run build`, `adapter-static`, 145 rutas). Branch `active-uix`.
> Todas las cifras "medidas" salen de `build/_app/immutable`. Las cifras de
> otros frameworks son **públicas de referencia** (no medidas en este repo) y
> están marcadas como tales.
---
## TL;DR
**El JS está sano y bien tree-shakeado. El CSS es donde nos estamos
excediendo** — y es un problema *estructural* (un agregado monolítico), no de
"demasiado código".
| Eje | Estado | Cifra clave |
| --- | --- | --- |
| JS por página | ✅ Sano | entry 10.4 KB gz · runtime ~35 KB gz · componente mediana 7.3 KB gz |
| CSS por página | ⚠️ Exceso | **113.5 KB gz / 851 KB raw** en *toda* página `/uix` |
| Code-splitting JS | ✅ Correcto | color engine, sema y componentes se cargan bajo demanda |
| Code-splitting CSS | ✅ Resuelto | 93 recipes code-split; index.css foundation-only. **113.5 → 54.4 KB gz/página** (− 59 KB). Pendiente: Fase 5 (podar base.css) |
---
## Evaluación, correcciones y plan (2026-06-06)
> Verificado contra el build real (`build/_app/immutable`) y el código. **Las
> cifras del doc son exactas** — 104 `@import`, 10 auto-import, 851 KB raw /
> 113.5 KB gz monolito, base.css 340 KB, words.css 56 KB, foundation 52 KB gz:
> todo confirmado. Dos matices cambian la lectura.
### Corrección 1 — los 10 "code-splittable" estaban DOBLE-CARGADOS
Los 10 que auto-importan su `.css` **seguían también en `index.css`** ⇒ su CSS
viajaba dos veces (probado: `badge` en su chunk `badge.*.css` **y** dentro del
monolito de 851 KB). Hicieron el paso 1 (auto-import) pero no el paso 2 (quitar
de `index.css` ). No eran el ejemplo bueno — eran media migración que **sumaba**
bytes. → **Resuelto en la Fase 0** (abajo).
### Corrección 2 — el suelo real es `base.css`, y lleva contratos de token por componente
`base.css` (340 KB raw / **52 KB gz** ) es el **41 % del monolito** y la pieza
única mayor. No es foundation pura: contiene los **contratos de token por
componente** (`[data-badge]`, `[data-card]` , `--tag-group` , `--file-upload` …).
Probado: tras quitar `badge.css` del agregado, `[data-badge]` sigue 8× en el
monolito — **son de `base.css`** (8 en base = 8 en monolito). Por tanto, aun con
el split perfecto **toda página paga ~52 KB gz** , y ese suelo solo baja podando
`base.css` (Fase 5), no partiendo recipes. El doc lo trataba como inamovible; es
la siguiente frontera.
---
## Plan de ejecución
Mecánica por componente = **2 pasos** : (1) `import './x.css'` en su `.svelte` ;
(2) quitar su `@import` de `index.css` . El riesgo real no es eso — es el **orden
de cascada** (hoy `index.css` ordena deterministamente; `menu-indicator.css`
debe cargar *después* de los menús). Al code-splittear, el orden lo dicta qué
monta la página. Ese eje decide la Fase 1 (`@layer` o no).
| Fase | Qué | Riesgo | Verifica |
| --- | --- | --- | --- |
| **0** ✅ | Des-duplicar los 10 (quitar de `index.css` ; ya auto-importan) | nulo | hecho — ver resultado |
| **1** ✅ | Piloto `dialog` (compuesto + portalizado): import en la raíz · tokens cross-portal OK · ** `@layer` descartado** | medio | hecho — ver resultado |
| **2** ✅ | Partials compartidos (`menu-indicator` order-independent por especificidad) + primitivas layout en foundation | medio | hecho |
| **3** ✅ | Propagar a los 83 restantes **por lotes** por familia; couplings arreglados (toggle-group, pickers) | medio | hecho |
| **4** ✅ | `index.css` = foundation-only (− 59 KB gz/página) | bajo | hecho |
| **5** | (track aparte) podar `base.css` a roles tematizados + calendarios no-gregorianos dynamic import | alto | build |
**Cross-coupling a vigilar** (recipes agregados que referencian un componente
splitteado): auditado para los 10 — único caso `words.css → [data-textarea]` , y
es **seguro** porque `words-block-panel.svelte` monta el `TextArea` de eidos (su
chunk carga el recipe). Repetir esta auditoría en cada lote de la Fase 3.
### Fase 0 — resultado medido
- `index.css` : 104 → **94** `@import` . Monolito **850 879 → 814 420 raw**
(− 36 KB) · **113 489 → 108 991 gz** (− 4.5 KB) — en *toda* página `/uix` .
- Doble-carga **eliminada** : los 10 recipes ya solo viajan como chunk. image /
spinner / skeleton / password-field salen a **0** en el monolito; badge / card
dejan solo su contrato de token de `base.css` ; textarea deja solo la
referencia de `words.css` . Build verde, sin acoplamiento roto.
### Decisión `@layer` (Fase 1) — DESCARTADO
Evaluado con evidencia, no en teoría. **No se adopta `@layer`** :
- **0** usos actuales en eidos → sería un sistema nuevo sobre las 104 recipes.
- **16** `!important` en 5 recipes → `@layer` invierte su precedencia (en cascada
con capas, `!important` resuelve en orden de capa **inverso** ) ⇒ regresiones
silenciosas a auditar una a una.
- Cada recipe ya scopea a `[data-{component}]` (doctrina CLAUDE.md) → las
colisiones cross-componente a igual especificidad están **estructuralmente
prevenidas**. La única dependencia de orden real (`menu-indicator`, partial
compartido) se resolvió por **especificidad** (Fase 2), no por orden.
El scoping por `data-*` ya da el determinismo que daría `@layer` , sin su coste
(envolver 104 ficheros) ni su riesgo (invertir 16 `!important` ). `@layer` queda
como **escape hatch documentado** si algún día aparece una colisión que el
scoping no resuelva.
### Fase 1 — resultado medido (piloto `dialog`)
- **Patrón fijado para compuestos**: el `import './x.css'` va en el `.svelte`
**raíz** (`dialog.svelte`). El barrel (`index.ts`) importa la raíz, así que el
CSS carga al usar cualcomponente parte (Trigger, Content portalizado, …).
- `index.css` : 94 → **93** `@import` . Monolito **814 420 → 805 692 raw**
(− 8.7 KB) · **108 991 → 107 843 gz** (− 1.1 KB).
- Recipe fuera del monolito: `[data-dialog-content]` / `[data-dialog-overlay]`
= **0** ; viaja en su chunk. El token `--dialog-content-bg` se queda en
`base.css` (global).
- **Cross-portal verificado en navegador**: el `[data-dialog-content]`
portalizado (fuera de `[data-dialog]` ) sigue **completamente estilado**
(bg `oklch(0.285 0 0)` , radius 16px, sombra, padding 20px) — tokens de
`base.css` global + recipe del chunk. El portal no rompe nada.
### Fases 2– 4 — barrido completo (COMPLETADO)
83 recipes splitteados en 5 lotes (build+navegador+commit por lote). `index.css`
quedó **foundation-only** : `base.css` + `archetypes` + `events` + las 10
primitivas de layout (box/flex/grid/stack/group/wrap/container/section/aspect-
ratio/auto-grid — se quedan: uso ubicuo + las recipes layerean sobre ellas).
| Lote | Qué | Δ monolito (gz) |
| --- | --- | --- |
| 1 (35) | tipografía·inline·overlays·feedback·misc | 107.8 → 90.4 KB |
| 2 (7) | menús + `menu-indicator` order-independent | 90.4 → 86.5 KB |
| 3 (15) | form controls | 86.5 → 74.7 KB |
| 4 (16) | familia de pickers | 74.7 → 63.5 KB |
| 5 (10) | data·service·`words` | 63.5 → **54.4 KB** |
**Resultado final: monolito 850 879 → 335 100 raw · 113 489 → 54 429 gz** —
**− 515 KB raw / − 59 KB gz en TODA página**. Una página de 5 componentes ahora
paga ~54 KB foundation + sus chunks (dialog 1.6 KB, toggle 0.45 KB, …) en vez de
113.5 KB fijos. El piso = `base.css` (52 KB gz), tal como predijo la auditoría.
**Patrón de acoplamiento (lección clave)** — un compuesto que **renderiza el
markup** de otro componente (vía partes soma o `import type` ) pero **no monta el
componente eidos** no arrastra su recipe. Hay que importarla explícitamente (leaf
primero, para que el compuesto la pueda sobreescribir). Casos encontrados y
arreglados, verificados en navegador:
- `toggle-group` → `toggle` (los ítems son DOM-equivalentes a `<Toggle>` por
identidad estructural). Sin el fix: botones crudos (bg `#f0f0f0` , borde outset).
- `date-picker` → `calendar` + `month-grid` + `year-grid` (vistas con `import
type`). Sin el fix: calendario en `display:table` crudo.
- `date-range-picker` → `range-calendar` ; `date-range-field` → `date-field` ;
`time-range-field` → `time-field` .
- `color/time/time-range-picker` → `slider` (usan partes soma Slider). Sin el fix:
thumb 0px.
- `picker-shell` → `popover` (contenido en un Popover soma).
Un compuesto que **monta** el componente eidos (p. ej. `words` monta Button/Icon/
NumberField/ColorPicker/TextArea) NO necesita el import — el recipe llega solo.
> **Coste residual aceptado**: si una página usa a la vez un compuesto y el leaf
> standalone (p. ej. date-picker + un Calendar suelto), el recipe del leaf viaja
> en 2 chunks (doble-carga menor, combo raro). Eliminarlo requeriría que el
> compuesto montara el componente eidos en vez de markup soma — refactor de
> componente, fuera del alcance de este split.
`@layer` siguió sin necesitarse: cero colisiones cross-componente en todo el
barrido (el scoping `[data-{component}]` lo confirma en la práctica).
- `check` 0 errores.
## Metodología
- `npm run build` → `adapter-static` , salida en `build/` .
- Medición del **cliente** (`build/_app/immutable`), que es lo que viaja al
navegador — no el build SSR.
- gzip calculado con `gzip -c` (proxy de lo que sirve un CDN; brotli sería
~15-20 % menor todavía).
- "Por página" = entry + runtime compartido + chunk único del nodo de ruta +
su CSS. El total del `build/` (11 MB) es el **catálogo completo de 145
demos**, NO lo que sirve una app real — no usar esa cifra como referencia.
---
## JS — sin exceso
| Qué | gzip | Cuándo carga |
| --- | --- | --- |
| `entry` (app + start) | **10.4 KB** | toda página |
| Runtime Svelte/Kit (chunk compartido) | ~35 KB | toda página |
| Chunk único por componente (mediana) | **7.3 KB** | solo su página |
| Chunk único más pesado (demo command/words) | 72 KB | solo esa demo |
| Color engine (`build-scheme` / OKLCH / APCA) | 27.8 KB | **solo páginas de tema** (dynamic import) |
| Sema + a11y | 25.7 KB | bajo demanda |
| Sistemas de calendario (`$libs/days`) | ~67 KB (2 chunks) | páginas con fecha/calendario |
**Por qué está bien:**
- El barrel de soma (`src/uix/soma/index.ts`) expone **solo** `Soma` y obliga a
importar componentes por subpath explícito (`$soma/components`) → tree-shaking
real, sin superficie duplicada.
- El color engine se carga por **dynamic import** solo en las páginas de tema —
confirmado: `deriveScheme` /`generateScale`/`oklch` NO están en el chunk de
entrada ni en el runtime compartido.
- Componentes code-split: una app que use 5 componentes no arrastra los 110.
**Conclusión: no tocar el JS de soma/sema/color. Ya está bien.**
---
## CSS — el exceso real
Un ** único** stylesheet:
```
index.< hash > .css → 851 KB raw → 113.5 KB gzip
```
…cargado en **toda página `/uix`** porque el layout importa
`@/uix/eidos/index.css` .
### Causa raíz: agregado monolítico
`src/uix/eidos/index.css` hace `@import` de **104 de 109** componentes:
| | Componentes |
| --- | --- |
| Auto-importan su `.css` desde el `.svelte` | **10** (badge, card, image, password-field, s-text, s-text-virtual-list, scroll-frames, skeleton, spinner, textarea) — antes **también** en `index.css` ⇒ doble-carga; quitados del agregado en Fase 0 |
| Agregados en `index.css` | **104** → **94 tras Fase 0** |
```
CSS de componentes alcanzable SOLO vía index.css:
723 KB raw ≈ 94 KB gzip
```
> Una app que renderice 3 componentes paga el CSS de los 104. Eso es **más peso
> que todo el JS de la página junta**, y casi todo es CSS de componentes que
> nunca se montan.
### Desglose del stylesheet
| Pieza | raw |
| --- | --- |
| `generated/base.css` (escalas de color × roles × light/dark) | 337 KB |
| 104 CSS de componente agregados | 723 KB (resto del archivo tras dedupe de `@import` ) |
| `words.css` (el más pesado individual) | 55 KB |
| `date-range-picker` , `color-picker` , `drawer` , `range-calendar` … | 18– 25 KB cada uno |
---
## Comparativa con otros frameworks
> ⚠️ **Cifras de referencia públicas**, no medidas en este repo. La comparación
> *exacta* es imposible (distinto scope, distinto modelo de estilado), pero
> sirve para situar el orden de magnitud. Lo que importa es el **modelo de
> distribución**, no el número absoluto.
### JavaScript (runtime + un componente típico, gzip aprox.)
| Framework | Modelo | JS por componente (gz) | Notas |
| --- | --- | --- | --- |
| **activeUIX (soma)** | Svelte 5, headless + eidos | ** ~7 KB** (mediana) + ~35 KB runtime compartido | Runtime Svelte se amortiza; componentes code-split |
| Radix UI (primitives) | React, headless | ~10– 20 KB por primitiva + React (~45 KB) | Tree-shakeable por paquete (`@radix-ui/react-*`) |
| Chakra UI v2 | React, styled-system | Pesado: runtime emotion + theme (~100 KB+ base) | Runtime CSS-in-JS; conocido por bundle grande |
| Chakra UI v3 / Ark UI | React, headless (Zag.js) | ~15– 30 KB por componente + state machines | Más ligero que v2 |
| MUI (Material UI) | React, emotion | ~80– 150 KB base + emotion runtime | El más pesado de la lista |
| shadcn/ui | Copia código a tu repo | Solo lo que pegas (~5– 15 KB) | No es dependencia; cero runtime propio |
| Bits UI / Melt | Svelte, headless | ~5– 10 KB por componente | Comparable a activeUIX |
**Lectura:** en JS, activeUIX juega en la liga ligera (Svelte + headless +
code-split), comparable a Bits UI / Radix y muy por debajo de Chakra v2 / MUI.
El runtime de Svelte (~35 KB) se amortiza a partir de un par de componentes y no
crece con React-sized overhead.
### CSS / estilado (el eje donde difiere el modelo)
| Framework | Modelo de CSS | Coste típico |
| --- | --- | --- |
| **activeUIX (eidos)** hoy | Stylesheet agregado global | **113 KB gz fijo** en toda página ⚠️ |
| **activeUIX (eidos)** propuesto | Foundation + CSS por componente | ~50 KB foundation + lo que uses |
| Radix Themes | Stylesheet global (`@radix-ui/themes/styles.css`) | ~30– 45 KB gz (todo el sistema) |
| Chakra v2 | CSS-in-JS (emotion), runtime | 0 KB estático, pero coste JS/runtime alto |
| Tailwind (shadcn) | Utilidades purgadas | ~5– 15 KB gz tras purge (solo clases usadas) |
| MUI | CSS-in-JS (emotion) | Runtime, similar a Chakra |
| Bits UI | Sin estilos (BYO) | 0 KB (tú pones el CSS) |
**Lectura:** el modelo "stylesheet global" no es malo per se — Radix Themes
hace lo mismo. El problema es la **magnitud** : el agregado de activeUIX (113 KB
gz) es ** ~2.5– 3× el de Radix Themes** (~40 KB) porque incluye los 104
componentes sin posibilidad de cargar solo un subconjunto. Tailwind/shadcn
ganan en CSS precisamente porque purgan lo no usado — que es exactamente lo que
propone la recomendación nº 1.
---
## Recomendaciones (priorizadas)
### 1. Romper `eidos/index.css` en CSS por-componente — **alto impacto, esfuerzo medio**
Que cada `*.svelte` importe su propio `.css` (como ya hacen 10 componentes).
Vite los code-splittea automáticamente y la app solo carga el CSS de lo que
renderiza. `index.css` quedaría solo con la **foundation** : `generated/base.css`
+ `archetypes.css` + `events.css` + primitivas de layout (box/flex/grid/stack…).
**Efecto esperado:** convierte "113 KB fijos siempre" en
"foundation (~50 KB) + CSS de los componentes que montas". Una app de 5
componentes pasaría de 113 KB → ~55– 65 KB gz de CSS.
**Plan seguro (probar uno, verificar, propagar):**
1. Migrar **un** componente (p. ej. `dialog` ): añadir `import './dialog.css'` en
su `.svelte` , quitar su `@import` de `index.css` .
2. Build + verificar en navegador que el estilo sigue intacto y que el CSS de
dialog ya NO está en el stylesheet global (sale como chunk propio).
3. Medir el delta. Si cuadra, propagar a los 103 restantes.
> Riesgo a vigilar: componentes que portalizan contenido (popover/dialog/drawer/
> menús) — sus tokens custom no heredan a través del portal. La foundation debe
> seguir conteniendo los tokens globales que esos descendientes portalizados
> necesitan (ver feedback `cross_portal_css_vars`).
### 2. Auditar sistemas de calendario no-gregorianos — **medio impacto**
~67 KB gz repartidos en 2 chunks incluyen gregorian/islamic/buddhist/japanese/
persian/ethiopic. Si la mayoría de apps solo usan gregoriano, cargarlos por
dynamic import por sistema desde `$libs/days` recorta ese peso de las páginas de
fecha/calendario.
### 3. No tocar el JS de soma/sema/color — **ya está bien**
Code-split y tree-shakeado correctamente. No sobre-optimizar.
---
## Apéndice — comandos de medición
```bash
npm run build
# Total cliente
du -sh build/_app/immutable
# JS vs CSS raw
find build/_app/immutable -name '*.js' -printf '%s\n' | awk '{s+=$1} END{print s/1024" KB JS"}'
find build/_app/immutable -name '*.css' -printf '%s\n' | awk '{s+=$1} END{print s/1024" KB CSS"}'
# El stylesheet monolítico, raw + gzip
for f in $(find build/_app/immutable -name '*.css' -printf '%s %p\n' | sort -rn | head -1 | awk '{print $2}'); do
echo "raw $(stat -c%s "$f") gzip $(gzip -c "$f" | wc -c)"
done
# Self-import vs agregado
grep -rl "import '\./.*\.css'" src/uix/eidos/components --include='*.svelte' | wc -l
grep -c "@import './components" src/uix/eidos/index.css
```