22 KiB
Auditoría de herencia y escalado — capa Eidos / Theming
Fecha: 2026-06-27 · Alcance:
src/uix/eidos(foundation + recipes + 80 componentes). Excluidos (tracks WIP, regla de proyecto):words/**,palabras/**,chronos/**.Método: lectura directa de la foundation (
lib/render-css.ts,lib/primitives/static.ts,lib/config-types.ts), de la doctrina (THEMING.md,SCALING_RFC.md,SHAPE_ENGINE_RFC.md,STRUCTURE_ENGINE_RFC.md,TYPOGRAPHY_ENGINE_RFC.md) y de los recipes (lib/recipes/base.ts) + CSS por componente. Cada hallazgo lleva evidenciaarchivo:línea. No se usaron agentes; los datos provienen de grep/lectura verificables.
0. Las 5 reglas auditadas
| # | Regla (enunciado del usuario) | Veredicto global |
|---|---|---|
| R1 | Los componentes deben escalar con el nivel de escala del tema (data-scaling). |
✅ Cumplida en lo esencial (vía tokens) — 2 excepciones puntuales. |
| R2 | Los componentes deben reaccionar a la densidad del tema (data-density). |
✅ Cumplida en lo esencial (vía tokens) — mismas excepciones. |
| R3 | Un componente sin size propio debe heredar el size de su padre. |
❌ Incumplida de forma sistémica — ~todos hardcodean size='md'. |
| R4 | Los sub-componentes internos deben escalar con el size (tipografía, controles, etc.). |
🟡 Parcial — geometría + tipografía sí; el radio NO. |
| R5 | Las formas de esquina/radios deben heredar/escalar (radios concéntricos). | ❌ Incumplida — radio desacoplado del size; concéntrico casi sin adoptar. |
Tesis de la auditoría: la disciplina de tokenización del framework hace que
R1 y R2 se cumplan casi gratis (todo lo que es un token hereda --scaling y la
densidad). Donde el sistema falla es en las tres reglas de herencia/coherencia
de tamaño y forma (R3, R4-radio, R5): no existe herencia ambiental de size,
el radio no forma parte de la cascada [data-size] salvo en toggle, y
conviven tres filosofías de radio incompatibles sin doctrina única.
1. Cómo la foundation implementa cada eje (el contrato)
1.1 Scaling (zoom global data-scaling 90–110)
--scaling se emite en :root (render-css.ts:708 appendScalingDeclarations)
y multiplica las métricas px en su calc:
font-size→calc(<size> * var(--scaling))(render-css.ts:1028).icon-size,blur→appendScaledMetricDeclarations(render-css.ts:260,266).space,control-height→calc(value * var(--density-*-scale) * var(--scaling))(render-css.ts:162-168).- NO escalan:
radius,border-width,shadow,z,line-height(ratio),opacity,motion(decisión deliberada,SCALING_RFC.md:34-36).
→ Consecuencia: cualquier componente que dimensione con var(--font-size-*),
var(--icon-size-*), var(--space-*), var(--control-height-*) (o tokens de
recipe que los referencian) escala con --scaling automáticamente. Solo los
literales px/rem quedan fuera.
1.2 Densidad (data-density compact/comfortable/spacious)
--density-space-scale + --density-control-scale (static.ts:169 STATIC_DENSITY)
multiplican --space-* y --control-height-* (render-css.ts:162-168). La
tipografía no lleva densidad por diseño (THEMING.md:687-694, paridad Radix).
→ Consecuencia: los componentes reaccionan a densidad si su padding/gap salen
de --space-* y sus alturas de --control-height-*. Confirmado en los recipes:
button.height-md → var(--control-height-md), field, combobox, etc.
(base.ts:841,1065,1222).
1.3 Size (prop size discreto xxs…xxl)
La foundation emite un bundle --size-{k}-{control-height,font-size,icon-size, padding-inline,padding-block,gap,radius} (render-css.ts:1328 appendSizeDeclarations,
desde STATIC_SIZE en static.ts:393). PERO ese bundle está huérfano: 0
consumidores (THEMING.md:605-606). En su lugar, cada recipe re-declara su
propia familia de tokens por size (--{c}-height-{size}, --{c}-font-size-{size},
…) y el CSS rebindea --_{c}-* en bloques [data-size='X']. El patrón canónico
(piloto) es toggle (toggle.css:64-98).
1.4 Radio / forma (shape)
- Escala
--radius-{none…full}(4/6/10/16/20px + pill) (static.ts:40). STATIC_SIZEmapea cada size a un radio (xxs/xs→sm, sm/md→md, lg→lg, xl→xl, xxl→xxl) (static.ts:393-457) — pero ese mapeo viaja en el bundle--size-*huérfano, así que nadie lo consume.- Concéntrico:
[data-shape-nest]derivaborder-radius: max(0px, var(--shape-outer-radius) − var(--shape-nest-gap))(render-css.ts:1153-1159,SHAPE_ENGINE_RFC.mdFase 2). - Familias
data-shape='rounded|continuous|cut|scoop'+--shape-smoothing(superelipse) (render-css.ts:1140-1151).
2. Hallazgos por regla
R1 — Scaling · ✅ cumplida en lo esencial · severidad de los huecos: BAJA
Todo el dimensionado pasa por tokens scaling-coupled, así que el grueso cumple.
Excepciones reales (literales que no siguen --scaling):
| Evidencia | Problema |
|---|---|
carousel.css:128-142 |
--_carousel-indicator-size: 0.375rem … 0.75rem — literal rem por size, no token. Los puntos de paginación no siguen --scaling (sí el zoom de navegador, pero no el eje del tema). |
base.ts:1030-1031 (meter/progress) |
height-md: '6px', height-lg: '8px' — grosor de barra en px fijo (no escala). Menor: es grosor visual, discutible. |
Hairlines 1px/2px (separadores, indicadores) — button.css:245, command.css:153, navigation-menu.css:288, etc. |
Legítimo: un separador de 1px no debe escalar. No es violación. |
R2 — Densidad · ✅ cumplida en lo esencial · severidad: BAJA
Padding/gap salen de --space-* y alturas de --control-height-* (ambos
density-coupled). Confirmado en recipes (base.ts:341-352,481-525,841-842,1065-1076).
Mismas excepciones que R1 (el rem de carousel tampoco lleva densidad; grosor de
barra fijo). La tipografía no escala con densidad a propósito (correcto).
R3 — Herencia de size del padre · ❌ incumplida sistémica · severidad: ALTA
No existe herencia ambiental de size. Prácticamente todos los
componentes hardcodean size = 'md' como default (grep: 90+ wrappers
.svelte con size = 'md'). Un componente sin size no mira a su padre:
fija md.
Las únicas propagaciones contenedor→parte (las excepciones que confirman la regla):
| Mecanismo | Evidencia | Alcance |
|---|---|---|
dialog/context.ts → Dialog.Close deriva el size del dialog (cap en su subset) |
dialog/context.ts:21-31, dialog-close.svelte:49 |
solo Dialog→Close |
list-surface-context.ts — paneles flotantes anidados igualan el size de la superficie raíz |
lib/list-surface-context.ts:14-30 |
menús/popups anidados (SubContent, listbox combobox) |
toggle-group/context.ts — propaga variant/size a los items |
(contexto de toggle-group) | solo items de toggle-group |
Casos que deberían heredar y NO lo hacen:
- Un
<Select>/<Switch>/<Checkbox>junto a hermanoslgo dentro de un bloque sizeado: salenmd. <Form size="lg">no propaga el size a los<Field>/ controles anidados por contexto ambiental — el recipeformsolo sizea su propio gap + sus acciones (form.css:25-51,118-137), no inyecta unsizea Fields arbitrarios.Dialog.*/Drawer.*solo propagan al Close; el resto de partes internas no.
Causa raíz: no hay un token/contexto --ui-size ambiental ni un
SizeContext genérico que un control lea cuando su prop size es undefined.
El default es un literal 'md', no inherit.
R4 — Escalado interno con el size propio · 🟡 parcial · severidad: MEDIA
Lo que SÍ escala (verificado en ~35 componentes): por cada [data-size='X']
el componente rebindea altura, padding, gap, font-size, icon-size y sus
dimensiones específicas (track/thumb/indicator/control-size/swatch/preview/day-size…).
Ejemplos confirmados: button.css:111-154, field.css:56-78, select.css:55-80,198-220,
switch.css:35-61, slider.css:10-36, radio-group.css:96-114,212-230,
tabs.css:23-42, tag-group.css:20-41, toolbar.css:30-55, pagination.css:15-34,
accordion.css:42-150, calendar.css:28-47, card.css:95-129, checkbox.css:34-62,
combobox.css:109-135,293-315, feed.css:45-68, editable.css:23-49,
file-upload.css:17-31, avatar.css:50-73, banner.css:38-54, tooltip.css:46-56.
→ La tipografía interna y la geometría de sub-controles escalan correctamente.
Lo que NO escala con el size:
- El radio — ausente de casi todas las cascadas
[data-size](ver R5). Solotogglelo rebindea. - Decoraciones con literal —
carouselpuntos en rem (R1).
R5 — Radio / forma · ❌ incumplida · severidad: ALTA
5.a — El radio NO escala con el size. En casi todos los componentes el radio
es un token fijo, idéntico en todas las tallas:
| Componente | Radio | Evidencia |
|---|---|---|
field (toda la familia de campos) |
var(--field-control-radius) único |
field.css:191 |
select |
--select-trigger-radius / --select-content-radius fijos |
select.css:39,189 |
tabs |
--tabs-trigger/content/list/pills-*-radius fijos |
tabs.css:72,115,166,174,198 |
pagination |
--pagination-control-radius fijo |
pagination.css:52 |
tag-group |
--tag-group-item-radius fijo |
tag-group.css:70 |
stepper, toolbar, combobox, tooltip, listbox, menús, calendars |
var(--radius-*) fijo |
grep border-radius: var(--radius- |
Único componente que acopla radio↔size: toggle —
--_toggle-radius: var(--toggle-radius-{size}) rebindeado por [data-size]
(toggle.css:7,70,79,88,97). Es el patrón correcto según STATIC_SIZE.
5.b — Incoherencia de filosofía: 3 modelos de radio coexisten sin doctrina.
- size-coupled:
toggle(radio sigue el size). - prop
roundedindependiente:buttonycarddeclaran--{c}-radius-{sm..xl}pero los cablean adata-rounded(defaultmd), no adata-size(button.css:27,216-220;card.css:40,133-134). → un<Button size="xl">conserva esquinasmd. - token fijo único: todos los demás (sin
rounded, sin acoplar a size).
El primitivo canónico que zanjaría esto — el campo radius por size de
STATIC_SIZE y el token --size-{k}-radius (render-css.ts:1344) — está
huérfano (0 consumidores) (THEMING.md:605). El contrato existe pero nadie lo usa.
5.c — Radios concéntricos: adopción casi nula. [data-shape-nest] /
--shape-outer-radius solo aparece en 11 archivos (card-group, combobox,
select, command, menubar + sus items). Las superficies anidadas más comunes —
campo dentro de Form, card dentro de panel, contenido de Dialog/Drawer,
calendar dentro de un picker, items de la mayoría de menús — no derivan su
radio del padre. El concéntrico es opt-in por diseño (SHAPE_ENGINE_RFC.md:80),
pero su penetración real es marginal.
3. Incoherencias estructurales transversales
- Bundle
--size-*huérfano + contradictorio. La foundation emite--size-md-font-size: 14px(appendSizeDeclarations), pero la regla viva es el 1:1 (md = 16px) re-declarado en cada recipe (THEMING.md:605-606). El sistema mantiene un contrato de tamaño que (a) nadie consume y (b) miente sobre los valores actuales. Es deuda que invita a drift. - Re-declaración del mapeo size→token en cada recipe. Como nadie consume
--size-{k}-*, cada componente repite a manoheight/px/gap/font/radiuspor size (decenas de tokens × 80 componentes). Un solo punto de verdad (consumir el bundle) eliminaría la duplicación — hoy el único guard contra drift es el test de "no literales px en font/icon" (THEMING.md:608-611), que no cubre padding/gap/radio. - Sin herencia ambiental de size (R3) ni de forma (R5.c): los dos ejes que el usuario espera "heredables por defecto" son justo los que el sistema trata como per-instancia con default literal.
4. Matriz por componente
Leyenda: ✅ cumple · 🟡 parcial · ❌ incumple · — no aplica (sin eje de
size/densidad). R1=scaling · R2=densidad · R3=hereda size del padre ·
R4=escalado interno con size · R5=radio escala/concéntrico.
4.1 Controles e inputs (tienen size)
| Componente | R1 | R2 | R3 | R4 | R5 | Notas |
|---|---|---|---|---|---|---|
toggle |
✅ | ✅ | ❌ md | ✅ | ✅ | Referencia: radio acoplado a size. |
button |
✅ | ✅ | ❌ md | 🟡 | ❌ | radio por rounded, no size. |
badge |
✅ | ✅ | ❌ md | 🟡 | ❌ | radio fijo / rounded. |
checkbox |
✅ | ✅ | ❌ md | ✅ | ❌ | radio del box fijo. |
radio-group |
✅ | ✅ | ❌ md | ✅ | ❌ | indicador circular (radio N/A), pero item radio fijo. |
switch |
✅ | ✅ | ❌ md | ✅ | ❌ | track/thumb son pills (radio full, OK). |
slider |
✅ | ✅ | ❌ md | ✅ | ❌ | track/thumb pills (OK). |
field (genérico) |
✅ | ✅ | ❌ md | ✅ | ❌ | --field-control-radius único. |
spin/number/css-field |
✅ | ✅ | ❌ md | ✅ | ❌ | comparten superficie spin-field. |
date/time/color-field |
✅ | ✅ | ❌ md | ✅ | ❌ | segmentos + swatch escalan; radio fijo. |
search/password-field |
✅ | ✅ | ❌ md | ✅ | ❌ | radio fijo. |
pin-input |
✅ | ✅ | ❌ md | ✅ | ❌ | celdas: font escala (cell-font-size-*), radio fijo. |
textarea |
✅ | ✅ | ❌ md | ✅ | ❌ | radio fijo. |
tags-input |
✅ | ✅ | ❌ md | ✅ | ❌ | radio fijo. |
editable |
✅ | ✅ | ❌ md | ✅ | ❌ | radio fijo. |
select |
✅ | ✅ | ❌ md | ✅ | ❌ | trigger+content escalan; radios fijos. |
combobox |
✅ | ✅ | ❌ md | ✅ | ❌ | usa concéntrico en items (data-shape-nest). |
rating-group |
✅ | ✅ | ❌ md | ✅ | — | iconos (estrellas); radio N/A. |
stepper |
✅ | ✅ | ❌ md | ✅ | ❌ | indicador/trigger/content radio fijo. |
4.2 Acciones compuestas (composición sobre Button/Toggle)
| Componente | R1 | R2 | R3 | R4 | R5 | Notas |
|---|---|---|---|---|---|---|
toggle-group |
✅ | ✅ | 🟡 ctx | ✅ | ❌ | hereda size/variant del grupo (contexto) — único buen caso de R3 en controles; items son Toggle → radio podría seguir el size pero se aplana en grupo. |
button-group |
✅ | ✅ | ❌ md | 🟡 | ❌ | radio en extremos via Button. |
split-button |
✅ | ✅ | ❌ md | 🟡 | ❌ | compone Button. |
fab |
✅ | ✅ | ❌ md | ✅ | ❌ | redondo (radio full, OK). |
menu-dial / onion-menu |
✅ | ✅ | ❌ md | ✅ | — | radial; radio full. |
4.3 Overlays y superficies (size = densidad/anchura del panel)
| Componente | R1 | R2 | R3 | R4 | R5 | Notas |
|---|---|---|---|---|---|---|
dialog |
✅ | ✅ | 🟡 Close | ✅ | 🟡 | propaga size al Close (ctx). Radio panel fijo (en full lo cambia, dialog.css:177). |
drawer |
✅ | ✅ | ❌ | ✅ | 🟡 | width/height/padding por size; radio fijo. |
popover |
✅ | ✅ | ❌ md | 🟡 | ❌ | radio --radius-md fijo. |
tooltip |
✅ | ✅ | ❌ md | ✅ | ❌ | py/px/font por size; radio fijo. |
dropdown/context-menu |
✅ | ✅ | 🟡 ctx | 🟡 | ❌ | list-surface ctx para anidados; usan data-shape-nest en items. |
menubar / navigation-menu |
✅ | ✅ | 🟡 ctx | 🟡 | ❌ | concéntrico parcial. |
command |
✅ | ✅ | ❌ md | ✅ | ❌ | input/item escalan; concéntrico en items. |
listbox |
✅ | ✅ | 🟡 ctx | 🟡 | ❌ | radio fijo. |
tabs |
✅ | ✅ | ❌ md | ✅ | ❌ | trigger/content/list/pills radio TODO fijo. |
toolbar |
✅ | ✅ | ❌ md | ✅ | ❌ | controles escalan; radios fijos. |
banner / toast / announce |
✅ | ✅ | ❌ md | ✅ | ❌ | radio fijo. |
4.4 Pickers y calendarios
| Componente | R1 | R2 | R3 | R4 | R5 | Notas |
|---|---|---|---|---|---|---|
calendar / range-calendar |
✅ | ✅ | ❌ md | ✅ | ❌ | padding/control/day/font por size; celdas radio fijo. |
month-grid / year-grid |
✅ | ✅ | ❌ md | ✅ | ❌ | escalan font/celda. |
date/time/date-range/time-range-picker |
✅ | ✅ | ❌ md | ✅ | ❌ | reusan tokens de field+calendar por size. |
color-picker |
✅ | ✅ | ❌ md | ✅ | ❌ | trigger/content/swatch por size. |
gradient-builder / gradient-picker |
✅ | ✅ | ❌ md | 🟡 | ❌ | (nuevos, sin commitear) radio fijo. |
4.5 Datos / navegación / feedback
| Componente | R1 | R2 | R3 | R4 | R5 | Notas |
|---|---|---|---|---|---|---|
accordion |
✅ | ✅ | ❌ md | ✅ | ❌ | trigger/content/indicator por size (incl. full); radio item fijo. |
breadcrumb |
✅ | ✅ | ❌ md | ✅ | — | solo gap+font (sin radio relevante). |
pagination |
✅ | ✅ | ❌ md | ✅ | ❌ | control radio fijo. |
tag-group |
✅ | ✅ | ❌ md | ✅ | ❌ | item radio fijo. |
tabs |
(ver 4.3) | |||||
table / tree-view / tree-grid / grid-list |
✅ | ✅ | ❌ md | ✅ | ❌ | densidad de fila por size; radios de celda/chip fijos. |
feed |
✅ | ✅ | ❌ md | ✅ | — | padding/indent/font por size. |
carousel |
🟡 | 🟡 | ❌ md | 🟡 | — | indicadores en rem literal (carousel.css:128-142). |
virtual-list / virtual-grid |
✅ | ✅ | ❌ md | ✅ | — | densidad por size. |
progress / meter |
🟡 | 🟡 | ❌ md | ✅ | — | grosor de barra px fijo (base.ts:1030); radio full (OK). |
spinner / skeleton |
✅ | ✅ | ❌ md | ✅ | — | radio circular/sm. |
avatar |
✅ | ✅ | ❌ md | ✅ | 🟡 | tamaño+font por size; radio via data-radius, no size. |
card |
✅ | ✅ | ❌ md | ✅ | ❌ | título/desc/body+padding por size; radio por rounded. |
card-group |
✅ | ✅ | ❌ md | ✅ | 🟡 | usa data-shape-nest (concéntrico). |
metrics / timeline |
✅ | ✅ | ❌ md | ✅ | ❌ | radios fijos. |
file-upload |
✅ | ✅ | ❌ md | ✅ | ❌ | gap/padding/control/preview por size. |
form |
✅ | ✅ | ❌ md | 🟡 | — | sizea gap+acciones; no propaga size a Fields (R3). |
4.6 Primitivas de layout / tipografía (sin eje size-densidad)
box · stack · flex · grid · wrap · group · float · aspect-ratio · auto-grid · container · section (layout) y text · heading · display · code · code-block · kbd · mark · highlight · link · separator (tipografía/inline):
- R1/R2: ✅ — escalan vía
var(--space-*)(layout) yvar(--font-size-*)/var(--style-*)(tipografía), todos scaling/densidad-coupled. - R3/R4/R5:
—— no exponensizediscreto de control (exceptocontainerdefaultxl/sectiondefaultlg, que son anchuras de layout, no densidad). - Radio relevante solo en
code-block/kbd/mark/link→--radius-sm/mdfijo (aceptable: no tienen eje de size).
5. Recomendaciones priorizadas
P0 — Herencia ambiental de size (cierra R3)
Introducir un contexto/cascada de size ambiental que un control lea cuando su
prop size es undefined, en lugar de hardcodear 'md'. Dos vías:
- Contexto Svelte genérico
SizeContext(comolist-surface-context.tspero universal), set por contenedores sizeados (Form,Card,Toolbar,Dialog…) y leído por cada wrapper:size = props.size ?? ctx?.size ?? 'md'. - O un token CSS heredable
--ui-size+data-sizeque cascadee por DOM (cuando no haya portal de por medio). Aplicar primero aForm → Field/controles(el caso más esperado) reutilizando la norma cap-en-mdya documentada (THEMING.md:560-579).
P1 — Acoplar el radio al size (cierra R5.a / R4-radio)
Decidir una doctrina de radio y aplicarla:
- Opción canónica (recomendada): consumir el
radiuspor size deSTATIC_SIZE— añadir--_{c}-radiusa cada cascada[data-size]como hacetoggle. Resucita el contrato--size-{k}-radiushoy huérfano. - Mantener
roundedcomo override explícito sobre ese default por size (no como sustituto), unificandobutton/cardcon el resto.
P1 — Eliminar el bundle --size-* huérfano o forzar su consumo
O bien (a) los recipes consumen --size-{k}-* en vez de re-declarar el mapeo
(un punto de verdad, mata el drift y la mentira del 14px), o bien (b) se poda el
bundle y se documenta que el canon vivo es el 1:1 por recipe. Hoy coexisten ambos
y se contradicen (THEMING.md:605).
P2 — Ampliar radios concéntricos (R5.c)
Extender [data-shape-nest] a las superficies anidadas obvias: contenido de
Dialog/Drawer, Field dentro de Form, calendar dentro de los pickers,
items de menús que aún no lo usan.
P3 — Tokenizar los literales residuales (R1/R2)
carouselindicadores:0.375rem…0.75rem→ tokens (--icon-size-*o--space-*) para que sigan--scaling/densidad (carousel.css:128-142).- Evaluar grosor de barra
progress/meter(base.ts:1030) — si debe seguir el zoom, pasarlo a un token escalado.
P3 — Guard de coherencia ampliado
El test actual solo prohíbe literales px en font-size/icon-size
(THEMING.md:608). Ampliarlo a padding/gap/radius de recipe y a una regla
"si hay [data-size], debe rebindear el radio" cerraría R4/R5 a nivel de CI.
6. Resumen de severidad
| Hallazgo | Regla | Severidad | Amplitud |
|---|---|---|---|
Sin herencia ambiental de size (default literal md) |
R3 | ALTA | ~todos los componentes |
Radio desacoplado del size (solo toggle lo acopla) |
R5.a / R4 | ALTA | ~todos los controles/superficies |
| 3 filosofías de radio sin doctrina única | R5.b | ALTA (coherencia) | toggle vs button/card vs resto |
Bundle --size-* huérfano y contradictorio |
transversal | MEDIA | foundation + 80 recipes |
| Concéntrico casi sin adoptar | R5.c | MEDIA | superficies anidadas |
| Literales rem/px (carousel, barras) | R1/R2 | BAJA | 2–3 componentes |
Conclusión: el sistema es fuerte en R1/R2 (la tokenización fuerza
scaling+densidad casi gratis) y débil justo donde el usuario apunta: la
herencia (R3 inexistente, R5.c marginal) y la coherencia del radio con el
size (R5.a/R4, con una inconsistencia de fondo entre toggle, button/card y
el resto). Las cuatro acciones P0–P1 resuelven el 80% del problema reutilizando
mecanismos que el framework ya tiene a medio cablear (el contexto de size de
dialog, el radius por size de STATIC_SIZE, el patrón toggle).