docs(eidos): inherit/scale coherence audit + fix plan

The audit that kicked off the radius-decoupling / archetype-:where / floating-gap
canon work — per-component matrix for the 5 inheritance rules (scaling, density,
ambient size, sub-component scale, concentric corners) + the corrected v2 doctrine
(radius = Radix decoupled-from-size, corners = Apple concentric, ambient = Ant).
active-uix
dev 3 months ago
parent 7bb1ad510b
commit 1b8fcd45a5

@ -0,0 +1,374 @@
# 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 evidencia
> `archivo: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_SIZE` mapea **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]` deriva `border-radius: max(0px, var(--shape-outer-radius)
− var(--shape-nest-gap))` (`render-css.ts:1153-1159`, `SHAPE_ENGINE_RFC.md` Fase 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 hermanos `lg` o dentro de un
bloque sizeado: salen `md`.
- `<Form size="lg">` **no** propaga el size a los `<Field>` / controles anidados
por contexto ambiental — el recipe `form` solo sizea su propio gap + sus
acciones (`form.css:25-51,118-137`), no inyecta un `size` a 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`:**
1. **El radio** — ausente de casi todas las cascadas `[data-size]` (ver R5). Solo
`toggle` lo rebindea.
2. **Decoraciones con literal** — `carousel` puntos 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 `rounded` independiente:** `button` y `card` declaran `--{c}-radius-{sm..xl}`
pero los cablean a `data-rounded` (default `md`), **no** a `data-size`
(`button.css:27,216-220`; `card.css:40,133-134`). → un `<Button size="xl">`
conserva esquinas `md`.
- **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
1. **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.
2. **Re-declaración del mapeo size→token en cada recipe.** Como nadie consume
`--size-{k}-*`, cada componente repite a mano `height/px/gap/font/radius` por
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**.
3. **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) y `var(--font-size-*)` /
`var(--style-*)` (tipografía), todos scaling/densidad-coupled.
- **R3/R4/R5:** `—` — no exponen `size` discreto de control (excepto `container`
default `xl` / `section` default `lg`, que son anchuras de layout, no densidad).
- Radio relevante solo en `code-block`/`kbd`/`mark`/`link` → `--radius-sm/md`
fijo (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` (como `list-surface-context.ts` pero
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-size` que cascadee por DOM (cuando
no haya portal de por medio).
Aplicar primero a `Form → Field/controles` (el caso más esperado) reutilizando la
norma cap-en-`md` ya 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 `radius` por size de
`STATIC_SIZE` — añadir `--_{c}-radius` a cada cascada `[data-size]` como hace
`toggle`. Resucita el contrato `--size-{k}-radius` hoy huérfano.
- Mantener `rounded` como **override** explícito sobre ese default por size
(no como sustituto), unificando `button`/`card` con 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)
- `carousel` indicadores: `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`).

