diff --git a/optimize-bundle.md b/optimize-bundle.md new file mode 100644 index 000000000..47c2854f1 --- /dev/null +++ b/optimize-bundle.md @@ -0,0 +1,267 @@ +# 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 | ⏳ En progreso | Fase 0 hecha (−4.5 KB gz/página, doble-carga eliminada); 94 recipes aún en el agregado | + +--- + +## 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): dónde va el import · tokens cross-portal · **decidir `@layer`** | medio | build + navegador | +| **2** | Partials compartidos (`menu-indicator`) + primitivas layout se quedan en foundation; auditar colisiones a igual especificidad | medio | eidos-lint | +| **3** | Propagar a los ~94 restantes **por lotes** por familia (overlays·pickers·menús·forms·data·tipografía); nunca en cascada | medio | build+lint+visual/lote | +| **4** | `index.css` = foundation-only; medir CSS/página | bajo | build | +| **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. + +## 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..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 +```