@ -0,0 +1,135 @@
# Plan de corrección — herencia / escala (Eidos) · **v2, corregido por diseño + referencias**
> **Compañero de** [`inherit_audit.md`](inherit_audit.md). · **Fecha:** 2026-06-27
> · **Excluidos:** `words/**`, `palabras/**`, `chronos/**` (WIP).
>
> **Esta v2 reemplaza la doctrina de radio de la v1.** La v1 acoplaba el radio al
> `size` del control (sesgo de statu quo, anclado en `toggle`). La investigación de
> 10 frameworks de referencia (Radix, Material 3, Apple HIG, Ant, MUI, Tailwind,
> Carbon, Fluent, Chakra, Mantine — verificada adversarialmente) demostró que **eso
> es un outlier**: nadie escala el radio sin tope con el size. La doctrina correcta,
> decidida por el usuario, es el **modelo Radix** (radio = eje propio, desacoplado
> del size) + **herencia concéntrica del padre** (modelo Apple).
>
> Cada decisión va con cita de diseño (`THEMING.md`/RFC) **o** de referencia (URL).
---
## 0. Doctrina raíz (corregida)
> **Un componente deriva su GEOMETRÍA + TIPO + ICONO del primitivo de size
> `--size-{k}-*` (que compone densidad×scaling); si no fija su size, HEREDA el del
> padre (publishers deliberados, cadena Ant). El RADIO es un eje propio
> —default por arquetipo × factor de tema `radius` × override `rounded`—
> DESACOPLADO del size, y se HEREDA del padre de forma CONCÉNTRICA cuando anida
> (`inner = max(0, outer − gap)`, modelo Apple).**
**Decisiones cerradas:**
- ✅ `--size-md-font-size = 16px` (1:1; el texto de control sigue la escala tipográfica — campo "font-in-size" de Chakra/Ant/Radix). El compact-md 14px queda muerto.
- ✅ **Radio = modelo Radix** (Opción A): eje propio, desacoplado del `size`.
**Reencuadre clave:** tu **regla 5** ("las esquinas heredan del padre; los radios concéntricos") **no** pide "el radio escala con el size propio" (eso era R4, ya cubierto por el size recipe). Pide **herencia del padre + concentricidad** — que es exactamente el modelo de Apple (`ContainerRelativeShape`/`ConcentricRectangle`) y el helper `[data-shape-nest]` que eidos **ya tiene**. El falso problema "cap-at-md vs full-scale" desaparece: el radio no es función del size.
---
## 1. Qué fijan el diseño + las referencias (resumen citado)
| Eje | Referencia (cita) | eidos hoy | Acción |
|---|---|---|---|
| **scaling** (R1) | Radix `scaling` 90-110% escala todo incl. tipo ([radix/spacing](https://www.radix-ui.com/themes/docs/theme/spacing)). | `--scaling` idéntico. | ✅ validado; solo higiene (literales). |
| **densidad** (R2) | M3/Carbon/Fluent: altura/spacing cambian, **texto estable** ([m3/density](https://m3.material.io/foundations/layout/understanding-layout/density)). | density compact/comfortable/spacious, texto estable. | ✅ validado. **eidos por delante**: tener densidad **Y** scaling es la unión que ningún referente ofrece. |
| **size → internos** (R4) | Chakra/Ant/Radix meten font en el size recipe ([chakra/sizes](https://chakra-ui.com/docs/theming/sizes)). | recipes por-size (font incl.). | ✅ 1:1 (md=16) confirma el campo. |
| **heredar size** (R3) | Ant `ConfigProvider.componentSize` global; cadena `explícito > grupo > contexto > md` ([ant/config](https://ant.design/components/config-provider/)). Publishers = contenedores **concretos** (Form, input-group, group). | hardcode `md`; solo 3 contextos ad-hoc. | **WS-A**. |
| **radio** (R5.a/b) | Radix: radio = factor de tema × paso por rol, **independiente del size**; `full`→pill, checkbox capado ([radix/radius](https://www.radix-ui.com/themes/docs/theme/radius)). Ningún referente escala radio sin tope. | 3 filosofías; `STATIC_SIZE` lo escala 7 pasos (**outlier**). | **WS-B** (modelo Radix). |
| **esquinas concéntricas** (R5) | Apple `inner = max(0, outer − gap)`, hijo deriva del padre ([apple/ConcentricRectangle](https://developer.apple.com/documentation/swiftui/concentricrectangle)); canon web idéntico ([Cloud Four](https://cloudfour.com/thinks/the-math-behind-nesting-rounded-corners/)). | `[data-shape-nest]` = ese modelo, **reference-grade**, pero solo 5/80. | **WS-C**. |
---
## 2. Workstreams
### WS-A — Herencia ambiental de `size` (R3) · esfuerzo L · pilot `Form→Field`
**Verificado.** Todo wrapper resuelve con fallback literal `'md'` (`eidos.resolve(size,'md')`, `active-eidos.svelte.ts:540`), sin mirar al padre. Solo 3 contextos ad-hoc (`dialog/context.ts`, `lib/list-surface-context.ts`, `toggle-group/context.ts`). 124 archivos usan el idioma.
**Diseño (modelo Ant, no "toda caja emite"):**
1. Nuevo `src/uix/eidos/lib/ui-size-context.ts` (espejo de `list-surface-context.ts`, getter reactivo) + `clampSize<S>(size, allowed, fallback)` para el subset por componente.
2. **Publishers DELIBERADOS** (no cualquier caja): `Form`, los grupos (button-group/toggle-group ya), overlays (Dialog/Drawer/Popover content como provider de su propio size), y un **provider global tipo `ConfigProvider.componentSize`** (la superficie "size por defecto de la app"). Esto es lo que las referencias hacen; un `<Button>` cualquiera **no** debe rebroadcast su size a descendientes no relacionados.
3. **Cadena de prioridad (Ant, documentar):** `prop explícita > grupo/compound más cercano > provider ambiental > 'md'`. Reader: `eidos.resolve(size ?? group?.size ?? ambient?.getSize(), 'md')` — la ambiental **como fallback de `resolve`**, nunca como `size ?? ctx` (rompería `resolveResponsiveProp` en los 124 sitios).
4. **Cambio de API:** lectores pasan de `size = 'md'` a `size?: Size` (default `undefined`) para detectar "no fijado". *(sign-off)*
5. **Cap-at-md** (`THEMING.md:560`) aplica a su ámbito real —derivación contenedor→parte en **overlays** (un dialog `full` no engorda su Close)— **no** necesariamente a la herencia en-flujo (`<Form size="lg">` probablemente quiere controles `lg`). Política por tipo de publisher. *(sign-off)*
6. **Portal-reset:** los roots de overlay resetean el contexto a su propio size (no filtran el del Form ancestro al panel portalizado). *(recomendado: sí)*
**Pilot:** `Form` SET + `Field`/`Checkbox` READ; verificar en navegador `<Form size="lg">` → controles `lg`. Luego el resto.
### WS-B — Radio = modelo Radix (R5.a/b) · esfuerzo M · pilot `button` + `card`
**El radio es un eje propio, desacoplado del `size`.** Cuatro piezas (Radix verbatim):
1. **Default por arquetipo/rol** — cada componente tiene un radio de reposo por su arquetipo (control, chip, panel…), no por su size. Encaja con el vocabulario de arquetipos ya existente (`ARCHETYPE_COHERENCE_AUDIT`).
2. **× factor de tema `radius`** (`none|small|medium|large|full`) — **verificar si existe**; si no, **añadir** un `--radius-factor` global à la Radix (`--radius-factor` 0/0.75/1/1.5/1.5 sobre la escala `--radius-*`). Es la perilla de tema que falta.
3. **Override `rounded` per-instancia** — `button`/`card` ya lo tienen (`button.css:216-220`, `card.css:133-134`); **generalizarlo** como la jaula abierta (= prop `radius` de Radix/Mantine/Chakra).
4. **`full`/pill = cápsula** (caso especial size-aware, autocapado: ½ altura / 9999px) con **caps por parte** (checkbox nunca totalmente redondo, modelo Radix).
**Migración (DES-acoplar, lo contrario de la v1):**
- **Quitar** el rebinding de radio de las cascadas `[data-size]` — empezando por `toggle` (`toggle.css:64-98` rebindea `--_toggle-radius` por size: **ese era el outlier**, se elimina). El radio pasa a venir del default de arquetipo + factor + `rounded`.
- **Sacar el radio del bundle de size:** `STATIC_SIZE.radius` / `--size-{k}-radius` dejan de ser fuente del radio (eran el outlier). Se repurposan como tabla de default-por-arquetipo o se podan.
- Unifica las 3 filosofías bajo la de Radix; `button`/`card` quedan como la referencia (ya casi lo eran).
**Pilot:** `button` + `card` (los más cercanos a Radix). Verificar esquinas a xs..xl + que `rounded`/`full` siguen ganando. Es **cambio visual** → screenshots por size. *(sign-off del look)*
### WS-C — Esquinas concéntricas heredadas del padre (R5) · esfuerzo M · pilot `dropdown-menu`
**Verificado.** El helper `[data-shape-nest] { border-radius: max(0px, calc(var(--shape-outer-radius) − var(--shape-nest-gap))) }` (`render-css.ts:1153-1159`) es **el modelo de Apple, reference-grade**. Adoptan 5; debería ser ~20-40 (los que de verdad anidan una superficie redondeada en un padre redondeado con padding).
**Diseño:** dos partes — el padre SETea `--shape-outer-radius` (+ `--shape-nest-gap` = su padding); el hijo añade `data-shape-nest` y borra su `border-radius` propio. Es **la herencia del padre que pide tu regla 5**, y compone con WS-B (un parte usa concéntrico cuando anida; el default de arquetipo cuando no).
**Adoptar** (los que anidan de verdad): items de `dropdown-menu`/`context-menu`/`listbox`/`navigation-menu` (in-bar), cards en cards, contenido interno de paneles, etc. **Excluir:** flush/gap-cero (radio compartido legítimo), y padres con radio `full`/9999px (`SHAPE_ENGINE_RFC §5`: el cálculo es ilegible a 9999 → excluir dialog/drawer `full`/`xl`).
**Pilot:** `dropdown-menu` (espejar `command.css:230-232`). Verificar concéntrico en navegador. Luego el resto, uno a uno.
### WS-D — Consumir el bundle de size (geometría/tipo/icono) + fijar font · esfuerzo S→M
**Verificado.** El bundle `--size-{k}-*` se emite (`render-css.ts:1328`) con **0 consumidores**; cada recipe re-declara el mapeo (≈417 tokens). `THEMING.md:615-616` dice que consumirlo es el follow-up previsto.
1. **Inmediato:** `STATIC_SIZE.fontSize` → 1:1 (`md:'sm'→'md'`, etc., `static.ts:423`); actualizar test + regen `generated/base.css`. Mata la mentira 14px.
2. **Consumir** en los recipes los slots **control-height / font-size / icon-size / padding / gap** (= "size recipe" de Chakra; mata la duplicación). Recipes que se desvían a propósito (ladder de px de `button`) quedan explícitos.
3. **El slot `radius` SALE del bundle** (WS-B): el radio ya no es size-keyed.
### WS-E — Densidad + scaling: validar + higiene · esfuerzo S
eidos está **alineado y por delante** (ambos ejes; ningún referente tiene los dos). Acción = documentar + higiene:
- Documentar que densidad = texto estable y scaling = escala texto (mantener ortogonales), con pisos de a11y (targets ≥ tamaño mínimo) — recomendación M3/Carbon.
- Tokenizar literales residuales: `carousel` indicadores `0.375rem…` → tokens (`carousel.css:128-142`); revisar grosor de barra `progress`/`meter` (`base.ts:1030`).
---
## 3. Guards de CI (extender `recipe-css-contract.test.ts`)
1. **(WS-A)** todo wrapper con prop `size` resuelve vía la cadena ambiental (no `'md'` literal suelto).
2. **(WS-B)** ningún recipe rebindea `radius` en sus bloques `[data-size]` (el radio está desacoplado del size — **invierte el guard equivocado de la v1**).
3. **(WS-C)** un hijo redondeado anidado en un padre redondeado con padding usa `data-shape-nest` o está en la lista de exención (flush / pills / `full`).
4. **(WS-D/E)** sin literales px/rem en tokens de geometría/tipo de recipe; todo `--size-{k}-{slot}` emitido tiene ≥1 consumidor (o se poda).
---
## 4. Secuencia (pilot-first siempre; nunca cascada)
`WS-D paso 1 (font 1:1, trivial)` → **`WS-B (radio Radix, pilot button/card)`** → **`WS-C (concéntrico, pilot dropdown-menu)`** → **`WS-A (size ambiental, pilot Form→Field)`** → `WS-D pasos 2-3 (consumir bundle)` → `WS-E (docs+higiene)`.
B y C son R5 (van juntos: B fija el default de arquetipo, C la herencia concéntrica). A es independiente (R3) y puede solaparse. D-paso-1 primero por trivial; D-pasos-2-3 tras B (que saca el radio del bundle).
---
## 5. Verificación
- **Tipos/lógica:** `npm run check` + `npx vitest run src/uix/eidos` (+ scope del componente).
- **Visual (obligatorio en B y C):** screenshot/preview **por size**; el radio es visible y `npm run check` no lo valida. Medir, no ojímetro (precedente toggle-group: computed values bit-a-bit).
- **Pilot-first**: un componente, verificar en navegador, luego propagar.
---
## 6. Sign-offs restantes (tuyos)
1. `size?: Size` (default `undefined`) en lectores — cambio de API pública.
2. **Provider global `componentSize`** (à la Ant ConfigProvider) además de los publishers por-contenedor — ¿lo incluimos como superficie "size por defecto de la app"?
3. **Cap-at-md**: ¿solo overlays (un `full` no engorda su parte) y la herencia en-flujo (`Form size="lg"`) sigue 1:1? (recomendado).
4. **Factor de tema `radius`** (none…full, à la Radix): ¿lo añadimos si no existe? (recomendado — es la perilla que cierra el modelo Radix).
5. Cambio visual de WS-B (esquinas de button/card dejan de moverse con el size; pasan a default-de-arquetipo + `rounded`).
Loading…
Cancel
Save

Powered by TurnKey Linux.